Initialize the API and add managed app banners.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-09 17:08:47 +03:30
co-authored by Cursor
commit b15fefe438
158 changed files with 25624 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
# پیش‌فرض همه چیز ignore؛ فقط آنچه برای Docker لازم است را باز کن
*
!deploy-bundle.tar.gz
!prisma-client.tar.gz
!Dockerfile
!docker-compose.yml
!docker-entrypoint.sh
!dist
!dist/**
!package.json
!package-lock.json
!prisma
!prisma/**
!prisma.config.ts
!.env.example
+38
View File
@@ -0,0 +1,38 @@
# این فایل فقط روی سرور/لوکال می‌ماند و داخل گیت commit نمی‌شود.
# ایمیج Docker سکرت ندارد؛ همه تنظیمات از اینجا خوانده می‌شود.
POSTGRES_USER=fabric
POSTGRES_PASSWORD=fabric123
POSTGRES_DB=fabric_trade
POSTGRES_PORT=5432
# برای ابزارهای روی خود سرور (خارج از Docker)
DATABASE_URL=postgresql://fabric:fabric123@localhost:5432/fabric_trade?schema=public
API_PORT=8585
PORT=3000
# پشت LiteSpeed با URI=/server این را بگذار:
GLOBAL_PREFIX=server/api
API_IMAGE=fabric-trade-api:latest
RUN_SEED=false
JWT_SECRET=change-me-to-a-long-random-string
JWT_EXPIRES_IN=7d
MOCK_OTP_CODE=1234
OTP_TTL_MINUTES=5
# پیامک OTP (مثل آبشده خانگی — rayegansms / createsms)
SMS_ENABLED=true
SMS_API_URL=http://185.231.112.101/api/createsms
SMS_SECRET_KEY=your-sms-secret-key
SMS_PLATFORM=rayegansms
SMS_HOST=پارچه
SMS_APP_NAME=پارچه
# سقف حجم بدنه JSON (تصاویر چک و رسید base64 هستند)
BODY_LIMIT=25mb
TZ=Asia/Tehran
NODE_ENV=production
NPM_REGISTRY=https://registry.npmmirror.com
COMPOSE_BAKE=false
+50
View File
@@ -0,0 +1,50 @@
# compiled output — dist را برای دیپلوی روی گیت می‌فرستیم
# /dist
/dist-docker
/node_modules
/build
/generated
.deploy-pack
server-runtime.tgz
# logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
# tests
/coverage
/.nyc_output
# IDEs
.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# env
.env
.env.local
.env.*.local
# local postgres data if any
.pgdata
.tools
# misc
*.tsbuildinfo
+4
View File
@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}
+33
View File
@@ -0,0 +1,33 @@
# bookworm کامل تا OpenSSL آماده باشد (بدون apt روی سرور ایران)
FROM node:22-bookworm
WORKDIR /app
ENV NODE_ENV=production
ENV TZ=Asia/Tehran
COPY deploy-bundle.tar.gz /tmp/deploy-bundle.tar.gz
RUN tar -xzf /tmp/deploy-bundle.tar.gz -C /app \
&& rm /tmp/deploy-bundle.tar.gz
# هیچ دستور prisma یا npm روی سرور اجرا نمی‌شود؛ همه چیز از قبل ساخته آمده است
ENV CHECKPOINT_DISABLE=1
ENV DO_NOT_TRACK=1
ENV PRISMA_HIDE_UPDATE_MESSAGE=1
COPY package.json package-lock.json prisma.config.ts /app/
COPY prisma /app/prisma
# کلاینت Prisma روی سیستم دولوپر با scripts/pack-prisma-client.sh ساخته می‌شود
COPY prisma-client.tar.gz /tmp/prisma-client.tar.gz
RUN rm -rf /app/node_modules/.prisma/client \
&& mkdir -p /app/node_modules/.prisma \
&& tar -xzf /tmp/prisma-client.tar.gz -C /app/node_modules/.prisma \
&& rm /tmp/prisma-client.tar.gz
# dist آخر باشد تا دیپلوی معمولی فقط همین لایه را دوباره بسازد
COPY dist /app/dist
COPY docker-entrypoint.sh /app/docker-entrypoint.sh
RUN chmod +x /app/docker-entrypoint.sh
EXPOSE 3000
ENTRYPOINT ["./docker-entrypoint.sh"]
+17
View File
@@ -0,0 +1,17 @@
# فقط کپی فایل‌های آماده‌شده — بدون npm
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
COPY node_modules ./node_modules
COPY prisma ./prisma
COPY prisma.config.ts ./
COPY dist ./dist
COPY docker-entrypoint.sh ./
RUN chmod +x docker-entrypoint.sh
EXPOSE 3000
ENTRYPOINT ["./docker-entrypoint.sh"]
+32
View File
@@ -0,0 +1,32 @@
# ایمیج اجرا: dist از گیت می‌آید، فقط dependencyهای production نصب می‌شود
ARG NPM_REGISTRY=https://registry.npmmirror.com
FROM node:22-alpine
ARG NPM_REGISTRY=https://registry.npmmirror.com
WORKDIR /app
ENV NODE_ENV=production
ENV NPM_CONFIG_REGISTRY=${NPM_REGISTRY}
COPY package.json package-lock.json ./
COPY prisma ./prisma
COPY prisma.config.ts ./
RUN set -eux; \
mirror="$(echo "${NPM_REGISTRY}" | sed 's:/*$::')"; \
sed -i \
-e "s|https://registry.npmjs.org|${mirror}|g" \
-e "s|https://registry.npmmirror.com|${mirror}|g" \
package-lock.json; \
npm config set registry "${mirror}"; \
npm ci --omit=dev --no-audit --no-fund; \
npm install prisma ts-node typescript --no-save --no-audit --no-fund --registry="${mirror}"; \
npx prisma generate
# بیلد لوکال که داخل گیت است
COPY dist ./dist
COPY docker-entrypoint.sh ./
RUN chmod +x docker-entrypoint.sh
EXPOSE 3000
ENTRYPOINT ["./docker-entrypoint.sh"]
+123
View File
@@ -0,0 +1,123 @@
# Fabric Trade Server
بک‌اند NestJS + Prisma + PostgreSQL برای اپ کاربر و پنل ادمین تجارت پارچه.
## پیش‌نیاز
- Node.js 20+
- Docker (برای PostgreSQL)
اگر به Docker دسترسی ندارید:
```bash
sudo usermod -aG docker "$USER"
# سپس یک‌بار logout/login کنید
```
## راه‌اندازی سریع (لوکال بدون Docker برای API)
```bash
cd fabric_trade_server
docker compose up -d postgres
npm install
npx prisma migrate deploy
npm run prisma:seed
npm run start:dev
```
## دیپلوی: بیلد روی سیستم خودت، اجرا روی سرور
روی سرور هیچ `npm` و هیچ دستور `prisma` اجرا نمی‌شود. سه چیز از قبل ساخته و روی
گیت هستند: `dist`، `prisma-client.tar.gz` (کلاینت Prisma) و
`deploy-bundle.tar.gz` (`node_modules` پروداکشن).
**۱) روی سیستم خودت:**
```bash
cd fabric_trade_server
npm run deploy:build
git add dist prisma-client.tar.gz && git commit -m "..." && git push
```
**۲) روی سرور:**
```bash
cd /opt/parche/server
git pull
docker compose up -d --build
docker compose logs -f api
```
`Dockerfile` باندل را باز می‌کند، بعد `dist` و `prisma` و کلاینت آماده‌ی Prisma
را روی آن می‌نویسد. `prisma generate` روی هاست پروداکشن چند دقیقه طول می‌کشد،
پس عمداً آنجا اجرا نمی‌شود.
هر بار که `prisma/schema.prisma` را عوض کردی، `npm run deploy:build` را بزن تا
`prisma-client.tar.gz` هم به‌روز شود؛ وگرنه سرور با کلاینت قدیمی بالا می‌آید.
**فقط اگر پکیج npm جدید اضافه شد** باندل را دوباره بساز و push کن:
```bash
bash scripts/pack-bundle.sh
git add deploy-bundle.tar.gz && git commit -m "refresh deploy bundle" && git push
```
- `env` سرور از `.env` همان پوشه خوانده می‌شود (`env_file` در compose)
- API روی `127.0.0.1:${API_PORT}` بالا می‌آید (سرور: `8585`)
- مایگریشن موقع استارت کانتینر اجرا می‌شود
- LiteSpeed را Reverse Proxy کنید به `http://127.0.0.1:8585`
مثال مسیر LiteSpeed: `https://domain.com/api/` → `http://127.0.0.1:3000/api/`
- API: http://localhost:3000/api
- Swagger: http://localhost:3000/api/docs
## حساب‌های تست (پس از seed)
| نقش | ورود | رمز / OTP |
|------|------|-----------|
| ادمین | `09128081392` | `123456` |
| مشتری | `09121234567` | OTP پیامکی (۴ رقم) — با `SMS_ENABLED=false` کد ثابت: `1234` |
## متغیرهای محیطی
فایل `.env`:
```
DATABASE_URL=postgresql://fabric:fabric123@localhost:5432/fabric_trade?schema=public
JWT_SECRET=fabric-trade-dev-secret-change-me
JWT_EXPIRES_IN=7d
PORT=3000
MOCK_OTP_CODE=1234
OTP_TTL_MINUTES=5
SMS_ENABLED=true
SMS_API_URL=http://185.231.112.101/api/createsms
SMS_SECRET_KEY=...
SMS_PLATFORM=rayegansms
SMS_HOST=پارچه
SMS_APP_NAME=پارچه
```
## اسکریپت‌ها
| دستور | کار |
|--------|-----|
| `npm run db:up` | بالا آوردن Postgres |
| `npm run prisma:migrate` | مایگریشن توسعه |
| `npm run prisma:seed` | دیتای فیک |
| `npm run start:dev` | سرور با watch |
| `npm run db:reset` | ریست کامل دیتابیس |
## ساختار API (خلاصه)
### کاربر (Bearer JWT مشتری)
- `POST /api/auth/otp/request` / `verify`
- `GET /api/auth/me` · `PATCH /api/auth/profile`
- `GET /api/companies` · catalogs · products · bank-accounts
- `GET/POST /api/carts` …
- `GET/POST /api/orders` · `POST /api/orders/:id/cash-receipt`
### ادمین (Bearer JWT ادمین)
- `POST /api/admin/auth/login`
- `GET /api/admin/dashboard` · `/admin/reports` · `/admin/settings`
- CRUD: companies, catalogs, products, customers, users, orders, checks, cash-payments, shipments
Binary file not shown.
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
MIRROR="${NPM_REGISTRY:-https://registry.npmmirror.com}"
MIRROR="${MIRROR%/}"
NODE_VERSION="${NODE_VERSION:-22.14.0}"
NODE_DIR="${PWD}/.tools/node"
NODE_BIN="${NODE_DIR}/bin"
echo "==> npm registry: $MIRROR"
# Node پرتابل از میرور (بدون apt و بدون docker pull)
if [ ! -x "${NODE_BIN}/node" ]; then
echo "==> دانلود Node ${NODE_VERSION} از npmmirror..."
mkdir -p .tools
curl -fL --retry 5 --retry-delay 2 \
"https://npmmirror.com/mirrors/node/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz" \
-o /tmp/node.tar.xz
rm -rf "${NODE_DIR}"
mkdir -p .tools
tar -xJf /tmp/node.tar.xz -C .tools
mv ".tools/node-v${NODE_VERSION}-linux-x64" "${NODE_DIR}"
rm -f /tmp/node.tar.xz
fi
export PATH="${NODE_BIN}:${PATH}"
echo "==> $(node -v) / npm $(npm -v)"
echo "==> آماده‌سازی package-lock برای میرور..."
sed -i \
-e "s|https://registry.npmjs.org|${MIRROR}|g" \
-e "s|https://mirror.iranserver.com/npm|${MIRROR}|g" \
package-lock.json
npm config set registry "$MIRROR"
npm config set fetch-retries 5
npm config set fetch-retry-mintimeout 20000
npm config set fetch-retry-maxtimeout 120000
npm config set progress true
npm config set loglevel info
echo "==> npm ci روی دیسک سرور (سریع‌تر از داخل Docker)..."
npm ci --no-audit --no-fund
echo "==> prisma generate + build..."
npx prisma generate
npm run build
npm prune --omit=dev
npm install prisma ts-node typescript --no-save --no-audit --no-fund --registry="$MIRROR"
echo "==> Docker فقط برای اجرا (Postgres + API)..."
export RUN_SEED="${RUN_SEED:-true}"
# برای بیلد prebuilt به ایمیج پایه node نیاز است؛ اگر نباشد از هاب می‌گیرد
if ! docker image inspect node:22-alpine >/dev/null 2>&1; then
echo "==> pull node:22-alpine برای ایمیج نهایی..."
docker pull node:22-alpine || true
fi
docker compose -f docker-compose.prebuilt.yml up -d --build
echo ""
echo "OK → curl http://127.0.0.1:3000/api/health"
echo "logs → docker compose -f docker-compose.prebuilt.yml logs -f api"
+191
View File
@@ -0,0 +1,191 @@
import type { AuthPrincipal } from '../common/decorators/current-user.decorator';
import { AgentService } from './agent.service';
import { CompleteReminderDto, CreateMeetingDto, CreateReminderDto } from './dto/agent.dto';
export declare class AgentController {
private readonly agentService;
constructor(agentService: AgentService);
categories(): Promise<{
usage: {
id: string;
title: string;
subtitle: string;
}[];
seasons: {
id: string;
title: string;
description: string;
}[];
}>;
profile(user: AuthPrincipal): Promise<{
id: string;
firstName: string;
lastName: string;
code: string;
levelLabel: string;
nextLevelLabel: string;
pointsToNext: number;
levelProgress: number;
phone: string;
customersCount: number;
ordersCount: number;
salesVolumeToman: number;
profitToman: number;
periodLabel: string;
}>;
customers(user: AuthPrincipal, q?: string, status?: string): Promise<{
id: string;
name: string;
contactName: string;
phone: string;
status: string;
avatarLetter: string;
avatarColor: string;
totalPurchaseToman: number;
orderCount: number;
averageOrderToman: number;
levelLabel: string;
firstPurchaseDate: string;
agentCode: string | undefined;
agentName: string | undefined;
}[]>;
customerDetail(user: AuthPrincipal, id: string): Promise<{
customer: {
id: string;
name: string;
contactName: string;
phone: string;
status: string;
avatarLetter: string;
avatarColor: string;
totalPurchaseToman: number;
orderCount: number;
averageOrderToman: number;
levelLabel: string;
firstPurchaseDate: string;
agentCode: string | null | undefined;
agentName: string | undefined;
};
orders: {
id: string;
customerId: string;
title: string;
amountToman: number;
statusLabel: import("@prisma/client").$Enums.OrderStatus;
statusTone: "info";
date: string;
}[];
meetings: {
id: string;
customerId: string;
type: import("@prisma/client").$Enums.InteractionType;
title: string;
description: string;
date: string;
time: string | undefined;
}[];
}>;
createMeeting(user: AuthPrincipal, dto: CreateMeetingDto): Promise<{
meeting: {
id: string;
createdAt: Date;
updatedAt: Date;
customerId: string;
agentId: string;
type: import("@prisma/client").$Enums.InteractionType;
title: string;
description: string | null;
subject: string | null;
content: string | null;
date: string;
time: string | null;
};
reminder: {
id: string;
createdAt: Date;
updatedAt: Date;
customerId: string;
title: string;
description: string | null;
date: string;
time: string;
note: string | null;
interactionType: import("@prisma/client").$Enums.InteractionType;
completed: boolean;
completedAt: Date | null;
ownerId: string;
} | null;
}>;
reminders(user: AuthPrincipal, filter?: string): Promise<{
id: string;
customerId: string;
title: string;
description: string;
time: string;
date: string;
interactionType: import("@prisma/client").$Enums.InteractionType;
completed: boolean;
note: string | undefined;
customerName: string;
contactName: string;
avatarLetter: string;
avatarColor: string;
}[]>;
reminderDetail(user: AuthPrincipal, id: string): Promise<{
reminder: {
id: string;
customerId: string;
title: string;
description: string;
time: string;
date: string;
interactionType: import("@prisma/client").$Enums.InteractionType;
completed: boolean;
note: string | undefined;
};
customer: {
id: string;
name: string;
contactName: string;
phone: string;
avatarLetter: string;
avatarColor: string;
};
history: {
id: string;
type: import("@prisma/client").$Enums.InteractionType;
title: string;
description: string;
date: string;
}[];
}>;
createReminder(user: AuthPrincipal, dto: CreateReminderDto): Promise<{
id: string;
createdAt: Date;
updatedAt: Date;
customerId: string;
title: string;
description: string | null;
date: string;
time: string;
note: string | null;
interactionType: import("@prisma/client").$Enums.InteractionType;
completed: boolean;
completedAt: Date | null;
ownerId: string;
}>;
completeReminder(user: AuthPrincipal, id: string, dto: CompleteReminderDto): Promise<{
id: string;
createdAt: Date;
updatedAt: Date;
customerId: string;
title: string;
description: string | null;
date: string;
time: string;
note: string | null;
interactionType: import("@prisma/client").$Enums.InteractionType;
completed: boolean;
completedAt: Date | null;
ownerId: string;
}>;
}
+2
View File
@@ -0,0 +1,2 @@
export declare class AgentModule {
}
+191
View File
@@ -0,0 +1,191 @@
import { PrismaService } from '../prisma/prisma.service';
import { CompleteReminderDto, CreateMeetingDto, CreateReminderDto } from './dto/agent.dto';
export declare class AgentService {
private readonly prisma;
constructor(prisma: PrismaService);
private requireAgent;
getProfile(agentId: string): Promise<{
id: string;
firstName: string;
lastName: string;
code: string;
levelLabel: string;
nextLevelLabel: string;
pointsToNext: number;
levelProgress: number;
phone: string;
customersCount: number;
ordersCount: number;
salesVolumeToman: number;
profitToman: number;
periodLabel: string;
}>;
listMyCustomers(agentId: string, q?: string, status?: string): Promise<{
id: string;
name: string;
contactName: string;
phone: string;
status: string;
avatarLetter: string;
avatarColor: string;
totalPurchaseToman: number;
orderCount: number;
averageOrderToman: number;
levelLabel: string;
firstPurchaseDate: string;
agentCode: string | undefined;
agentName: string | undefined;
}[]>;
getCustomerDetail(agentId: string, customerId: string): Promise<{
customer: {
id: string;
name: string;
contactName: string;
phone: string;
status: string;
avatarLetter: string;
avatarColor: string;
totalPurchaseToman: number;
orderCount: number;
averageOrderToman: number;
levelLabel: string;
firstPurchaseDate: string;
agentCode: string | null | undefined;
agentName: string | undefined;
};
orders: {
id: string;
customerId: string;
title: string;
amountToman: number;
statusLabel: import("@prisma/client").$Enums.OrderStatus;
statusTone: "info";
date: string;
}[];
meetings: {
id: string;
customerId: string;
type: import("@prisma/client").$Enums.InteractionType;
title: string;
description: string;
date: string;
time: string | undefined;
}[];
}>;
createMeeting(agentId: string, dto: CreateMeetingDto): Promise<{
meeting: {
id: string;
createdAt: Date;
updatedAt: Date;
customerId: string;
agentId: string;
type: import("@prisma/client").$Enums.InteractionType;
title: string;
description: string | null;
subject: string | null;
content: string | null;
date: string;
time: string | null;
};
reminder: {
id: string;
createdAt: Date;
updatedAt: Date;
customerId: string;
title: string;
description: string | null;
date: string;
time: string;
note: string | null;
interactionType: import("@prisma/client").$Enums.InteractionType;
completed: boolean;
completedAt: Date | null;
ownerId: string;
} | null;
}>;
listReminders(agentId: string, filter?: string): Promise<{
id: string;
customerId: string;
title: string;
description: string;
time: string;
date: string;
interactionType: import("@prisma/client").$Enums.InteractionType;
completed: boolean;
note: string | undefined;
customerName: string;
contactName: string;
avatarLetter: string;
avatarColor: string;
}[]>;
getReminder(agentId: string, reminderId: string): Promise<{
reminder: {
id: string;
customerId: string;
title: string;
description: string;
time: string;
date: string;
interactionType: import("@prisma/client").$Enums.InteractionType;
completed: boolean;
note: string | undefined;
};
customer: {
id: string;
name: string;
contactName: string;
phone: string;
avatarLetter: string;
avatarColor: string;
};
history: {
id: string;
type: import("@prisma/client").$Enums.InteractionType;
title: string;
description: string;
date: string;
}[];
}>;
createReminder(agentId: string, dto: CreateReminderDto): Promise<{
id: string;
createdAt: Date;
updatedAt: Date;
customerId: string;
title: string;
description: string | null;
date: string;
time: string;
note: string | null;
interactionType: import("@prisma/client").$Enums.InteractionType;
completed: boolean;
completedAt: Date | null;
ownerId: string;
}>;
completeReminder(agentId: string, reminderId: string, dto: CompleteReminderDto): Promise<{
id: string;
createdAt: Date;
updatedAt: Date;
customerId: string;
title: string;
description: string | null;
date: string;
time: string;
note: string | null;
interactionType: import("@prisma/client").$Enums.InteractionType;
completed: boolean;
completedAt: Date | null;
ownerId: string;
}>;
listCategories(): Promise<{
usage: {
id: string;
title: string;
subtitle: string;
}[];
seasons: {
id: string;
title: string;
description: string;
}[];
}>;
}
+363
View File
@@ -0,0 +1,363 @@
"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.AgentService = void 0;
const common_1 = require("@nestjs/common");
const client_1 = require("@prisma/client");
const prisma_service_1 = require("../prisma/prisma.service");
let AgentService = class AgentService {
prisma;
constructor(prisma) {
this.prisma = prisma;
}
async requireAgent(agentId) {
const agent = await this.prisma.customer.findUnique({
where: { id: agentId },
});
if (!agent || agent.appRole !== client_1.AppUserRole.agent) {
throw new common_1.ForbiddenException('فقط واسطه به این بخش دسترسی دارد');
}
return agent;
}
async getProfile(agentId) {
const agent = await this.requireAgent(agentId);
const [customersCount, ordersCount] = await Promise.all([
this.prisma.customer.count({
where: { assignedAgentId: agentId, isActive: true },
}),
this.prisma.order.count({
where: {
user: { assignedAgentId: agentId },
},
}),
]);
const sales = await this.prisma.order.aggregate({
where: {
user: { assignedAgentId: agentId },
finalAmount: { not: null },
},
_sum: { finalAmount: true },
});
const salesVolume = sales._sum.finalAmount ?? 0;
return {
id: agent.id,
firstName: agent.firstName,
lastName: agent.lastName,
code: agent.agentCode ?? '',
levelLabel: agent.levelLabel ?? 'برنزی',
nextLevelLabel: agent.nextLevelLabel ?? 'نقره‌ای',
pointsToNext: agent.pointsToNext ?? 0,
levelProgress: agent.levelProgress ?? 0,
phone: agent.phone,
customersCount,
ordersCount,
salesVolumeToman: salesVolume,
profitToman: Math.round(salesVolume * 0.1),
periodLabel: '۳۰۰ روز اخیر',
};
}
async listMyCustomers(agentId, q, status) {
await this.requireAgent(agentId);
const where = {
assignedAgentId: agentId,
...(status === 'active' ? { isActive: true } : {}),
...(status === 'inactive' ? { isActive: false } : {}),
...(q
? {
OR: [
{ firstName: { contains: q, mode: 'insensitive' } },
{ lastName: { contains: q, mode: 'insensitive' } },
{ businessName: { contains: q, mode: 'insensitive' } },
{ phone: { contains: q } },
],
}
: {}),
};
const customers = await this.prisma.customer.findMany({
where,
orderBy: { createdAt: 'desc' },
include: {
orders: {
select: { finalAmount: true, id: true },
},
},
});
return customers.map((c) => {
const amounts = c.orders
.map((o) => o.finalAmount ?? 0)
.filter((n) => n > 0);
const total = amounts.reduce((a, b) => a + b, 0);
const orderCount = c.orders.length;
return {
id: c.id,
name: c.businessName || `${c.firstName} ${c.lastName}`,
contactName: `${c.firstName} ${c.lastName}`,
phone: c.phone,
status: !c.isActive ? 'inactive' : orderCount === 0 ? 'new' : 'active',
avatarLetter: c.avatarLetter || c.firstName.charAt(0),
avatarColor: c.avatarColor || '#c4b5fd',
totalPurchaseToman: total,
orderCount,
averageOrderToman: orderCount ? Math.round(total / orderCount) : 0,
levelLabel: c.levelLabel || 'برنزی',
firstPurchaseDate: '—',
agentCode: undefined,
agentName: undefined,
};
});
}
async getCustomerDetail(agentId, customerId) {
await this.requireAgent(agentId);
const customer = await this.prisma.customer.findFirst({
where: { id: customerId, assignedAgentId: agentId },
include: {
orders: {
orderBy: { createdAt: 'desc' },
take: 20,
},
},
});
if (!customer)
throw new common_1.NotFoundException('مشتری یافت نشد');
const agent = await this.prisma.customer.findUnique({
where: { id: agentId },
});
const amounts = customer.orders
.map((o) => o.finalAmount ?? 0)
.filter((n) => n > 0);
const total = amounts.reduce((a, b) => a + b, 0);
const orderCount = customer.orders.length;
const meetings = await this.prisma.meetingInteraction.findMany({
where: { customerId, agentId },
orderBy: { createdAt: 'desc' },
take: 50,
});
return {
customer: {
id: customer.id,
name: customer.businessName || `${customer.firstName} ${customer.lastName}`,
contactName: `${customer.firstName} ${customer.lastName}`,
phone: customer.phone,
status: customer.isActive ? 'active' : 'inactive',
avatarLetter: customer.avatarLetter || customer.firstName.charAt(0),
avatarColor: customer.avatarColor || '#f9a8d4',
totalPurchaseToman: total,
orderCount,
averageOrderToman: orderCount ? Math.round(total / orderCount) : 0,
levelLabel: customer.levelLabel || 'برنزی',
firstPurchaseDate: customer.createdAt.toISOString().slice(0, 10),
agentCode: agent?.agentCode,
agentName: agent ? `${agent.firstName} ${agent.lastName}` : undefined,
},
orders: customer.orders.map((o) => ({
id: o.id,
customerId: customer.id,
title: `سفارش #${o.orderNumber}`,
amountToman: o.finalAmount ?? 0,
statusLabel: o.status,
statusTone: 'info',
date: o.createdAt.toISOString().slice(0, 10),
})),
meetings: meetings.map((m) => ({
id: m.id,
customerId: m.customerId,
type: m.type,
title: m.title,
description: m.description ?? '',
date: m.date,
time: m.time ?? undefined,
})),
};
}
async createMeeting(agentId, dto) {
await this.requireAgent(agentId);
const customer = await this.prisma.customer.findFirst({
where: { id: dto.customerId, assignedAgentId: agentId },
});
if (!customer)
throw new common_1.NotFoundException('مشتری یافت نشد');
const meeting = await this.prisma.meetingInteraction.create({
data: {
agentId,
customerId: dto.customerId,
type: dto.type,
title: dto.title.trim(),
description: dto.description?.trim(),
subject: dto.subject?.trim(),
content: dto.content?.trim(),
date: dto.date,
time: dto.time,
},
});
let reminder = null;
if (dto.createReminder && dto.reminderTitle && dto.reminderDate && dto.reminderTime) {
reminder = await this.prisma.reminder.create({
data: {
ownerId: agentId,
customerId: dto.customerId,
title: dto.reminderTitle.trim(),
description: dto.reminderNote?.trim(),
note: dto.reminderNote?.trim(),
date: dto.reminderDate,
time: dto.reminderTime,
interactionType: dto.type,
},
});
}
return { meeting, reminder };
}
async listReminders(agentId, filter) {
await this.requireAgent(agentId);
const where = { ownerId: agentId };
if (filter === 'past') {
where.completed = true;
}
else if (filter === 'all') {
}
else {
where.completed = false;
}
const reminders = await this.prisma.reminder.findMany({
where,
orderBy: [{ date: 'asc' }, { time: 'asc' }],
include: {
customer: true,
},
});
return reminders.map((r) => ({
id: r.id,
customerId: r.customerId,
title: r.title,
description: r.description ?? '',
time: r.time,
date: r.date,
interactionType: r.interactionType,
completed: r.completed,
note: r.note ?? undefined,
customerName: r.customer.businessName ||
`${r.customer.firstName} ${r.customer.lastName}`,
contactName: `${r.customer.firstName} ${r.customer.lastName}`,
avatarLetter: r.customer.avatarLetter || r.customer.firstName.charAt(0),
avatarColor: r.customer.avatarColor || '#c4b5fd',
}));
}
async getReminder(agentId, reminderId) {
await this.requireAgent(agentId);
const reminder = await this.prisma.reminder.findFirst({
where: { id: reminderId, ownerId: agentId },
include: { customer: true },
});
if (!reminder)
throw new common_1.NotFoundException('یادآور یافت نشد');
const history = await this.prisma.meetingInteraction.findMany({
where: { customerId: reminder.customerId, agentId },
orderBy: { createdAt: 'desc' },
take: 20,
});
return {
reminder: {
id: reminder.id,
customerId: reminder.customerId,
title: reminder.title,
description: reminder.description ?? '',
time: reminder.time,
date: reminder.date,
interactionType: reminder.interactionType,
completed: reminder.completed,
note: reminder.note ?? undefined,
},
customer: {
id: reminder.customer.id,
name: reminder.customer.businessName ||
`${reminder.customer.firstName} ${reminder.customer.lastName}`,
contactName: `${reminder.customer.firstName} ${reminder.customer.lastName}`,
phone: reminder.customer.phone,
avatarLetter: reminder.customer.avatarLetter || reminder.customer.firstName.charAt(0),
avatarColor: reminder.customer.avatarColor || '#f9a8d4',
},
history: history.map((m) => ({
id: m.id,
type: m.type,
title: m.title,
description: m.description ?? '',
date: m.date,
})),
};
}
async createReminder(agentId, dto) {
await this.requireAgent(agentId);
const customer = await this.prisma.customer.findFirst({
where: { id: dto.customerId, assignedAgentId: agentId },
});
if (!customer)
throw new common_1.NotFoundException('مشتری یافت نشد');
return this.prisma.reminder.create({
data: {
ownerId: agentId,
customerId: dto.customerId,
title: dto.title.trim(),
description: dto.description?.trim(),
note: dto.note?.trim(),
date: dto.date,
time: dto.time,
interactionType: dto.interactionType ?? client_1.InteractionType.note,
},
});
}
async completeReminder(agentId, reminderId, dto) {
await this.requireAgent(agentId);
const reminder = await this.prisma.reminder.findFirst({
where: { id: reminderId, ownerId: agentId },
});
if (!reminder)
throw new common_1.NotFoundException('یادآور یافت نشد');
const completed = dto.completed ?? true;
return this.prisma.reminder.update({
where: { id: reminderId },
data: {
completed,
completedAt: completed ? new Date() : null,
},
});
}
async listCategories() {
return {
usage: [
{ id: 'trousers', title: 'پارچه‌های شلواری', subtitle: 'مشاهده محصولات' },
{ id: 'suit', title: 'پارچه‌های کت‌شلواری', subtitle: 'مشاهده محصولات' },
{ id: 'shirt', title: 'پارچه‌های پیراهنی', subtitle: 'مشاهده محصولات' },
],
seasons: [
{
id: 'summer',
title: 'تابستانه',
description: 'سبک، خنک و مناسب فصل گرم',
},
{
id: 'winter',
title: 'زمستانه',
description: 'گرم و مناسب فصل سرد',
},
{
id: 'all_season',
title: 'چهارفصل',
description: 'مناسب استفاده در بیشتر فصل‌های سال',
},
],
};
}
};
exports.AgentService = AgentService;
exports.AgentService = AgentService = __decorate([
(0, common_1.Injectable)(),
__metadata("design:paramtypes", [prisma_service_1.PrismaService])
], AgentService);
//# sourceMappingURL=agent.service.js.map
File diff suppressed because one or more lines are too long
+2
View File
@@ -0,0 +1,2 @@
export declare class AppModule {
}
+1
View File
@@ -0,0 +1 @@
export {};
+40
View File
@@ -0,0 +1,40 @@
services:
postgres:
image: postgres:16-alpine
container_name: fabric_trade_db
restart: unless-stopped
env_file:
- .env
environment:
POSTGRES_USER: ${POSTGRES_USER:-fabric}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-fabric123}
POSTGRES_DB: ${POSTGRES_DB:-fabric_trade}
ports:
- '${POSTGRES_PORT:-5432}:5432'
volumes:
- fabric_pg_data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB']
interval: 5s
timeout: 5s
retries: 10
api:
build:
context: .
dockerfile: Dockerfile
container_name: fabric_trade_api
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
env_file:
- .env
environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-fabric}:${POSTGRES_PASSWORD:-fabric123}@postgres:5432/${POSTGRES_DB:-fabric_trade}?schema=public
PORT: ${PORT:-3000}
ports:
- '127.0.0.1:${API_PORT:-3000}:3000'
volumes:
fabric_pg_data:
+42
View File
@@ -0,0 +1,42 @@
services:
postgres:
image: postgres:16-alpine
container_name: fabric_trade_db
restart: unless-stopped
env_file:
- .env
environment:
POSTGRES_USER: ${POSTGRES_USER:-fabric}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-fabric123}
POSTGRES_DB: ${POSTGRES_DB:-fabric_trade}
ports:
- '${POSTGRES_PORT:-5432}:5432'
volumes:
- fabric_pg_data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB']
interval: 5s
timeout: 5s
retries: 10
api:
build:
context: .
dockerfile: Dockerfile.dist
args:
NPM_REGISTRY: ${NPM_REGISTRY:-https://registry.npmmirror.com}
container_name: fabric_trade_api
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
env_file:
- .env
environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-fabric}:${POSTGRES_PASSWORD:-fabric123}@postgres:5432/${POSTGRES_DB:-fabric_trade}?schema=public
PORT: ${PORT:-3000}
ports:
- '127.0.0.1:${API_PORT:-3000}:3000'
volumes:
fabric_pg_data:
+40
View File
@@ -0,0 +1,40 @@
services:
postgres:
image: postgres:16-alpine
container_name: fabric_trade_db
restart: unless-stopped
env_file:
- .env
environment:
POSTGRES_USER: ${POSTGRES_USER:-fabric}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-fabric123}
POSTGRES_DB: ${POSTGRES_DB:-fabric_trade}
ports:
- '${POSTGRES_PORT:-5432}:5432'
volumes:
- fabric_pg_data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB']
interval: 5s
timeout: 5s
retries: 10
api:
image: ${API_IMAGE:-fabric-trade-api:latest}
container_name: fabric_trade_api
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
# سکرت‌ها و تنظیمات از فایل .env روی خود سرور
env_file:
- .env
environment:
# داخل شبکه Docker هاست دیتابیس همیشه "postgres" است
DATABASE_URL: postgresql://${POSTGRES_USER:-fabric}:${POSTGRES_PASSWORD:-fabric123}@postgres:5432/${POSTGRES_DB:-fabric_trade}?schema=public
PORT: ${PORT:-3000}
ports:
- '127.0.0.1:${API_PORT:-3000}:3000'
volumes:
fabric_pg_data:
+41
View File
@@ -0,0 +1,41 @@
services:
postgres:
image: postgres:16-alpine
container_name: fabric_trade_db
restart: unless-stopped
env_file:
- .env
environment:
POSTGRES_USER: ${POSTGRES_USER:-fabric}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-fabric123}
POSTGRES_DB: ${POSTGRES_DB:-fabric_trade}
ports:
- '${POSTGRES_PORT:-5432}:5432'
volumes:
- fabric_pg_data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB']
interval: 5s
timeout: 5s
retries: 10
api:
build:
context: .
dockerfile: Dockerfile
container_name: fabric_trade_api
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
env_file:
- .env
environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-fabric}:${POSTGRES_PASSWORD:-fabric123}@postgres:5432/${POSTGRES_DB:-fabric_trade}?schema=public
PORT: ${PORT:-3000}
TZ: ${TZ:-Asia/Tehran}
ports:
- '127.0.0.1:${API_PORT:-8585}:3000'
volumes:
fabric_pg_data:
+41
View File
@@ -0,0 +1,41 @@
#!/bin/sh
set -e
echo "Node: $(node -v)"
echo "OpenSSL: $(openssl version 2>/dev/null || echo missing)"
# فقط host را لاگ کن تا سکرت لو نرود
DB_HOST=$(printf '%s' "$DATABASE_URL" | sed -n 's|.*@\([^:/]*\).*|\1|p')
echo "DATABASE_URL host: ${DB_HOST:-unknown}"
if [ ! -x ./node_modules/.bin/prisma ] && [ ! -f ./node_modules/prisma/build/index.js ]; then
echo "ERROR: prisma در node_modules نیست. روی سرور بزن:"
echo " npm install prisma --no-audit --no-fund --registry=https://registry.npmmirror.com"
exit 1
fi
if [ -x ./node_modules/.bin/prisma ]; then
PRISMA="./node_modules/.bin/prisma"
else
PRISMA="node ./node_modules/prisma/build/index.js"
fi
echo "Waiting for database & applying migrations..."
i=0
until $PRISMA migrate deploy; do
i=$((i + 1))
if [ "$i" -ge 30 ]; then
echo "Database/migrate failed after 30 attempts"
exit 1
fi
echo "Retry $i..."
sleep 2
done
if [ "${RUN_SEED:-false}" = "true" ]; then
echo "Seeding database..."
$PRISMA db seed || true
fi
echo "Starting API on port ${PORT:-3000}..."
exec node dist/main.js
+35
View File
@@ -0,0 +1,35 @@
// @ts-check
import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended,
{
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
sourceType: 'commonjs',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
"prettier/prettier": ["error", { endOfLine: "auto" }],
},
},
);
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"builder": "tsc",
"deleteOutDir": true,
"webpack": false
}
}
+11074
View File
File diff suppressed because it is too large Load Diff
+104
View File
@@ -0,0 +1,104 @@
{
"name": "fabric_trade_server",
"version": "0.0.1",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"build": "rm -rf dist tsconfig.build.tsbuildinfo && nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main.js",
"deploy:build": "npm run build && bash scripts/pack-prisma-client.sh",
"build:deploy": "bash scripts/build-local-dist.sh",
"docker:dist": "docker compose -f docker-compose.dist.yml up -d --build",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"test:smoke": "node scripts/smoke-order-flow.mjs",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate dev",
"prisma:deploy": "prisma migrate deploy",
"prisma:seed": "prisma db seed",
"db:up": "docker compose up -d postgres",
"db:down": "docker compose down",
"db:reset": "prisma migrate reset --force",
"docker:up": "docker compose up -d --build",
"docker:down": "docker compose down",
"docker:logs": "docker compose logs -f api",
"docker:export": "bash scripts/build-and-export-image.sh"
},
"prisma": {
"seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts"
},
"dependencies": {
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.4",
"@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/schedule": "^6.1.3",
"@nestjs/swagger": "^11.4.6",
"@prisma/client": "^6.19.3",
"bcryptjs": "^3.0.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"dotenv": "^17.4.2",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"prisma": "^6.19.3",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.18.0",
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@types/bcrypt": "^6.0.0",
"@types/bcryptjs": "^2.4.6",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/node": "^24.0.0",
"@types/passport-jwt": "^4.0.1",
"@types/supertest": "^7.0.0",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.2",
"globals": "^17.0.0",
"jest": "^30.0.0",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}
Binary file not shown.
+14
View File
@@ -0,0 +1,14 @@
import "dotenv/config";
import { defineConfig, env } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
seed: 'ts-node --compiler-options {"module":"CommonJS"} prisma/seed.ts',
},
engine: "classic",
datasource: {
url: env("DATABASE_URL"),
},
});
@@ -0,0 +1,469 @@
-- CreateSchema
CREATE SCHEMA IF NOT EXISTS "public";
-- CreateEnum
CREATE TYPE "SettlementType" AS ENUM ('cash', 'check');
-- CreateEnum
CREATE TYPE "CheckRegistrationMode" AS ENUM ('full', 'brief');
-- CreateEnum
CREATE TYPE "OrderStatus" AS ENUM ('cart', 'awaiting_settlement', 'cash_in_progress', 'check_in_progress', 'submitted', 'awaiting_review', 'pricing', 'needs_correction', 'awaiting_settlement_fix', 'cash_invoice_issued', 'awaiting_cash_payment', 'cash_payment_submitted', 'awaiting_cash_confirmation', 'cash_confirmed', 'awaiting_check_review', 'check_needs_fix', 'check_rejected', 'checks_approved', 'final_approved', 'preparing', 'ready_to_ship', 'shipped', 'delivered', 'cancelled', 'rejected', 'expired');
-- CreateEnum
CREATE TYPE "CheckStatus" AS ENUM ('pending_review', 'approved', 'rejected');
-- CreateEnum
CREATE TYPE "CashPaymentStatus" AS ENUM ('pending_review', 'approved', 'rejected', 'expired');
-- CreateEnum
CREATE TYPE "ShipmentStatus" AS ENUM ('preparing', 'ready', 'shipped', 'delivered');
-- CreateEnum
CREATE TYPE "CustomerType" AS ENUM ('wholesale', 'retail');
-- CreateEnum
CREATE TYPE "AdminRole" AS ENUM ('admin', 'sales', 'finance', 'warehouse');
-- CreateEnum
CREATE TYPE "NotificationTone" AS ENUM ('info', 'warning', 'success', 'danger');
-- CreateTable
CREATE TABLE "Company" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT,
"logoUrl" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"displayOrder" INTEGER NOT NULL DEFAULT 0,
"maxMeteragePerInvoice" INTEGER NOT NULL DEFAULT 5000,
"cashInvoiceValidityHours" INTEGER NOT NULL DEFAULT 8,
"cashInvoiceValidUntilHour" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Company_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BankAccount" (
"id" TEXT NOT NULL,
"companyId" TEXT NOT NULL,
"bankName" TEXT NOT NULL,
"accountHolder" TEXT NOT NULL,
"accountNumber" TEXT NOT NULL,
"cardNumber" TEXT NOT NULL,
"iban" TEXT NOT NULL,
"notes" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"displayOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BankAccount_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Catalog" (
"id" TEXT NOT NULL,
"companyId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"code" TEXT NOT NULL,
"description" TEXT,
"displayOrder" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Catalog_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Product" (
"id" TEXT NOT NULL,
"companyId" TEXT NOT NULL,
"catalogId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"code" TEXT NOT NULL,
"color" TEXT NOT NULL,
"description" TEXT,
"minMeterage" DOUBLE PRECISION NOT NULL DEFAULT 30,
"orderMultiplier" DOUBLE PRECISION NOT NULL DEFAULT 10,
"basePrice" DOUBLE PRECISION,
"stock" DOUBLE PRECISION,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Product_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Customer" (
"id" TEXT NOT NULL,
"firstName" TEXT NOT NULL,
"lastName" TEXT NOT NULL,
"phone" TEXT NOT NULL,
"nationalId" TEXT,
"customerType" "CustomerType" NOT NULL DEFAULT 'wholesale',
"companyId" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Customer_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AdminUser" (
"id" TEXT NOT NULL,
"fullName" TEXT NOT NULL,
"phone" TEXT NOT NULL,
"email" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"role" "AdminRole" NOT NULL DEFAULT 'admin',
"isActive" BOOLEAN NOT NULL DEFAULT true,
"lastLoginAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AdminUser_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OtpChallenge" (
"id" TEXT NOT NULL,
"phone" TEXT NOT NULL,
"code" TEXT NOT NULL,
"firstName" TEXT,
"lastName" TEXT,
"isRegister" BOOLEAN NOT NULL DEFAULT false,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "OtpChallenge_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Cart" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"companyId" TEXT,
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Cart_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CartItem" (
"id" TEXT NOT NULL,
"cartId" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"catalogCode" TEXT NOT NULL,
"productName" TEXT NOT NULL,
"productCode" TEXT NOT NULL,
"color" TEXT NOT NULL,
"meterage" DOUBLE PRECISION NOT NULL,
"unitPrice" DOUBLE PRECISION,
"notes" TEXT,
"companyId" TEXT NOT NULL,
"companyName" TEXT NOT NULL,
CONSTRAINT "CartItem_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Order" (
"id" TEXT NOT NULL,
"orderNumber" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"companyId" TEXT NOT NULL,
"companyName" TEXT NOT NULL,
"settlementType" "SettlementType" NOT NULL,
"checkMode" "CheckRegistrationMode",
"status" "OrderStatus" NOT NULL DEFAULT 'awaiting_review',
"totalMeterage" DOUBLE PRECISION NOT NULL DEFAULT 0,
"totalAmount" DOUBLE PRECISION,
"shippingCost" DOUBLE PRECISION,
"totalDiscount" DOUBLE PRECISION,
"finalAmount" DOUBLE PRECISION,
"notes" TEXT,
"checkRassDays" INTEGER,
"checkRassDate" TIMESTAMP(3),
"cashInvoiceExpiresAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Order_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OrderItem" (
"id" TEXT NOT NULL,
"orderId" TEXT NOT NULL,
"productId" TEXT,
"catalogCode" TEXT NOT NULL,
"productName" TEXT NOT NULL,
"productCode" TEXT,
"color" TEXT NOT NULL,
"requestedMeterage" DOUBLE PRECISION NOT NULL,
"confirmedMeterage" DOUBLE PRECISION,
"unitPrice" DOUBLE PRECISION,
"unitDiscount" DOUBLE PRECISION DEFAULT 0,
"lineTotal" DOUBLE PRECISION,
"notes" TEXT,
CONSTRAINT "OrderItem_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OrderStatusHistory" (
"id" TEXT NOT NULL,
"orderId" TEXT NOT NULL,
"fromStatus" "OrderStatus",
"toStatus" "OrderStatus" NOT NULL,
"changedBy" TEXT NOT NULL,
"note" TEXT,
"reason" TEXT,
"changedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "OrderStatusHistory_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Check" (
"id" TEXT NOT NULL,
"orderId" TEXT NOT NULL,
"row" INTEGER NOT NULL,
"amount" DOUBLE PRECISION NOT NULL,
"dueDate" TIMESTAMP(3) NOT NULL,
"sayadId" TEXT,
"checkNumber" TEXT,
"ownerNationalId" TEXT,
"ownerFirstName" TEXT,
"ownerLastName" TEXT,
"bankName" TEXT,
"branchName" TEXT,
"frontImage" TEXT,
"backImage" TEXT,
"notes" TEXT,
"mode" "CheckRegistrationMode" NOT NULL DEFAULT 'full',
"status" "CheckStatus" NOT NULL DEFAULT 'pending_review',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Check_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CashPaymentReceipt" (
"id" TEXT NOT NULL,
"orderId" TEXT NOT NULL,
"amount" DOUBLE PRECISION NOT NULL,
"paidAmount" DOUBLE PRECISION,
"transferDate" TIMESTAMP(3),
"transferTime" TEXT,
"trackingNumber" TEXT,
"destinationAccountId" TEXT,
"receiptImage" TEXT,
"notes" TEXT,
"expiresAt" TIMESTAMP(3),
"status" "CashPaymentStatus" NOT NULL DEFAULT 'pending_review',
"submittedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CashPaymentReceipt_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Shipment" (
"id" TEXT NOT NULL,
"orderId" TEXT NOT NULL,
"carrier" TEXT NOT NULL,
"trackingNumber" TEXT,
"meterage" DOUBLE PRECISION NOT NULL,
"status" "ShipmentStatus" NOT NULL DEFAULT 'preparing',
"shippedAt" TIMESTAMP(3),
"deliveredAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Shipment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SystemSettings" (
"id" TEXT NOT NULL DEFAULT 'default',
"maxMeteragePerInvoice" INTEGER NOT NULL DEFAULT 5000,
"cashInvoiceValidityHours" INTEGER NOT NULL DEFAULT 8,
"defaultSettlementCheck" BOOLEAN NOT NULL DEFAULT true,
"defaultSettlementCash" BOOLEAN NOT NULL DEFAULT true,
"smsOrderRegistered" BOOLEAN NOT NULL DEFAULT true,
"smsPaymentReminder" BOOLEAN NOT NULL DEFAULT true,
"smsCheckReview" BOOLEAN NOT NULL DEFAULT true,
"smsShippingUpdate" BOOLEAN NOT NULL DEFAULT true,
"supportPhone" TEXT,
"supportEmail" TEXT,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SystemSettings_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AdminNotification" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"body" TEXT NOT NULL,
"tone" "NotificationTone" DEFAULT 'info',
"link" TEXT,
"isRead" BOOLEAN NOT NULL DEFAULT false,
"sentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AdminNotification_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "InternalMessage" (
"id" TEXT NOT NULL,
"senderName" TEXT NOT NULL,
"subject" TEXT NOT NULL,
"preview" TEXT NOT NULL,
"isRead" BOOLEAN NOT NULL DEFAULT false,
"sentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "InternalMessage_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Company_slug_key" ON "Company"("slug");
-- CreateIndex
CREATE INDEX "BankAccount_companyId_idx" ON "BankAccount"("companyId");
-- CreateIndex
CREATE INDEX "Catalog_companyId_idx" ON "Catalog"("companyId");
-- CreateIndex
CREATE UNIQUE INDEX "Catalog_companyId_code_key" ON "Catalog"("companyId", "code");
-- CreateIndex
CREATE INDEX "Product_catalogId_idx" ON "Product"("catalogId");
-- CreateIndex
CREATE INDEX "Product_companyId_idx" ON "Product"("companyId");
-- CreateIndex
CREATE UNIQUE INDEX "Product_companyId_code_key" ON "Product"("companyId", "code");
-- CreateIndex
CREATE UNIQUE INDEX "Customer_phone_key" ON "Customer"("phone");
-- CreateIndex
CREATE INDEX "Customer_companyId_idx" ON "Customer"("companyId");
-- CreateIndex
CREATE UNIQUE INDEX "AdminUser_phone_key" ON "AdminUser"("phone");
-- CreateIndex
CREATE UNIQUE INDEX "AdminUser_email_key" ON "AdminUser"("email");
-- CreateIndex
CREATE INDEX "OtpChallenge_phone_idx" ON "OtpChallenge"("phone");
-- CreateIndex
CREATE INDEX "Cart_userId_idx" ON "Cart"("userId");
-- CreateIndex
CREATE INDEX "CartItem_cartId_idx" ON "CartItem"("cartId");
-- CreateIndex
CREATE UNIQUE INDEX "CartItem_cartId_productId_key" ON "CartItem"("cartId", "productId");
-- CreateIndex
CREATE UNIQUE INDEX "Order_orderNumber_key" ON "Order"("orderNumber");
-- CreateIndex
CREATE INDEX "Order_userId_idx" ON "Order"("userId");
-- CreateIndex
CREATE INDEX "Order_companyId_idx" ON "Order"("companyId");
-- CreateIndex
CREATE INDEX "Order_status_idx" ON "Order"("status");
-- CreateIndex
CREATE INDEX "Order_createdAt_idx" ON "Order"("createdAt");
-- CreateIndex
CREATE INDEX "OrderItem_orderId_idx" ON "OrderItem"("orderId");
-- CreateIndex
CREATE INDEX "OrderStatusHistory_orderId_idx" ON "OrderStatusHistory"("orderId");
-- CreateIndex
CREATE INDEX "Check_orderId_idx" ON "Check"("orderId");
-- CreateIndex
CREATE INDEX "Check_dueDate_idx" ON "Check"("dueDate");
-- CreateIndex
CREATE UNIQUE INDEX "CashPaymentReceipt_orderId_key" ON "CashPaymentReceipt"("orderId");
-- CreateIndex
CREATE UNIQUE INDEX "Shipment_orderId_key" ON "Shipment"("orderId");
-- AddForeignKey
ALTER TABLE "BankAccount" ADD CONSTRAINT "BankAccount_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Catalog" ADD CONSTRAINT "Catalog_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Product" ADD CONSTRAINT "Product_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Product" ADD CONSTRAINT "Product_catalogId_fkey" FOREIGN KEY ("catalogId") REFERENCES "Catalog"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Customer" ADD CONSTRAINT "Customer_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Cart" ADD CONSTRAINT "Cart_userId_fkey" FOREIGN KEY ("userId") REFERENCES "Customer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CartItem" ADD CONSTRAINT "CartItem_cartId_fkey" FOREIGN KEY ("cartId") REFERENCES "Cart"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CartItem" ADD CONSTRAINT "CartItem_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Order" ADD CONSTRAINT "Order_userId_fkey" FOREIGN KEY ("userId") REFERENCES "Customer"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Order" ADD CONSTRAINT "Order_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OrderItem" ADD CONSTRAINT "OrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OrderItem" ADD CONSTRAINT "OrderItem_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OrderStatusHistory" ADD CONSTRAINT "OrderStatusHistory_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Check" ADD CONSTRAINT "Check_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CashPaymentReceipt" ADD CONSTRAINT "CashPaymentReceipt_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CashPaymentReceipt" ADD CONSTRAINT "CashPaymentReceipt_destinationAccountId_fkey" FOREIGN KEY ("destinationAccountId") REFERENCES "BankAccount"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Shipment" ADD CONSTRAINT "Shipment_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- شمارنده تلاش ناموفق برای جلوگیری از حدس‌زدن کد ۴ رقمی
ALTER TABLE "OtpChallenge" ADD COLUMN "attempts" INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1,2 @@
-- روش تسویه دیگر در لحظه ثبت سفارش گرفته نمی‌شود؛ مشتری بعد از قیمت‌گذاری انتخاب می‌کند.
ALTER TABLE "Order" ALTER COLUMN "settlementType" DROP NOT NULL;
@@ -0,0 +1,24 @@
-- اعلان درون‌برنامه‌ای مشتری به‌همراه سوئیچ پیامک «فاکتور آماده شد»
ALTER TABLE "SystemSettings" ADD COLUMN "smsPricingReady" BOOLEAN NOT NULL DEFAULT true;
CREATE TABLE "CustomerNotification" (
"id" TEXT NOT NULL,
"customerId" TEXT NOT NULL,
"orderId" TEXT,
"event" TEXT NOT NULL,
"title" TEXT NOT NULL,
"body" TEXT NOT NULL,
"tone" "NotificationTone" DEFAULT 'info',
"link" TEXT,
"isRead" BOOLEAN NOT NULL DEFAULT false,
"smsSent" BOOLEAN NOT NULL DEFAULT false,
"sentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CustomerNotification_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "CustomerNotification_customerId_isRead_idx" ON "CustomerNotification"("customerId", "isRead");
CREATE INDEX "CustomerNotification_sentAt_idx" ON "CustomerNotification"("sentAt");
ALTER TABLE "CustomerNotification" ADD CONSTRAINT "CustomerNotification_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "CustomerNotification" ADD CONSTRAINT "CustomerNotification_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
-- سقف متراژ شرکت اختیاری می‌شود؛ خالی یعنی از SystemSettings ارث می‌برد.
ALTER TABLE "Company" ALTER COLUMN "maxMeteragePerInvoice" DROP NOT NULL;
ALTER TABLE "Company" ALTER COLUMN "maxMeteragePerInvoice" DROP DEFAULT;
@@ -0,0 +1,4 @@
-- یادآوری پایان مهلت پرداخت فقط یک‌بار برای هر فاکتور ارسال می‌شود.
ALTER TABLE "Order" ADD COLUMN "cashReminderSentAt" TIMESTAMP(3);
CREATE INDEX "Order_status_cashInvoiceExpiresAt_idx" ON "Order"("status", "cashInvoiceExpiresAt");
@@ -0,0 +1,15 @@
-- اقلام سفارش شرکت خود را نگه می‌دارند تا یک فاکتور چندشرکتی ممکن شود.
ALTER TABLE "OrderItem" ADD COLUMN "companyId" TEXT;
ALTER TABLE "OrderItem" ADD COLUMN "companyName" TEXT;
UPDATE "OrderItem" AS oi
SET
"companyId" = o."companyId",
"companyName" = o."companyName"
FROM "Order" AS o
WHERE oi."orderId" = o."id";
ALTER TABLE "OrderItem" ALTER COLUMN "companyId" SET NOT NULL;
ALTER TABLE "OrderItem" ALTER COLUMN "companyName" SET NOT NULL;
CREATE INDEX "OrderItem_companyId_idx" ON "OrderItem"("companyId");
@@ -0,0 +1,122 @@
-- AlterEnum
CREATE TYPE "AppUserRole" AS ENUM ('customer', 'agent');
CREATE TYPE "InteractionType" AS ENUM ('in_person', 'call', 'message', 'note');
CREATE TYPE "ProductSeason" AS ENUM ('summer', 'winter', 'all_season');
CREATE TYPE "ProductUsageCategory" AS ENUM ('trousers', 'suit', 'shirt', 'other');
-- AlterTable Product
ALTER TABLE "Product" ADD COLUMN IF NOT EXISTS "usageCategory" "ProductUsageCategory" NOT NULL DEFAULT 'other';
ALTER TABLE "Product" ADD COLUMN IF NOT EXISTS "season" "ProductSeason" NOT NULL DEFAULT 'all_season';
ALTER TABLE "Product" ADD COLUMN IF NOT EXISTS "widthCm" DOUBLE PRECISION;
ALTER TABLE "Product" ADD COLUMN IF NOT EXISTS "composition" TEXT;
ALTER TABLE "Product" ADD COLUMN IF NOT EXISTS "weightGrams" DOUBLE PRECISION;
ALTER TABLE "Product" ADD COLUMN IF NOT EXISTS "weaveType" TEXT;
ALTER TABLE "Product" ADD COLUMN IF NOT EXISTS "colorsJson" JSONB;
ALTER TABLE "Product" ADD COLUMN IF NOT EXISTS "tagsJson" JSONB;
ALTER TABLE "Product" ADD COLUMN IF NOT EXISTS "imageUrlsJson" JSONB;
ALTER TABLE "Product" ADD COLUMN IF NOT EXISTS "subtitle" TEXT;
ALTER TABLE "Product" ADD COLUMN IF NOT EXISTS "rollMeters" DOUBLE PRECISION NOT NULL DEFAULT 50;
ALTER TABLE "Product" ADD COLUMN IF NOT EXISTS "isNew" BOOLEAN NOT NULL DEFAULT false;
CREATE INDEX IF NOT EXISTS "Product_usageCategory_idx" ON "Product"("usageCategory");
CREATE INDEX IF NOT EXISTS "Product_season_idx" ON "Product"("season");
-- AlterTable Customer
ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "appRole" "AppUserRole" NOT NULL DEFAULT 'customer';
ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "agentCode" TEXT;
ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "businessName" TEXT;
ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "levelLabel" TEXT;
ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "nextLevelLabel" TEXT;
ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "pointsToNext" INTEGER;
ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "levelProgress" INTEGER DEFAULT 0;
ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "assignedAgentId" TEXT;
ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "avatarLetter" TEXT;
ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "avatarColor" TEXT;
CREATE INDEX IF NOT EXISTS "Customer_appRole_idx" ON "Customer"("appRole");
CREATE INDEX IF NOT EXISTS "Customer_assignedAgentId_idx" ON "Customer"("assignedAgentId");
CREATE INDEX IF NOT EXISTS "Customer_agentCode_idx" ON "Customer"("agentCode");
ALTER TABLE "Customer"
ADD CONSTRAINT "Customer_assignedAgentId_fkey"
FOREIGN KEY ("assignedAgentId") REFERENCES "Customer"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
-- MeetingInteraction
CREATE TABLE IF NOT EXISTS "MeetingInteraction" (
"id" TEXT NOT NULL,
"customerId" TEXT NOT NULL,
"agentId" TEXT NOT NULL,
"type" "InteractionType" NOT NULL DEFAULT 'in_person',
"title" TEXT NOT NULL,
"description" TEXT,
"subject" TEXT,
"content" TEXT,
"date" TEXT NOT NULL,
"time" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "MeetingInteraction_pkey" PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "MeetingInteraction_customerId_idx" ON "MeetingInteraction"("customerId");
CREATE INDEX IF NOT EXISTS "MeetingInteraction_agentId_idx" ON "MeetingInteraction"("agentId");
CREATE INDEX IF NOT EXISTS "MeetingInteraction_date_idx" ON "MeetingInteraction"("date");
ALTER TABLE "MeetingInteraction"
ADD CONSTRAINT "MeetingInteraction_customerId_fkey"
FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "MeetingInteraction"
ADD CONSTRAINT "MeetingInteraction_agentId_fkey"
FOREIGN KEY ("agentId") REFERENCES "Customer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- Reminder
CREATE TABLE IF NOT EXISTS "Reminder" (
"id" TEXT NOT NULL,
"ownerId" TEXT NOT NULL,
"customerId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"note" TEXT,
"date" TEXT NOT NULL,
"time" TEXT NOT NULL,
"interactionType" "InteractionType" NOT NULL DEFAULT 'note',
"completed" BOOLEAN NOT NULL DEFAULT false,
"completedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Reminder_pkey" PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "Reminder_ownerId_completed_idx" ON "Reminder"("ownerId", "completed");
CREATE INDEX IF NOT EXISTS "Reminder_customerId_idx" ON "Reminder"("customerId");
CREATE INDEX IF NOT EXISTS "Reminder_date_idx" ON "Reminder"("date");
ALTER TABLE "Reminder"
ADD CONSTRAINT "Reminder_ownerId_fkey"
FOREIGN KEY ("ownerId") REFERENCES "Customer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "Reminder"
ADD CONSTRAINT "Reminder_customerId_fkey"
FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- ProductFavorite
CREATE TABLE IF NOT EXISTS "ProductFavorite" (
"id" TEXT NOT NULL,
"customerId" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ProductFavorite_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "ProductFavorite_customerId_productId_key" ON "ProductFavorite"("customerId", "productId");
CREATE INDEX IF NOT EXISTS "ProductFavorite_customerId_idx" ON "ProductFavorite"("customerId");
ALTER TABLE "ProductFavorite"
ADD CONSTRAINT "ProductFavorite_customerId_fkey"
FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ProductFavorite"
ADD CONSTRAINT "ProductFavorite_productId_fkey"
FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,149 @@
-- CreateEnum
CREATE TYPE "CatalogRequestStatus" AS ENUM ('new', 'approved', 'ready_to_ship', 'delivered', 'returned', 'cancelled');
-- CreateEnum
CREATE TYPE "CatalogItemHolderType" AS ENUM ('warehouse', 'customer', 'agent');
-- AlterTable SystemSettings
ALTER TABLE "SystemSettings" ADD COLUMN IF NOT EXISTS "minOrderRollsPerProduct" INTEGER NOT NULL DEFAULT 1;
ALTER TABLE "SystemSettings" ADD COLUMN IF NOT EXISTS "showCatalogPublicPrice" BOOLEAN NOT NULL DEFAULT false;
-- CreateTable Brand
CREATE TABLE "Brand" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT,
"description" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"displayOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Brand_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "Brand_slug_key" ON "Brand"("slug");
-- CreateTable CustomerLevelDef
CREATE TABLE "CustomerLevelDef" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"label" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"criteriaJson" JSONB,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CustomerLevelDef_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "CustomerLevelDef_code_key" ON "CustomerLevelDef"("code");
-- CreateTable LevelBenefit
CREATE TABLE "LevelBenefit" (
"id" TEXT NOT NULL,
"levelId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
CONSTRAINT "LevelBenefit_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "LevelBenefit_levelId_idx" ON "LevelBenefit"("levelId");
-- AlterTable Customer
ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "levelDefId" TEXT;
CREATE INDEX IF NOT EXISTS "Customer_levelDefId_idx" ON "Customer"("levelDefId");
-- CreateTable CustomerBrand
CREATE TABLE "CustomerBrand" (
"customerId" TEXT NOT NULL,
"brandId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CustomerBrand_pkey" PRIMARY KEY ("customerId","brandId")
);
CREATE INDEX "CustomerBrand_brandId_idx" ON "CustomerBrand"("brandId");
-- CreateTable CatalogRequest
CREATE TABLE "CatalogRequest" (
"id" TEXT NOT NULL,
"customerId" TEXT NOT NULL,
"status" "CatalogRequestStatus" NOT NULL DEFAULT 'new',
"note" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CatalogRequest_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "CatalogRequest_customerId_idx" ON "CatalogRequest"("customerId");
CREATE INDEX "CatalogRequest_status_idx" ON "CatalogRequest"("status");
-- CreateTable CatalogRequestItem
CREATE TABLE "CatalogRequestItem" (
"id" TEXT NOT NULL,
"requestId" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"quantity" INTEGER NOT NULL DEFAULT 1,
"holderType" "CatalogItemHolderType" NOT NULL DEFAULT 'warehouse',
"holderCustomerId" TEXT,
CONSTRAINT "CatalogRequestItem_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "CatalogRequestItem_requestId_idx" ON "CatalogRequestItem"("requestId");
CREATE INDEX "CatalogRequestItem_productId_idx" ON "CatalogRequestItem"("productId");
-- CreateTable CatalogItemMovement
CREATE TABLE "CatalogItemMovement" (
"id" TEXT NOT NULL,
"itemId" TEXT NOT NULL,
"fromHolder" TEXT NOT NULL,
"toHolder" TEXT NOT NULL,
"note" TEXT,
"movedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CatalogItemMovement_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "CatalogItemMovement_itemId_idx" ON "CatalogItemMovement"("itemId");
-- CreateTable StockNotifyRequest
CREATE TABLE "StockNotifyRequest" (
"id" TEXT NOT NULL,
"customerId" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"notifiedAt" TIMESTAMP(3),
CONSTRAINT "StockNotifyRequest_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "StockNotifyRequest_customerId_productId_key" ON "StockNotifyRequest"("customerId", "productId");
CREATE INDEX "StockNotifyRequest_productId_active_idx" ON "StockNotifyRequest"("productId", "active");
-- CreateTable ProductSuggestion
CREATE TABLE "ProductSuggestion" (
"id" TEXT NOT NULL,
"customerId" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"note" TEXT,
"createdByAdminId" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ProductSuggestion_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "ProductSuggestion_customerId_isActive_idx" ON "ProductSuggestion"("customerId", "isActive");
CREATE INDEX "ProductSuggestion_productId_idx" ON "ProductSuggestion"("productId");
-- FKs
ALTER TABLE "Customer" ADD CONSTRAINT "Customer_levelDefId_fkey" FOREIGN KEY ("levelDefId") REFERENCES "CustomerLevelDef"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "LevelBenefit" ADD CONSTRAINT "LevelBenefit_levelId_fkey" FOREIGN KEY ("levelId") REFERENCES "CustomerLevelDef"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "CustomerBrand" ADD CONSTRAINT "CustomerBrand_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "CustomerBrand" ADD CONSTRAINT "CustomerBrand_brandId_fkey" FOREIGN KEY ("brandId") REFERENCES "Brand"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "CatalogRequest" ADD CONSTRAINT "CatalogRequest_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "CatalogRequestItem" ADD CONSTRAINT "CatalogRequestItem_requestId_fkey" FOREIGN KEY ("requestId") REFERENCES "CatalogRequest"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "CatalogRequestItem" ADD CONSTRAINT "CatalogRequestItem_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "CatalogItemMovement" ADD CONSTRAINT "CatalogItemMovement_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "CatalogRequestItem"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "StockNotifyRequest" ADD CONSTRAINT "StockNotifyRequest_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "StockNotifyRequest" ADD CONSTRAINT "StockNotifyRequest_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ProductSuggestion" ADD CONSTRAINT "ProductSuggestion_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "ProductSuggestion" ADD CONSTRAINT "ProductSuggestion_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "SystemSettings" ADD COLUMN IF NOT EXISTS "allowAgentPricing" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "SystemSettings" ADD COLUMN IF NOT EXISTS "agentMinUnitPrice" INTEGER;
@@ -0,0 +1 @@
ALTER TABLE "Customer" ADD COLUMN IF NOT EXISTS "avatarUrl" TEXT;
@@ -0,0 +1 @@
ALTER TABLE "SystemSettings" ADD COLUMN IF NOT EXISTS "categoryImagesJson" JSONB;
@@ -0,0 +1,22 @@
CREATE TABLE IF NOT EXISTS "AppBanner" (
"id" TEXT NOT NULL,
"slot" TEXT NOT NULL,
"title" TEXT NOT NULL,
"subtitle" TEXT,
"ctaLabel" TEXT,
"ctaLink" TEXT,
"imageUrl" TEXT,
"displayOrder" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AppBanner_pkey" PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "AppBanner_slot_isActive_displayOrder_idx"
ON "AppBanner"("slot", "isActive", "displayOrder");
INSERT INTO "AppBanner" ("id", "slot", "title", "subtitle", "ctaLabel", "ctaLink", "displayOrder", "isActive", "createdAt", "updatedAt")
SELECT 'home-hero-default', 'home_hero', 'کالکشن پاییز و زمستان', 'جدیدترین پارچه‌ها', 'مشاهده کالکشن', '/categories', 0, true, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
WHERE NOT EXISTS (SELECT 1 FROM "AppBanner" WHERE "id" = 'home-hero-default');
+1
View File
@@ -0,0 +1 @@
provider = "postgresql"
+751
View File
@@ -0,0 +1,751 @@
generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "debian-openssl-3.0.x"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum SettlementType {
cash
check
}
enum CheckRegistrationMode {
full
brief
}
enum OrderStatus {
cart
awaiting_settlement
cash_in_progress
check_in_progress
submitted
awaiting_review
pricing
needs_correction
awaiting_settlement_fix
cash_invoice_issued
awaiting_cash_payment
cash_payment_submitted
awaiting_cash_confirmation
cash_confirmed
awaiting_check_review
check_needs_fix
check_rejected
checks_approved
final_approved
preparing
ready_to_ship
shipped
delivered
cancelled
rejected
expired
}
enum CheckStatus {
pending_review
approved
rejected
}
enum CashPaymentStatus {
pending_review
approved
rejected
expired
}
enum ShipmentStatus {
preparing
ready
shipped
delivered
}
enum CustomerType {
wholesale
retail
}
enum AppUserRole {
customer
agent
}
enum InteractionType {
in_person
call
message
note
}
enum ProductSeason {
summer
winter
all_season
}
enum ProductUsageCategory {
trousers
suit
shirt
other
}
enum AdminRole {
admin
sales
finance
warehouse
}
enum NotificationTone {
info
warning
success
danger
}
/// گردش کالیته فیزیکی (§۷ سند)
enum CatalogRequestStatus {
new
approved
ready_to_ship
delivered
returned
cancelled
}
enum CatalogItemHolderType {
warehouse
customer
agent
}
model Company {
id String @id @default(cuid())
name String
slug String @unique
description String?
logoUrl String?
isActive Boolean @default(true)
displayOrder Int @default(0)
/// خالی یعنی از تنظیمات سیستم ارث می‌برد
maxMeteragePerInvoice Int?
cashInvoiceValidityHours Int @default(8)
cashInvoiceValidUntilHour Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
bankAccounts BankAccount[]
catalogs Catalog[]
products Product[]
orders Order[]
customers Customer[]
}
model BankAccount {
id String @id @default(cuid())
companyId String
bankName String
accountHolder String
accountNumber String
cardNumber String
iban String
notes String?
isActive Boolean @default(true)
displayOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
cashReceipts CashPaymentReceipt[]
@@index([companyId])
}
model Catalog {
id String @id @default(cuid())
companyId String
name String
code String
description String?
displayOrder Int @default(0)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
products Product[]
@@unique([companyId, code])
@@index([companyId])
}
model Product {
id String @id @default(cuid())
companyId String
catalogId String
name String
code String
color String
description String?
minMeterage Float @default(30)
orderMultiplier Float @default(10)
basePrice Float?
stock Float?
isActive Boolean @default(true)
/// فیلدهای نمایش کاتالوگ موکاپ (اپ کاربر)
usageCategory ProductUsageCategory @default(other)
season ProductSeason @default(all_season)
widthCm Float?
composition String?
weightGrams Float?
weaveType String?
colorsJson Json?
tagsJson Json?
imageUrlsJson Json?
subtitle String?
rollMeters Float @default(50)
isNew Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
catalog Catalog @relation(fields: [catalogId], references: [id], onDelete: Cascade)
cartItems CartItem[]
orderItems OrderItem[]
favorites ProductFavorite[]
catalogRequestItems CatalogRequestItem[]
stockNotifyRequests StockNotifyRequest[]
productSuggestions ProductSuggestion[]
@@unique([companyId, code])
@@index([catalogId])
@@index([companyId])
@@index([usageCategory])
@@index([season])
}
model Customer {
id String @id @default(cuid())
firstName String
lastName String
phone String @unique
nationalId String?
customerType CustomerType @default(wholesale)
/// نقش در اپ کاربر: مشتری عادی یا واسطه
appRole AppUserRole @default(customer)
agentCode String?
businessName String?
levelLabel String?
nextLevelLabel String?
pointsToNext Int?
levelProgress Int? @default(0)
assignedAgentId String?
avatarLetter String?
avatarColor String?
avatarUrl String?
companyId String?
levelDefId String?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
levelDef CustomerLevelDef? @relation(fields: [levelDefId], references: [id], onDelete: SetNull)
assignedAgent Customer? @relation("AgentCustomers", fields: [assignedAgentId], references: [id], onDelete: SetNull)
agentCustomers Customer[] @relation("AgentCustomers")
carts Cart[]
orders Order[]
notifications CustomerNotification[]
meetings MeetingInteraction[] @relation("MeetingCustomer")
meetingsAsAgent MeetingInteraction[] @relation("MeetingAgent")
reminders Reminder[] @relation("ReminderOwner")
remindersAbout Reminder[] @relation("ReminderCustomer")
favorites ProductFavorite[]
catalogRequests CatalogRequest[]
stockNotifyRequests StockNotifyRequest[]
productSuggestions ProductSuggestion[]
customerBrands CustomerBrand[]
@@index([companyId])
@@index([appRole])
@@index([assignedAgentId])
@@index([agentCode])
@@index([levelDefId])
}
model AdminUser {
id String @id @default(cuid())
fullName String
phone String @unique
email String @unique
passwordHash String
role AdminRole @default(admin)
isActive Boolean @default(true)
lastLoginAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model OtpChallenge {
id String @id @default(cuid())
phone String
code String
firstName String?
lastName String?
isRegister Boolean @default(false)
attempts Int @default(0)
expiresAt DateTime
createdAt DateTime @default(now())
@@index([phone])
}
model Cart {
id String @id @default(cuid())
userId String
companyId String?
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user Customer @relation(fields: [userId], references: [id], onDelete: Cascade)
items CartItem[]
@@index([userId])
}
model CartItem {
id String @id @default(cuid())
cartId String
productId String
catalogCode String
productName String
productCode String
color String
meterage Float
unitPrice Float?
notes String?
companyId String
companyName String
cart Cart @relation(fields: [cartId], references: [id], onDelete: Cascade)
product Product @relation(fields: [productId], references: [id], onDelete: Restrict)
@@unique([cartId, productId])
@@index([cartId])
}
model Order {
id String @id @default(cuid())
orderNumber String @unique
userId String
companyId String
companyName String
// روش تسویه بعد از قیمت‌گذاری ادمین توسط مشتری انتخاب می‌شود
settlementType SettlementType?
checkMode CheckRegistrationMode?
status OrderStatus @default(awaiting_review)
totalMeterage Float @default(0)
totalAmount Float?
shippingCost Float?
totalDiscount Float?
finalAmount Float?
notes String?
checkRassDays Int?
checkRassDate DateTime?
cashInvoiceExpiresAt DateTime?
/// زمان ارسال یادآوری پایان مهلت پرداخت تا دوباره ارسال نشود
cashReminderSentAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user Customer @relation(fields: [userId], references: [id], onDelete: Restrict)
company Company @relation(fields: [companyId], references: [id], onDelete: Restrict)
items OrderItem[]
checks Check[]
cashReceipt CashPaymentReceipt?
shipment Shipment?
statusHistory OrderStatusHistory[]
notifications CustomerNotification[]
@@index([userId])
@@index([companyId])
@@index([status])
@@index([createdAt])
@@index([status, cashInvoiceExpiresAt])
}
model OrderItem {
id String @id @default(cuid())
orderId String
productId String?
catalogCode String
productName String
productCode String?
color String
requestedMeterage Float
confirmedMeterage Float?
unitPrice Float?
unitDiscount Float? @default(0)
lineTotal Float?
notes String?
companyId String
companyName String
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
product Product? @relation(fields: [productId], references: [id], onDelete: SetNull)
@@index([orderId])
@@index([companyId])
}
model OrderStatusHistory {
id String @id @default(cuid())
orderId String
fromStatus OrderStatus?
toStatus OrderStatus
changedBy String
note String?
reason String?
changedAt DateTime @default(now())
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
@@index([orderId])
}
model Check {
id String @id @default(cuid())
orderId String
row Int
amount Float
dueDate DateTime
sayadId String?
checkNumber String?
ownerNationalId String?
ownerFirstName String?
ownerLastName String?
bankName String?
branchName String?
frontImage String?
backImage String?
notes String?
mode CheckRegistrationMode @default(full)
status CheckStatus @default(pending_review)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
@@index([orderId])
@@index([dueDate])
}
model CashPaymentReceipt {
id String @id @default(cuid())
orderId String @unique
amount Float
paidAmount Float?
transferDate DateTime?
transferTime String?
trackingNumber String?
destinationAccountId String?
receiptImage String?
notes String?
expiresAt DateTime?
status CashPaymentStatus @default(pending_review)
submittedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
destinationAccount BankAccount? @relation(fields: [destinationAccountId], references: [id], onDelete: SetNull)
}
model Shipment {
id String @id @default(cuid())
orderId String @unique
carrier String
trackingNumber String?
meterage Float
status ShipmentStatus @default(preparing)
shippedAt DateTime?
deliveredAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
}
model SystemSettings {
id String @id @default("default")
maxMeteragePerInvoice Int @default(5000)
cashInvoiceValidityHours Int @default(8)
defaultSettlementCheck Boolean @default(true)
defaultSettlementCash Boolean @default(true)
/// حداقل سفارش: تعداد طاقه از هر محصول (§۱۹)
minOrderRollsPerProduct Int @default(1)
/// نمایش قیمت عمومی در کاتالوگ (پیش‌فرض خاموش)
showCatalogPublicPrice Boolean @default(false)
/// اجازه قیمت‌گذاری توسط واسطه (§۱۹)
allowAgentPricing Boolean @default(false)
/// کف قیمت واحد برای قیمت‌گذاری واسطه (ریال) — اختیاری
agentMinUnitPrice Int?
smsOrderRegistered Boolean @default(true)
smsPricingReady Boolean @default(true)
smsPaymentReminder Boolean @default(true)
smsCheckReview Boolean @default(true)
smsShippingUpdate Boolean @default(true)
supportPhone String?
supportEmail String?
/// تصاویر دسته‌های کاربرد/فصل اپ کاربر: { trousers: dataUrl, summer: dataUrl, ... }
categoryImagesJson Json?
updatedAt DateTime @updatedAt
}
/// بنرهای قابل مدیریت بخش‌های اپ کاربر
model AppBanner {
id String @id @default(cuid())
slot String
title String
subtitle String?
ctaLabel String?
ctaLink String?
imageUrl String?
displayOrder Int @default(0)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([slot, isActive, displayOrder])
}
model AdminNotification {
id String @id @default(cuid())
title String
body String
tone NotificationTone? @default(info)
link String?
isRead Boolean @default(false)
sentAt DateTime @default(now())
}
model CustomerNotification {
id String @id @default(cuid())
customerId String
orderId String?
event String
title String
body String
tone NotificationTone? @default(info)
link String?
isRead Boolean @default(false)
smsSent Boolean @default(false)
sentAt DateTime @default(now())
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
order Order? @relation(fields: [orderId], references: [id], onDelete: SetNull)
@@index([customerId, isRead])
@@index([sentAt])
}
model InternalMessage {
id String @id @default(cuid())
senderName String
subject String
preview String
isRead Boolean @default(false)
sentAt DateTime @default(now())
}
model MeetingInteraction {
id String @id @default(cuid())
customerId String
agentId String
type InteractionType @default(in_person)
title String
description String?
subject String?
content String?
date String
time String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
customer Customer @relation("MeetingCustomer", fields: [customerId], references: [id], onDelete: Cascade)
agent Customer @relation("MeetingAgent", fields: [agentId], references: [id], onDelete: Cascade)
@@index([customerId])
@@index([agentId])
@@index([date])
}
model Reminder {
id String @id @default(cuid())
ownerId String
customerId String
title String
description String?
note String?
date String
time String
interactionType InteractionType @default(note)
completed Boolean @default(false)
completedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
owner Customer @relation("ReminderOwner", fields: [ownerId], references: [id], onDelete: Cascade)
customer Customer @relation("ReminderCustomer", fields: [customerId], references: [id], onDelete: Cascade)
@@index([ownerId, completed])
@@index([customerId])
@@index([date])
}
model ProductFavorite {
id String @id @default(cuid())
customerId String
productId String
createdAt DateTime @default(now())
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
@@unique([customerId, productId])
@@index([customerId])
}
model Brand {
id String @id @default(cuid())
name String
slug String? @unique
description String?
isActive Boolean @default(true)
displayOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
customers CustomerBrand[]
}
model CustomerBrand {
customerId String
brandId String
createdAt DateTime @default(now())
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
brand Brand @relation(fields: [brandId], references: [id], onDelete: Cascade)
@@id([customerId, brandId])
@@index([brandId])
}
model CustomerLevelDef {
id String @id @default(cuid())
code String @unique
label String
sortOrder Int @default(0)
criteriaJson Json?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
benefits LevelBenefit[]
customers Customer[]
}
model LevelBenefit {
id String @id @default(cuid())
levelId String
title String
description String?
level CustomerLevelDef @relation(fields: [levelId], references: [id], onDelete: Cascade)
@@index([levelId])
}
model CatalogRequest {
id String @id @default(cuid())
customerId String
status CatalogRequestStatus @default(new)
note String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
items CatalogRequestItem[]
@@index([customerId])
@@index([status])
}
model CatalogRequestItem {
id String @id @default(cuid())
requestId String
productId String
quantity Int @default(1)
holderType CatalogItemHolderType @default(warehouse)
holderCustomerId String?
request CatalogRequest @relation(fields: [requestId], references: [id], onDelete: Cascade)
product Product @relation(fields: [productId], references: [id], onDelete: Restrict)
movements CatalogItemMovement[]
@@index([requestId])
@@index([productId])
}
model CatalogItemMovement {
id String @id @default(cuid())
itemId String
fromHolder String
toHolder String
note String?
movedAt DateTime @default(now())
item CatalogRequestItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
@@index([itemId])
}
model StockNotifyRequest {
id String @id @default(cuid())
customerId String
productId String
active Boolean @default(true)
createdAt DateTime @default(now())
notifiedAt DateTime?
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
@@unique([customerId, productId])
@@index([productId, active])
}
model ProductSuggestion {
id String @id @default(cuid())
customerId String
productId String
note String?
createdByAdminId String?
isActive Boolean @default(true)
createdAt DateTime @default(now())
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
@@index([customerId, isActive])
@@index([productId])
}
+882
View File
@@ -0,0 +1,882 @@
import {
PrismaClient,
AdminRole,
AppUserRole,
CustomerType,
OrderStatus,
SettlementType,
CheckRegistrationMode,
CheckStatus,
CashPaymentStatus,
ShipmentStatus,
InteractionType,
ProductSeason,
ProductUsageCategory,
} from '@prisma/client';
import * as bcrypt from 'bcryptjs';
const prisma = new PrismaClient();
async function main() {
console.log('Seeding fabric_trade database...');
await prisma.productFavorite.deleteMany();
await prisma.reminder.deleteMany();
await prisma.meetingInteraction.deleteMany();
await prisma.orderStatusHistory.deleteMany();
await prisma.cashPaymentReceipt.deleteMany();
await prisma.check.deleteMany();
await prisma.shipment.deleteMany();
await prisma.orderItem.deleteMany();
await prisma.order.deleteMany();
await prisma.cartItem.deleteMany();
await prisma.cart.deleteMany();
await prisma.otpChallenge.deleteMany();
await prisma.customerNotification.deleteMany();
await prisma.product.deleteMany();
await prisma.catalog.deleteMany();
await prisma.bankAccount.deleteMany();
await prisma.customer.deleteMany();
await prisma.company.deleteMany();
await prisma.adminUser.deleteMany();
await prisma.adminNotification.deleteMany();
await prisma.internalMessage.deleteMany();
await prisma.systemSettings.deleteMany();
const passwordHash = await bcrypt.hash('123456', 10);
await prisma.adminUser.create({
data: {
id: 'usr-1',
fullName: 'مدیر سیستم',
phone: '09128081392',
email: 'admin@parche.ir',
passwordHash,
role: AdminRole.admin,
isActive: true,
},
});
await prisma.company.createMany({
data: [
{
id: 'company-par',
name: 'شرکت پَر',
slug: 'par',
description: 'تولیدکننده پارچه‌های باکیفیت',
isActive: true,
displayOrder: 1,
maxMeteragePerInvoice: null,
cashInvoiceValidityHours: 8,
cashInvoiceValidUntilHour: 17,
},
{
id: 'company-fam-baft',
name: 'فام‌بافت',
slug: 'fam-baft',
description: 'عرضه پارچه‌های تخصصی',
isActive: true,
displayOrder: 2,
maxMeteragePerInvoice: null,
cashInvoiceValidityHours: 8,
cashInvoiceValidUntilHour: 17,
},
],
});
await prisma.bankAccount.createMany({
data: [
{
id: 'ba-par-1',
companyId: 'company-par',
bankName: 'بانک ملی',
accountHolder: 'شرکت پَر',
accountNumber: '0123456789',
cardNumber: '6037-9912-3456-7890',
iban: 'IR120170000000012345678901',
isActive: true,
displayOrder: 1,
},
{
id: 'ba-par-2',
companyId: 'company-par',
bankName: 'بانک صادرات',
accountHolder: 'شرکت پَر',
accountNumber: '9876543210',
cardNumber: '6037-6912-3456-7891',
iban: 'IR620190000000098765432101',
isActive: true,
displayOrder: 2,
},
{
id: 'ba-fam-1',
companyId: 'company-fam-baft',
bankName: 'بانک ملت',
accountHolder: 'فام‌بافت',
accountNumber: '1122334455',
cardNumber: '6104-3378-9012-3456',
iban: 'IR450120000000011223344556',
isActive: true,
displayOrder: 1,
},
],
});
await prisma.catalog.createMany({
data: [
{ id: 'cat-377', companyId: 'company-par', name: 'کالیته ۳۷۷', code: '377', displayOrder: 1, isActive: true },
{ id: 'cat-378', companyId: 'company-par', name: 'کالیته ۳۷۸', code: '378', displayOrder: 2, isActive: true },
{ id: 'cat-379', companyId: 'company-par', name: 'کالیته ۳۷۹', code: '379', displayOrder: 3, isActive: true },
{ id: 'cat-380', companyId: 'company-par', name: 'کالیته ۳۸۰', code: '380', displayOrder: 4, isActive: true },
{ id: 'cat-381', companyId: 'company-par', name: 'کالیته ۳۸۱', code: '381', displayOrder: 5, isActive: true },
{ id: 'cat-fam-401', companyId: 'company-fam-baft', name: 'کالیته ۴۰۱', code: '401', displayOrder: 1, isActive: true },
{ id: 'cat-fam-402', companyId: 'company-fam-baft', name: 'کالیته ۴۰۲', code: '402', displayOrder: 2, isActive: true },
],
});
await prisma.product.createMany({
data: [
{
id: 'prod-377-blue',
companyId: 'company-par',
catalogId: 'cat-377',
name: 'فاستونی ایتالیایی',
code: '377-BL',
color: 'آبی',
minMeterage: 30,
orderMultiplier: 10,
basePrice: 185000,
stock: 10000,
isActive: true,
usageCategory: ProductUsageCategory.suit,
season: ProductSeason.winter,
widthCm: 150,
composition: '۸۰٪ پشم، ۲۰٪ پلی‌استر',
weightGrams: 280,
weaveType: 'تویل',
colorsJson: ['#1e3a8a', '#111827', '#6b7280', '#92400e'],
tagsJson: ['کت‌شلواری', 'زمستانه', 'رسمی'],
subtitle: 'مناسب کت و شلوار رسمی',
rollMeters: 50,
isNew: true,
},
{
id: 'prod-377-black',
companyId: 'company-par',
catalogId: 'cat-377',
name: 'پارچه مشکی رسمی',
code: '377-BK',
color: 'مشکی',
minMeterage: 30,
orderMultiplier: 10,
basePrice: 192000,
stock: 8000,
isActive: true,
usageCategory: ProductUsageCategory.trousers,
season: ProductSeason.all_season,
widthCm: 150,
composition: '۷۰٪ پشم، ۳۰٪ پلی‌استر',
weightGrams: 260,
weaveType: 'ساده',
colorsJson: ['#111827', '#374151'],
tagsJson: ['شلواری', 'چهارفصل'],
subtitle: 'شلوار رسمی روزانه',
rollMeters: 50,
isNew: true,
},
{
id: 'prod-378-blue',
companyId: 'company-par',
catalogId: 'cat-378',
name: 'پیراهنی نخی آبی',
code: '378-BL',
color: 'آبی',
minMeterage: 50,
orderMultiplier: 10,
basePrice: 200000,
stock: 5000,
isActive: true,
usageCategory: ProductUsageCategory.shirt,
season: ProductSeason.summer,
widthCm: 145,
composition: '۱۰۰٪ پنبه',
weightGrams: 120,
weaveType: 'پاپلین',
colorsJson: ['#3b82f6', '#93c5fd', '#ffffff'],
tagsJson: ['پیراهنی', 'تابستانه'],
subtitle: 'خنک و سبک',
rollMeters: 40,
isNew: true,
},
{
id: 'prod-378-black',
companyId: 'company-par',
catalogId: 'cat-378',
name: 'پیراهنی مشکی',
code: '378-BK',
color: 'مشکی',
minMeterage: 50,
orderMultiplier: 10,
basePrice: 205000,
stock: 5000,
isActive: true,
usageCategory: ProductUsageCategory.shirt,
season: ProductSeason.all_season,
widthCm: 145,
composition: '۹۵٪ پنبه، ۵٪ الاستان',
weightGrams: 130,
colorsJson: ['#111827', '#4b5563'],
tagsJson: ['پیراهنی'],
subtitle: 'پیراهن اداری',
rollMeters: 40,
isNew: false,
},
{
id: 'prod-378-blue-white',
companyId: 'company-par',
catalogId: 'cat-378',
name: 'آبی پشت سفید',
code: '378-BLW',
color: 'آبی پشت سفید',
minMeterage: 50,
orderMultiplier: 10,
basePrice: 210000,
stock: 3000,
isActive: true,
usageCategory: ProductUsageCategory.suit,
season: ProductSeason.summer,
widthCm: 150,
composition: '۵۵٪ پشم، ۴۵٪ پلی‌استر',
weightGrams: 220,
colorsJson: ['#60a5fa', '#f8fafc'],
tagsJson: ['کت‌شلواری', 'تابستانه'],
subtitle: 'سبک تابستانه',
rollMeters: 50,
isNew: false,
},
{
id: 'prod-378-black-white',
companyId: 'company-par',
catalogId: 'cat-378',
name: 'مشکی پشت سفید',
code: '378-BKW',
color: 'مشکی پشت سفید',
minMeterage: 50,
orderMultiplier: 10,
basePrice: 215000,
stock: 3000,
isActive: true,
usageCategory: ProductUsageCategory.trousers,
season: ProductSeason.winter,
widthCm: 150,
composition: '۶۰٪ پشم، ۴۰٪ پلی‌استر',
weightGrams: 300,
colorsJson: ['#111827', '#f8fafc'],
tagsJson: ['شلواری', 'زمستانه'],
subtitle: 'گرم زمستانه',
rollMeters: 50,
isNew: false,
},
{
id: 'prod-fam-401-1',
companyId: 'company-fam-baft',
catalogId: 'cat-fam-401',
name: 'پارچه مخمل',
code: '401-MV',
color: 'سرمه‌ای',
minMeterage: 40,
orderMultiplier: 10,
basePrice: 250000,
stock: 6000,
isActive: true,
usageCategory: ProductUsageCategory.suit,
season: ProductSeason.winter,
widthCm: 140,
composition: 'مخمل پلی‌استر',
weightGrams: 320,
colorsJson: ['#1e3a5f', '#7c2d12', '#365314'],
tagsJson: ['کت‌شلواری', 'زمستانه'],
subtitle: 'مخمل لوکس',
rollMeters: 45,
isNew: true,
},
{
id: 'prod-fam-401-2',
companyId: 'company-fam-baft',
catalogId: 'cat-fam-401',
name: 'پارچه کجراه',
code: '401-KJ',
color: 'کرم',
minMeterage: 40,
orderMultiplier: 10,
basePrice: null,
stock: 4000,
isActive: true,
usageCategory: ProductUsageCategory.trousers,
season: ProductSeason.all_season,
widthCm: 150,
composition: 'کجراه پنبه‌ای',
weightGrams: 240,
colorsJson: ['#d6c3a8', '#8b7355'],
tagsJson: ['شلواری', 'چهارفصل'],
subtitle: 'روزمره',
rollMeters: 50,
isNew: false,
},
],
});
await prisma.customer.create({
data: {
id: 'agent-1',
firstName: 'علی',
lastName: 'رضایی',
phone: '09121112233',
nationalId: '0011111111',
customerType: CustomerType.wholesale,
appRole: AppUserRole.agent,
agentCode: 'W-1247',
levelLabel: 'طلایی',
nextLevelLabel: 'VIP',
pointsToNext: 550_000_000,
levelProgress: 72,
companyId: 'company-par',
isActive: true,
avatarLetter: 'ع',
avatarColor: '#0B3D33',
createdAt: new Date('2024-01-10T10:00:00'),
},
});
await prisma.customer.createMany({
data: [
{
id: 'cust-1',
firstName: 'سارا',
lastName: 'محمدی',
phone: '09121234567',
nationalId: '0012345678',
customerType: CustomerType.wholesale,
companyId: 'company-par',
businessName: 'بوتیک رز',
assignedAgentId: 'agent-1',
levelLabel: 'نقره‌ای',
avatarLetter: 'پ',
avatarColor: '#f9a8d4',
isActive: true,
createdAt: new Date('2024-03-12T10:00:00'),
},
{
id: 'cust-2',
firstName: 'نرگس',
lastName: 'احمدی',
phone: '09135551234',
nationalId: '1030567890',
customerType: CustomerType.wholesale,
companyId: 'company-fam-baft',
businessName: 'مزون الوان',
assignedAgentId: 'agent-1',
levelLabel: 'برنزی',
avatarLetter: 'م',
avatarColor: '#c4b5fd',
isActive: true,
createdAt: new Date('2024-05-20T14:30:00'),
},
{
id: 'cust-3',
firstName: 'فروشگاه',
lastName: 'پارچه مهر',
phone: '09128877665',
nationalId: '0012345679',
customerType: CustomerType.retail,
companyId: 'company-par',
businessName: 'پارچه مهر',
assignedAgentId: 'agent-1',
levelLabel: 'برنزی',
avatarLetter: 'م',
avatarColor: '#93c5fd',
isActive: true,
createdAt: new Date('2024-07-08T09:15:00'),
},
{
id: 'cust-4',
firstName: 'پوشاک',
lastName: 'سپهر',
phone: '09369876543',
nationalId: '1050987654',
customerType: CustomerType.wholesale,
companyId: 'company-par',
businessName: 'پوشاک سپهر',
assignedAgentId: 'agent-1',
levelLabel: 'برنزی',
avatarLetter: 'س',
avatarColor: '#fcd34d',
isActive: false,
createdAt: new Date('2024-08-01T11:00:00'),
},
{
id: 'cust-5',
firstName: 'بافت',
lastName: 'آسان',
phone: '09121110000',
nationalId: '0065432109',
customerType: CustomerType.wholesale,
companyId: 'company-fam-baft',
businessName: 'بافت آسان',
assignedAgentId: 'agent-1',
levelLabel: 'نقره‌ای',
avatarLetter: 'ب',
avatarColor: '#6ee7b7',
isActive: true,
createdAt: new Date('2024-06-01T10:00:00'),
},
{
id: 'cust-6',
firstName: 'مینا',
lastName: 'کریمی',
phone: '09129990001',
nationalId: '0077007700',
customerType: CustomerType.retail,
companyId: 'company-par',
businessName: 'مزون مینا',
assignedAgentId: 'agent-1',
levelLabel: 'برنزی',
avatarLetter: 'ن',
avatarColor: '#fda4af',
isActive: true,
createdAt: new Date('2024-09-01T10:00:00'),
},
],
});
const order1 = await prisma.order.create({
data: {
id: 'ord-1',
orderNumber: 'ORD-1403-1256',
userId: 'cust-1',
companyId: 'company-par',
companyName: 'شرکت پَر',
settlementType: SettlementType.check,
checkMode: CheckRegistrationMode.full,
status: OrderStatus.awaiting_review,
totalMeterage: 4250,
totalAmount: 2_450_000_000,
createdAt: new Date('2024-10-25T10:30:00'),
items: {
create: [
{
id: 'oi-1',
productId: 'prod-377-blue',
catalogCode: '377',
productName: 'پارچه آبی',
productCode: '377-BL',
color: 'آبی',
requestedMeterage: 2000,
confirmedMeterage: 2000,
},
{
id: 'oi-2',
productId: 'prod-377-black',
catalogCode: '377',
productName: 'پارچه مشکی',
productCode: '377-BK',
color: 'مشکی',
requestedMeterage: 1500,
confirmedMeterage: 1000,
},
{
id: 'oi-3',
productId: 'prod-378-blue-white',
catalogCode: '378',
productName: 'آبی پشت سفید',
productCode: '378-BLW',
color: 'آبی پشت سفید',
requestedMeterage: 750,
confirmedMeterage: 750,
},
],
},
checks: {
create: [
{
id: 'chk-1',
row: 1,
amount: 1_000_000_000,
bankName: 'ملت',
sayadId: '1234567890123456',
dueDate: new Date('2024-10-21'),
status: CheckStatus.pending_review,
mode: CheckRegistrationMode.full,
},
{
id: 'chk-2',
row: 2,
amount: 800_000_000,
bankName: 'ملی',
sayadId: '2345678901234567',
dueDate: new Date('2024-10-26'),
status: CheckStatus.pending_review,
mode: CheckRegistrationMode.full,
},
{
id: 'chk-3',
row: 3,
amount: 650_000_000,
bankName: 'ملت',
sayadId: '3456789012345678',
dueDate: new Date('2024-10-31'),
status: CheckStatus.pending_review,
mode: CheckRegistrationMode.full,
},
],
},
statusHistory: {
create: [
{ fromStatus: null, toStatus: OrderStatus.submitted, changedBy: 'customer', changedAt: new Date('2024-10-25T10:30:00') },
{ fromStatus: OrderStatus.submitted, toStatus: OrderStatus.awaiting_review, changedBy: 'system', changedAt: new Date('2024-10-25T10:30:01') },
],
},
},
});
await prisma.order.create({
data: {
id: 'ord-2',
orderNumber: 'ORD-1403-1255',
userId: 'cust-2',
companyId: 'company-fam-baft',
companyName: 'فام‌بافت',
settlementType: SettlementType.check,
checkMode: CheckRegistrationMode.brief,
status: OrderStatus.awaiting_check_review,
totalMeterage: 3200,
totalAmount: 1_850_000_000,
createdAt: new Date('2024-10-24T14:15:00'),
items: {
create: [
{
productId: 'prod-fam-401-1',
catalogCode: '401',
productName: 'پارچه مخمل',
productCode: '401-MV',
color: 'سرمه‌ای',
requestedMeterage: 3200,
confirmedMeterage: 3200,
unitPrice: 250000,
lineTotal: 800_000_000,
},
],
},
cashReceipt: {
create: {
id: 'cp-1',
amount: 1_850_000_000,
paidAmount: 1_850_000_000,
transferDate: new Date('2024-10-25T08:30:00'),
trackingNumber: '9876543210',
expiresAt: new Date('2024-10-25T17:00:00'),
status: CashPaymentStatus.pending_review,
submittedAt: new Date('2024-10-25T08:30:00'),
},
},
statusHistory: {
create: [
{ fromStatus: null, toStatus: OrderStatus.submitted, changedBy: 'customer' },
{ fromStatus: OrderStatus.submitted, toStatus: OrderStatus.awaiting_check_review, changedBy: 'admin' },
],
},
},
});
await prisma.order.create({
data: {
id: 'ord-3',
orderNumber: 'ORD-1403-1254',
userId: 'cust-1',
companyId: 'company-par',
companyName: 'شرکت پَر',
settlementType: SettlementType.cash,
status: OrderStatus.final_approved,
totalMeterage: 1800,
totalAmount: 980_000_000,
finalAmount: 980_000_000,
createdAt: new Date('2024-10-23T09:00:00'),
cashReceipt: {
create: {
id: 'cp-2',
amount: 980_000_000,
paidAmount: 980_000_000,
transferDate: new Date('2024-10-24T15:20:00'),
trackingNumber: '1234567890',
destinationAccountId: 'ba-par-1',
expiresAt: new Date('2024-10-24T17:00:00'),
status: CashPaymentStatus.approved,
submittedAt: new Date('2024-10-24T15:20:00'),
},
},
},
});
await prisma.order.create({
data: {
id: 'ord-4',
orderNumber: 'ORD-1403-1253',
userId: 'cust-5',
companyId: 'company-fam-baft',
companyName: 'فام‌بافت',
settlementType: SettlementType.check,
status: OrderStatus.check_needs_fix,
totalMeterage: 5100,
totalAmount: 3_200_000_000,
createdAt: new Date('2024-10-22T16:45:00'),
},
});
await prisma.order.create({
data: {
id: 'ord-5',
orderNumber: 'ORD-1403-1252',
userId: 'cust-1',
companyId: 'company-par',
companyName: 'شرکت پَر',
settlementType: SettlementType.cash,
status: OrderStatus.delivered,
totalMeterage: 1200,
totalAmount: 750_000_000,
finalAmount: 750_000_000,
createdAt: new Date('2024-10-21T11:20:00'),
shipment: {
create: {
id: 'shp-delivered-5',
carrier: 'باربری تهران',
trackingNumber: 'BR-1403-88001',
meterage: 1200,
status: ShipmentStatus.delivered,
shippedAt: new Date('2024-10-19T09:00:00'),
deliveredAt: new Date('2024-10-21T11:00:00'),
},
},
cashReceipt: {
create: {
id: 'cp-3',
amount: 750_000_000,
paidAmount: 750_000_000,
transferDate: new Date('2024-10-18T11:00:00'),
trackingNumber: '5555123456',
destinationAccountId: 'ba-par-2',
expiresAt: new Date('2024-10-18T17:00:00'),
status: CashPaymentStatus.approved,
submittedAt: new Date('2024-10-18T11:00:00'),
},
},
},
});
await prisma.order.create({
data: {
id: 'ord-6',
orderNumber: 'ORD-1403-1251',
userId: 'cust-1',
companyId: 'company-par',
companyName: 'شرکت پَر',
settlementType: SettlementType.check,
status: OrderStatus.shipped,
totalMeterage: 2900,
totalAmount: 1_600_000_000,
createdAt: new Date('2024-10-20T08:30:00'),
shipment: {
create: {
id: 'shp-1',
carrier: 'باربری تهران-اصفهان',
trackingNumber: 'BR-1403-88901',
meterage: 2900,
status: ShipmentStatus.shipped,
shippedAt: new Date('2024-10-24T09:00:00'),
},
},
},
});
await prisma.order.create({
data: {
id: 'ord-7',
orderNumber: 'ORD-1403-1250',
userId: 'cust-5',
companyId: 'company-fam-baft',
companyName: 'فام‌بافت',
settlementType: SettlementType.check,
status: OrderStatus.awaiting_check_review,
totalMeterage: 3800,
totalAmount: 2_100_000_000,
createdAt: new Date('2024-10-19T13:00:00'),
shipment: {
create: {
id: 'shp-2',
carrier: 'باربری سریع',
trackingNumber: 'BR-1403-88902',
meterage: 3800,
status: ShipmentStatus.ready,
},
},
},
});
await prisma.order.create({
data: {
id: 'ord-8',
orderNumber: 'ORD-1403-1208',
userId: 'cust-3',
companyId: 'company-par',
companyName: 'شرکت پَر',
settlementType: SettlementType.cash,
status: OrderStatus.preparing,
totalMeterage: 950,
totalAmount: 450_000_000,
createdAt: new Date('2024-10-18T10:00:00'),
shipment: {
create: {
id: 'shp-3',
carrier: 'پیک داخلی',
trackingNumber: 'PK-1403-0045',
meterage: 950,
status: ShipmentStatus.preparing,
},
},
cashReceipt: {
create: {
id: 'cp-4',
amount: 450_000_000,
paidAmount: null,
transferDate: null,
trackingNumber: null,
expiresAt: new Date('2024-10-22T17:00:00'),
status: CashPaymentStatus.expired,
},
},
},
});
await prisma.order.create({
data: {
id: 'ord-9',
orderNumber: 'ORD-1403-1199',
userId: 'cust-1',
companyId: 'company-par',
companyName: 'شرکت پَر',
settlementType: SettlementType.check,
status: OrderStatus.delivered,
totalMeterage: 4100,
totalAmount: 2_200_000_000,
createdAt: new Date('2024-10-15T10:00:00'),
shipment: {
create: {
id: 'shp-4',
carrier: 'باربری تهران-مشهد',
trackingNumber: 'BR-1403-88750',
meterage: 4100,
status: ShipmentStatus.delivered,
shippedAt: new Date('2024-10-18T10:00:00'),
deliveredAt: new Date('2024-10-20T16:30:00'),
},
},
},
});
await prisma.systemSettings.create({
data: {
id: 'default',
maxMeteragePerInvoice: 5000,
cashInvoiceValidityHours: 8,
defaultSettlementCheck: true,
defaultSettlementCash: true,
smsOrderRegistered: true,
smsPaymentReminder: true,
smsCheckReview: true,
smsShippingUpdate: true,
supportPhone: '02191000000',
supportEmail: 'support@fabrictrade.ir',
},
});
await prisma.meetingInteraction.createMany({
data: [
{
id: 'meet-1',
agentId: 'agent-1',
customerId: 'cust-1',
type: InteractionType.in_person,
title: 'بازدید از بوتیک رز',
description: 'نیاز به پارچه‌های طوسی و سرمه‌ای اعلام شد.',
subject: 'بازدید حضوری',
content: 'نیاز به پارچه‌های طوسی، سرمه‌ای و ذغالی با عرض مناسب.',
date: '1403/03/20',
time: '11:30',
},
{
id: 'meet-2',
agentId: 'agent-1',
customerId: 'cust-2',
type: InteractionType.call,
title: 'پیگیری موجودی',
description: 'تماس برای اعلام موجودی فاستونی.',
date: '1403/03/18',
time: '10:00',
},
],
});
await prisma.reminder.createMany({
data: [
{
id: 'rem-1',
ownerId: 'agent-1',
customerId: 'cust-1',
title: 'پیگیری موجودی پارچه فاستونی طوسی',
description: 'اعلام موجودی و ارسال نمونه',
note: 'تماس برای اعلام موجودی و ارسال نمونه',
date: '1403/03/23',
time: '10:00',
interactionType: InteractionType.call,
completed: false,
},
{
id: 'rem-2',
ownerId: 'agent-1',
customerId: 'cust-2',
title: 'ارسال کالیته جدید',
description: 'ارسال کالیته زمستانه برای مزون الوان',
date: '1403/03/24',
time: '14:00',
interactionType: InteractionType.in_person,
completed: false,
},
{
id: 'rem-3',
ownerId: 'agent-1',
customerId: 'cust-3',
title: 'پیگیری سفارش قبلی',
description: 'بررسی وضعیت سفارش و تسویه',
date: '1403/03/10',
time: '09:00',
interactionType: InteractionType.note,
completed: true,
completedAt: new Date('2024-10-20T09:30:00'),
},
],
});
void order1;
console.log('Seed completed successfully.');
console.log('Admin login: 09128081392 / 123456');
console.log('Agent OTP login: 09121112233');
console.log('Customer OTP login: 09121234567');
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
IMAGE_NAME="${IMAGE_NAME:-fabric-trade-api}"
IMAGE_TAG="${IMAGE_TAG:-latest}"
FULL_IMAGE="${IMAGE_NAME}:${IMAGE_TAG}"
OUT_DIR="${OUT_DIR:-./dist-docker}"
OUT_TAR="${OUT_DIR}/${IMAGE_NAME}-${IMAGE_TAG}.tar"
NPM_REGISTRY="${NPM_REGISTRY:-https://registry.npmmirror.com}"
mkdir -p "$OUT_DIR"
echo "==> Building image locally: $FULL_IMAGE"
echo "==> npm registry: $NPM_REGISTRY"
docker build \
--build-arg "NPM_REGISTRY=${NPM_REGISTRY}" \
-t "$FULL_IMAGE" \
-f Dockerfile \
.
echo "==> Saving image to $OUT_TAR ..."
docker save "$FULL_IMAGE" -o "$OUT_TAR"
# فشرده‌سازی برای آپلود سریع‌تر
echo "==> Compressing..."
gzip -f -k "$OUT_TAR"
echo ""
echo "آماده:"
echo " $OUT_TAR"
echo " ${OUT_TAR}.gz"
ls -lh "$OUT_TAR" "${OUT_TAR}.gz"
echo ""
echo "آپلود روی سرور:"
echo " scp ${OUT_TAR}.gz root@SERVER:/opt/parche/server/"
echo ""
echo "روی سرور:"
echo " gunzip -f fabric-trade-api-latest.tar.gz"
echo " docker load -i fabric-trade-api-latest.tar"
echo " RUN_SEED=true docker compose -f docker-compose.image.yml up -d"
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
MIRROR="${NPM_REGISTRY:-https://registry.npmmirror.com}"
echo "==> بیلد لوکال بدون Docker"
echo "==> registry: $MIRROR"
echo "==> node: $(node -v)"
npm config set registry "$MIRROR"
# Prisma postinstall باید اجازه اجرا داشته باشد
npm config set ignore-scripts false || true
if [ ! -d node_modules ]; then
echo "==> npm ci..."
npm ci --no-audit --no-fund
fi
# اگر اسکریپت‌های prisma بلاک شده باشند
npx --yes prisma generate
echo "==> nest/tsc build (با پاک کردن کش)..."
rm -rf dist tsconfig.build.tsbuildinfo
npx nest build
if [ ! -f dist/main.js ]; then
echo "==> nest build خروجی نداد؛ fallback به tsc..."
rm -rf dist tsconfig.build.tsbuildinfo
npx tsc -p tsconfig.build.json
fi
if [ ! -f dist/main.js ]; then
echo "ERROR: dist/main.js ساخته نشد"
ls -la dist || true
exit 1
fi
echo "==> OK: dist/main.js ($(wc -c < dist/main.js) bytes)"
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
MIRROR="${NPM_REGISTRY:-https://registry.npmmirror.com}"
OUT="deploy-bundle.tar.gz"
PACK=".deploy-pack"
echo "==> بیلد + بسته‌بندی کامل (dist + node_modules)"
echo "==> node: $(node -v)"
npm config set registry "$MIRROR"
npm ci --no-audit --no-fund
DATABASE_URL="${DATABASE_URL:-postgresql://fabric:fabric123@localhost:5432/fabric_trade}" npx prisma generate
rm -rf dist tsconfig.build.tsbuildinfo
npx nest build
test -f dist/main.js
rm -rf "$PACK" /tmp/fabric-prod-pack
mkdir -p "$PACK" /tmp/fabric-prod-pack
cp package.json package-lock.json /tmp/fabric-prod-pack/
cp -a prisma /tmp/fabric-prod-pack/
cp prisma.config.ts /tmp/fabric-prod-pack/
(
cd /tmp/fabric-prod-pack
sed -i "s|https://registry.npmjs.org|${MIRROR}|g" package-lock.json || true
npm ci --omit=dev --no-audit --no-fund --registry="$MIRROR"
npm install prisma ts-node typescript --no-save --no-audit --no-fund --registry="$MIRROR"
DATABASE_URL="${DATABASE_URL:-postgresql://fabric:fabric123@localhost:5432/fabric_trade}" npx prisma generate
)
cp package.json package-lock.json "$PACK/"
cp -a prisma "$PACK/"
cp prisma.config.ts docker-entrypoint.sh "$PACK/"
cp -a dist "$PACK/"
cp -a /tmp/fabric-prod-pack/node_modules "$PACK/"
cp Dockerfile.bundle "$PACK/Dockerfile"
cp docker-compose.bundle.yml "$PACK/docker-compose.yml"
cp .env.example "$PACK/"
cat > "$PACK/.dockerignore" <<'EOF'
.git
.env
*.md
EOF
tar -czf "$OUT" -C "$PACK" .
ls -lh "$OUT"
echo ""
echo "فایل آماده: $(pwd)/$OUT"
echo "آپلود:"
echo " scp $(pwd)/$OUT root@SERVER:/opt/parche/"
echo "روی سرور:"
echo " mkdir -p /opt/parche/app && tar -xzf /opt/parche/deploy-bundle.tar.gz -C /opt/parche/app"
echo " cd /opt/parche/app && cp -n .env.example .env"
echo " RUN_SEED=true docker compose up -d --build"
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# کلاینت Prisma را روی سیستم دولوپر می‌سازد و بسته‌بندی می‌کند.
# سرور نباید prisma generate بزند؛ روی هاست پروडاکشن دقایق طول می‌کشد.
set -euo pipefail
cd "$(dirname "$0")/.."
OUT="prisma-client.tar.gz"
SRC="node_modules/.prisma/client"
if [ ! -d node_modules/prisma ]; then
echo "ERROR: node_modules نیست. اول npm ci بزن." >&2
exit 1
fi
# خروجی قبلی پاک شود تا engineهای بی‌استفاده در بسته نمانند
rm -rf "$SRC"
DATABASE_URL="${DATABASE_URL:-postgresql://build:build@localhost:5432/build?schema=public}" \
CHECKPOINT_DISABLE=1 ./node_modules/.bin/prisma generate
test -f "$SRC/libquery_engine-debian-openssl-3.0.x.so.node" \
|| { echo "ERROR: engine مربوط به debian-openssl-3.0.x ساخته نشد." >&2; exit 1; }
rm -f "$OUT"
tar -czf "$OUT" -C node_modules/.prisma client
ls -lh "$OUT"
+43
View File
@@ -0,0 +1,43 @@
/**
* ادمین‌های قبلی را پاک می‌کند و فقط یک ادمین می‌سازد.
* داخل کانتینر API:
* node scripts/reset-admin.mjs
* یا با DATABASE_URL محلی:
* node scripts/reset-admin.mjs
*/
const { PrismaClient } = require('@prisma/client');
const bcrypt = require('bcryptjs');
const PHONE = process.env.ADMIN_PHONE || '09128081392';
const PASSWORD = process.env.ADMIN_PASSWORD || '123456';
const FULL_NAME = process.env.ADMIN_NAME || 'مدیر سیستم';
async function main() {
const prisma = new PrismaClient();
try {
const passwordHash = await bcrypt.hash(PASSWORD, 10);
await prisma.$transaction([
prisma.adminUser.deleteMany({}),
prisma.adminUser.create({
data: {
id: 'usr-1',
fullName: FULL_NAME,
phone: PHONE,
email: 'admin@parche.ir',
passwordHash,
role: 'admin',
isActive: true,
},
}),
prisma.internalMessage.deleteMany({}),
]);
console.log(`Admin reset OK: ${PHONE} / ${PASSWORD}`);
} finally {
await prisma.$disconnect();
}
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
# روی سرور: یک دستور
set -euo pipefail
cd "$(dirname "$0")/.."
git pull --no-rebase
docker compose up -d --build
docker compose ps
echo "health: curl -s http://127.0.0.1:3000/api/health"
+530
View File
@@ -0,0 +1,530 @@
/**
* تست دود گردش کامل سفارش مطابق روال کارفرما:
* مشتری بدون دیدن قیمت سفارش می‌دهد، ادمین قیمت می‌گذارد و فاکتور می‌فرستد،
* مشتری روش تسویه را انتخاب می‌کند و مسیر نقدی/چکی تا تایید نهایی پیش می‌رود.
* اجرا: node scripts/smoke-order-flow.mjs
*/
const BASE = process.env.SMOKE_BASE_URL || 'http://127.0.0.1:3999/api';
const CUSTOMER_PHONE = '09121234567';
const ADMIN_PHONE = '09128081392';
const ADMIN_PASSWORD = '123456';
let passed = 0;
let failed = 0;
function check(name, condition, detail) {
if (condition) {
passed++;
console.log(` PASS ${name}`);
} else {
failed++;
console.log(` FAIL ${name}${detail ? ` → ${detail}` : ''}`);
}
}
async function api(path, { method = 'GET', body, token } = {}) {
const res = await fetch(`${BASE}${path}`, {
method,
headers: {
Accept: 'application/json',
...(body ? { 'Content-Type': 'application/json' } : {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const text = await res.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = text;
}
return { status: res.status, data };
}
function isoInDays(days) {
return new Date(Date.now() + days * 86400000).toISOString().slice(0, 10);
}
/** تصویر base64 بزرگ‌تر از سقف پیش‌فرض ۱۰۰KB اکسپرس */
function bigBase64(kb) {
return `data:image/jpeg;base64,${'A'.repeat(kb * 1024)}`;
}
function checkPayload(amount, days, extra = {}) {
return {
amount,
dueDate: isoInDays(days),
sayadId: '1234567890123456',
checkNumber: '123456',
ownerNationalId: '0012345678',
ownerFirstName: 'علی',
ownerLastName: 'رضایی',
bankName: 'ملی',
branchName: 'مرکزی',
mode: 'full',
...extra,
};
}
async function main() {
console.log(`\n== Smoke test @ ${BASE} ==\n`);
console.log('[1] احراز هویت');
const otp = await api('/auth/otp/request', {
method: 'POST',
body: { phone: CUSTOMER_PHONE },
});
check('درخواست OTP', otp.status === 201 || otp.status === 200, `status=${otp.status}`);
const code = otp.data?.debugCode ?? '1234';
check('کد OTP چهار رقمی است', /^\d{4}$/.test(String(code)), `code=${code}`);
const badVerify = await api('/auth/otp/verify', {
method: 'POST',
body: { phone: CUSTOMER_PHONE, code: String(code) === '0000' ? '1111' : '0000' },
});
check('کد اشتباه رد می‌شود', badVerify.status === 401, `status=${badVerify.status}`);
const verify = await api('/auth/otp/verify', {
method: 'POST',
body: { phone: CUSTOMER_PHONE, code: String(code) },
});
check('ورود مشتری', !!verify.data?.accessToken, JSON.stringify(verify.data).slice(0, 120));
const userToken = verify.data?.accessToken;
const adminLogin = await api('/admin/auth/login', {
method: 'POST',
body: { phone: ADMIN_PHONE, password: ADMIN_PASSWORD },
});
check('ورود ادمین', !!adminLogin.data?.accessToken, JSON.stringify(adminLogin.data).slice(0, 120));
const adminToken = adminLogin.data?.accessToken;
console.log('\n[2] جداسازی دسترسی');
const crossAccess = await api('/admin/orders', { token: userToken });
check('توکن مشتری به مسیر ادمین دسترسی ندارد', crossAccess.status === 403, `status=${crossAccess.status}`);
const custWithAdmin = await api('/orders', { token: adminToken });
check('توکن ادمین به مسیر مشتری دسترسی ندارد', custWithAdmin.status === 403, `status=${custWithAdmin.status}`);
const noToken = await api('/orders');
check('بدون توکن ۴۰۱', noToken.status === 401, `status=${noToken.status}`);
console.log('\n[3] سبد خرید بدون قیمت');
const companies = await api('/companies/public');
const company = companies.data?.[0];
check('لیست شرکت‌ها', !!company, JSON.stringify(companies.data).slice(0, 120));
check(
'سقف متراژ موثر برگردانده می‌شود',
typeof company?.maxMeteragePerInvoice === 'number' && company.maxMeteragePerInvoice > 0,
`max=${company?.maxMeteragePerInvoice}`,
);
const products = await api(`/products?companyId=${company.id}`, { token: userToken });
const product = (Array.isArray(products.data) ? products.data : products.data?.items)?.find(
(p) => p.companyId === company.id,
);
check('لیست محصولات', !!product, JSON.stringify(products.data).slice(0, 160));
const meterage = Math.max(product.minMeterage ?? 1, product.orderMultiplier ?? 1);
await api('/carts', { method: 'DELETE', token: userToken });
const addItem = await api('/carts/items', {
method: 'POST',
token: userToken,
body: { productId: product.id, meterage },
});
check('افزودن به سبد', addItem.status < 300, `status=${addItem.status} ${JSON.stringify(addItem.data).slice(0, 120)}`);
const cartItem = addItem.data?.items?.[0];
check('سبد قیمت واحد ندارد', cartItem?.unitPrice === undefined, `unitPrice=${cartItem?.unitPrice}`);
console.log('\n[4] ثبت سفارش بدون روش تسویه و بدون قیمت');
const cashOrder = await api('/orders', {
method: 'POST',
token: userToken,
body: { companyId: company.id, items: [{ productId: product.id, meterage }] },
});
check('ثبت سفارش', cashOrder.status < 300, `status=${cashOrder.status} ${JSON.stringify(cashOrder.data).slice(0, 200)}`);
const cashOrderId = cashOrder.data?.id;
check('وضعیت اولیه در انتظار بررسی', cashOrder.data?.status === 'awaiting_review', `status=${cashOrder.data?.status}`);
check('روش تسویه خالی است', cashOrder.data?.settlementType == null, `settlement=${cashOrder.data?.settlementType}`);
check('مبلغ برای مشتری پنهان است', cashOrder.data?.finalAmount == null && cashOrder.data?.totalAmount == null);
check('پرچم pricesVisible خاموش است', cashOrder.data?.pricesVisible === false, `flag=${cashOrder.data?.pricesVisible}`);
check(
'قیمت واحد آیتم‌ها پنهان است',
(cashOrder.data?.items ?? []).every((i) => i.unitPrice == null),
);
console.log('\n[5] اعتبارسنجی ورودی');
const extraField = await api('/orders', {
method: 'POST',
token: userToken,
body: { companyId: company.id, items: [{ productId: product.id, meterage }], bogusField: 'x' },
});
check('فیلد ناشناخته ۴۰۰ می‌دهد', extraField.status === 400, `status=${extraField.status}`);
const earlySettlement = await api(`/orders/${cashOrderId}/settlement`, {
method: 'POST',
token: userToken,
body: { settlementType: 'cash' },
});
check('انتخاب تسویه قبل از فاکتور رد می‌شود', earlySettlement.status === 400, `status=${earlySettlement.status}`);
const earlyReceipt = await api(`/orders/${cashOrderId}/cash-receipt`, {
method: 'POST',
token: userToken,
body: {
amount: 1000,
transferDate: isoInDays(0),
transferTime: '12:00',
trackingNumber: '1',
destinationAccountId: 'x',
},
});
check('رسید قبل از فاکتور رد می‌شود', earlyReceipt.status === 400, `status=${earlyReceipt.status}`);
console.log('\n[6] سطل‌های وضعیت ادمین');
const adminOrders = await api('/admin/orders', { token: adminToken });
const adminIds = (adminOrders.data ?? []).map((o) => o.id);
check('سفارش برای ادمین دیده می‌شود', adminIds.includes(cashOrderId));
const allowedTabs = new Set([
'pending_review',
'awaiting_customer',
'pending_check',
'pending_payment',
'needs_correction',
'check_correction',
'final_approval',
'shipped',
'delivered',
'cancelled',
'rejected',
'expired',
]);
const unknown = [...new Set((adminOrders.data ?? []).map((o) => o.adminStatus))].filter(
(s) => !allowedTabs.has(s),
);
check('همه وضعیت‌ها به تب معتبر نگاشت شده‌اند', unknown.length === 0, `unknown=${unknown.join(',')}`);
const filtered = await api('/admin/orders?status=pending_review', { token: adminToken });
check(
'فیلتر «در انتظار بررسی» سفارش را برمی‌گرداند',
(filtered.data ?? []).some((o) => o.id === cashOrderId),
`count=${(filtered.data ?? []).length}`,
);
console.log('\n[7] قیمت‌گذاری ادمین');
const detail = await api(`/admin/orders/${cashOrderId}`, { token: adminToken });
const items = detail.data?.items ?? [];
check('سفارش آیتم دارد', items.length > 0);
check(
'ادمین گذرهای مجاز را می‌بیند',
Array.isArray(detail.data?.allowedTransitions) && detail.data.allowedTransitions.includes('pricing'),
`transitions=${JSON.stringify(detail.data?.allowedTransitions)}`,
);
const earlyInvoice = await api(`/admin/orders/${cashOrderId}/status`, {
method: 'PATCH',
token: adminToken,
body: { status: 'awaiting_settlement' },
});
check('ارسال فاکتور بدون قیمت‌گذاری رد می‌شود', earlyInvoice.status === 400, `status=${earlyInvoice.status}`);
const pricing = await api(`/admin/orders/${cashOrderId}/pricing`, {
method: 'PATCH',
token: adminToken,
body: {
items: [{ id: items[0].id, confirmedMeterage: meterage, unitPrice: 100000, unitDiscount: 0 }],
shippingCost: 50000,
},
});
const expectedTotal = 100000 * meterage;
const expectedFinal = expectedTotal + 50000;
check('قیمت‌گذاری موفق', pricing.status < 300, `status=${pricing.status} ${JSON.stringify(pricing.data).slice(0, 150)}`);
check('جمع کل درست است', Math.abs((pricing.data?.totalAmount ?? 0) - expectedTotal) < 0.01, `total=${pricing.data?.totalAmount}`);
check('مبلغ نهایی = جمع + حمل', Math.abs((pricing.data?.finalAmount ?? 0) - expectedFinal) < 0.01, `final=${pricing.data?.finalAmount}`);
const stillHidden = await api(`/orders/${cashOrderId}`, { token: userToken });
check('قیمت تا ارسال فاکتور برای مشتری پنهان است', stillHidden.data?.finalAmount == null, `final=${stillHidden.data?.finalAmount}`);
const badDiscount = await api(`/admin/orders/${cashOrderId}/pricing`, {
method: 'PATCH',
token: adminToken,
body: { items: [{ id: items[0].id, unitPrice: 1000, unitDiscount: 5000 }] },
});
check('تخفیف بیشتر از قیمت رد می‌شود', badDiscount.status === 400, `status=${badDiscount.status}`);
console.log('\n[8] ارسال فاکتور برای مشتری');
await api(`/admin/orders/${cashOrderId}/pricing`, {
method: 'PATCH',
token: adminToken,
body: {
items: [{ id: items[0].id, confirmedMeterage: meterage, unitPrice: 100000, unitDiscount: 0 }],
shippingCost: 50000,
},
});
const invoice = await api(`/admin/orders/${cashOrderId}/status`, {
method: 'PATCH',
token: adminToken,
body: { status: 'awaiting_settlement', note: 'فاکتور نهایی شد' },
});
check('ارسال فاکتور', invoice.status < 300, `status=${invoice.status} ${JSON.stringify(invoice.data).slice(0, 150)}`);
const priced = await api(`/orders/${cashOrderId}`, { token: userToken });
check('مشتری مبلغ فاکتور را می‌بیند', Math.abs((priced.data?.finalAmount ?? 0) - expectedFinal) < 0.01, `final=${priced.data?.finalAmount}`);
check('pricesVisible روشن شد', priced.data?.pricesVisible !== false);
const illegal = await api(`/admin/orders/${cashOrderId}/status`, {
method: 'PATCH',
token: adminToken,
body: { status: 'delivered' },
});
check('گذر غیرمجاز بدون force رد می‌شود', illegal.status === 400, `status=${illegal.status}`);
const forceNoReason = await api(`/admin/orders/${cashOrderId}/status`, {
method: 'PATCH',
token: adminToken,
body: { status: 'delivered', force: true },
});
check('force بدون دلیل رد می‌شود', forceNoReason.status === 400, `status=${forceNoReason.status}`);
console.log('\n[9] اعلان مشتری');
const notifs = await api('/notifications', { token: userToken });
const invoiceNotif = (notifs.data ?? []).find((n) => n.event === 'invoice_priced');
check('اعلان صدور فاکتور ساخته شد', !!invoiceNotif, JSON.stringify(notifs.data)?.slice(0, 200));
check('اعلان به سفارش لینک دارد', invoiceNotif?.link === `/orders/${cashOrderId}`, `link=${invoiceNotif?.link}`);
const unread = await api('/notifications/unread-count', { token: userToken });
check('شمارنده خوانده‌نشده مثبت است', (unread.data?.count ?? 0) > 0, `count=${unread.data?.count}`);
if (invoiceNotif) {
const marked = await api(`/notifications/${invoiceNotif.id}/read`, { method: 'PATCH', token: userToken });
check('علامت خوانده‌شده', marked.status < 300 && marked.data?.count < (unread.data?.count ?? 0), JSON.stringify(marked.data));
}
const readAll = await api('/notifications/read-all', { method: 'PATCH', token: userToken });
check('خواندن همه', readAll.status < 300 && (readAll.data?.count ?? -1) === 0, JSON.stringify(readAll.data));
console.log('\n[10] مسیر نقدی');
const chooseCash = await api(`/orders/${cashOrderId}/settlement`, {
method: 'POST',
token: userToken,
body: { settlementType: 'cash' },
});
check('انتخاب تسویه نقدی', chooseCash.status < 300, `status=${chooseCash.status} ${JSON.stringify(chooseCash.data).slice(0, 150)}`);
check('وضعیت در انتظار پرداخت', chooseCash.data?.status === 'awaiting_cash_payment', `status=${chooseCash.data?.status}`);
const expiry = chooseCash.data?.cashInvoiceExpiresAt;
check('انقضای فاکتور در آینده است', !!expiry && new Date(expiry) > new Date(), `expiry=${expiry}`);
const accounts = await api(`/companies/${company.id}/bank-accounts`, { token: userToken });
const account = (accounts.data ?? [])[0];
check('حساب بانکی موجود', !!account, JSON.stringify(accounts.data).slice(0, 120));
const receipt = await api(`/orders/${cashOrderId}/cash-receipt`, {
method: 'POST',
token: userToken,
body: {
amount: expectedFinal,
transferDate: isoInDays(0),
transferTime: '12:00',
trackingNumber: '987654321',
destinationAccountId: account.id,
receiptImage: bigBase64(400),
},
});
check('ثبت رسید (تصویر ۴۰۰KB)', receipt.status < 300, `status=${receipt.status} ${JSON.stringify(receipt.data).slice(0, 200)}`);
const afterReceipt = await api(`/orders/${cashOrderId}`, { token: userToken });
check('وضعیت به تأیید پرداخت رفت', afterReceipt.data?.status === 'awaiting_cash_confirmation', `status=${afterReceipt.data?.status}`);
const receipts = await api('/admin/cash-payments', { token: adminToken });
const pendingReceipt = (receipts.data ?? []).find((r) => r.orderId === cashOrderId);
check('رسید در صف ادمین است', !!pendingReceipt, JSON.stringify(receipts.data)?.slice(0, 160));
const rejectReceipt = await api(`/admin/cash-payments/${pendingReceipt.id}/status`, {
method: 'PATCH',
token: adminToken,
body: { status: 'rejected', note: 'مبلغ نامطابق' },
});
check('رد رسید', rejectReceipt.status < 300, `status=${rejectReceipt.status}`);
const afterReject = await api(`/orders/${cashOrderId}`, { token: userToken });
check(
'رد رسید مشتری را به مرحله پرداخت برمی‌گرداند',
afterReject.data?.status === 'awaiting_cash_payment',
`status=${afterReject.data?.status}`,
);
const approveReceipt = await api(`/admin/cash-payments/${pendingReceipt.id}/status`, {
method: 'PATCH',
token: adminToken,
body: { status: 'approved' },
});
check('تأیید رسید', approveReceipt.status < 300, `status=${approveReceipt.status}`);
const afterApprove = await api(`/orders/${cashOrderId}`, { token: userToken });
check('وضعیت پرداخت تأییدشده', afterApprove.data?.status === 'cash_confirmed', `status=${afterApprove.data?.status}`);
const finalApprove = await api(`/admin/orders/${cashOrderId}/status`, {
method: 'PATCH',
token: adminToken,
body: { status: 'final_approved' },
});
check('تأیید نهایی سفارش نقدی', finalApprove.status < 300, `status=${finalApprove.status}`);
console.log('\n[11] مسیر چکی با حلقه اصلاح');
await api('/carts', { method: 'DELETE', token: userToken });
await api('/carts/items', {
method: 'POST',
token: userToken,
body: { productId: product.id, meterage },
});
const checkOrder = await api('/orders', {
method: 'POST',
token: userToken,
body: { companyId: company.id, items: [{ productId: product.id, meterage }] },
});
const checkOrderId = checkOrder.data?.id;
check('ثبت سفارش دوم', checkOrder.status < 300, `status=${checkOrder.status}`);
const checkDetail = await api(`/admin/orders/${checkOrderId}`, { token: adminToken });
const checkItem = checkDetail.data?.items?.[0];
await api(`/admin/orders/${checkOrderId}/pricing`, {
method: 'PATCH',
token: adminToken,
body: { items: [{ id: checkItem.id, confirmedMeterage: meterage, unitPrice: 100000 }] },
});
await api(`/admin/orders/${checkOrderId}/status`, {
method: 'PATCH',
token: adminToken,
body: { status: 'awaiting_settlement' },
});
const chooseCheck = await api(`/orders/${checkOrderId}/settlement`, {
method: 'POST',
token: userToken,
body: { settlementType: 'check', checkMode: 'full' },
});
check('انتخاب تسویه چکی', chooseCheck.status < 300, `status=${chooseCheck.status} ${JSON.stringify(chooseCheck.data).slice(0, 150)}`);
check('وضعیت در حال ثبت چک', chooseCheck.data?.status === 'check_in_progress', `status=${chooseCheck.data?.status}`);
const submitChecks = await api(`/orders/${checkOrderId}/checks`, {
method: 'PATCH',
token: userToken,
body: {
checkMode: 'full',
checks: [
checkPayload(expectedTotal, 30, {
frontImage: bigBase64(300),
backImage: bigBase64(300),
}),
],
},
});
check('ارسال چک‌ها (بدنه ~۶۰۰KB)', submitChecks.status < 300, `status=${submitChecks.status} ${JSON.stringify(submitChecks.data).slice(0, 200)}`);
check('وضعیت بررسی چک', submitChecks.data?.status === 'awaiting_check_review', `status=${submitChecks.data?.status}`);
check('راس چک محاسبه شد', submitChecks.data?.checkRassDays === 30, `rass=${submitChecks.data?.checkRassDays}`);
const orderChecks = await api(`/admin/orders/${checkOrderId}/checks`, { token: adminToken });
const firstCheck = (orderChecks.data ?? [])[0];
check('چک برای ادمین دیده می‌شود', !!firstCheck, JSON.stringify(orderChecks.data)?.slice(0, 160));
const rejectCheck = await api(`/admin/checks/${firstCheck.id}/status`, {
method: 'PATCH',
token: adminToken,
body: { status: 'rejected', note: 'تصویر ناخوانا' },
});
check('رد چک', rejectCheck.status < 300, `status=${rejectCheck.status}`);
const afterCheckReject = await api(`/orders/${checkOrderId}`, { token: userToken });
check(
'رد چک به اصلاح می‌رود نه بن‌بست',
afterCheckReject.data?.status === 'check_needs_fix',
`status=${afterCheckReject.data?.status}`,
);
const fixChecks = await api(`/orders/${checkOrderId}/checks`, {
method: 'PATCH',
token: userToken,
body: { checkMode: 'full', checks: [checkPayload(expectedTotal, 45)] },
});
check('اصلاح چک‌ها', fixChecks.status < 300, `status=${fixChecks.status} ${JSON.stringify(fixChecks.data).slice(0, 200)}`);
check('بازگشت به بررسی چک', fixChecks.data?.status === 'awaiting_check_review', `status=${fixChecks.data?.status}`);
const fixedChecks = await api(`/admin/orders/${checkOrderId}/checks`, { token: adminToken });
const approveCheck = await api(`/admin/checks/${fixedChecks.data[0].id}/status`, {
method: 'PATCH',
token: adminToken,
body: { status: 'approved' },
});
check('تأیید چک', approveCheck.status < 300, `status=${approveCheck.status}`);
const afterCheckApprove = await api(`/orders/${checkOrderId}`, { token: userToken });
check('وضعیت تأیید چک‌ها', afterCheckApprove.data?.status === 'checks_approved', `status=${afterCheckApprove.data?.status}`);
console.log('\n[12] تنظیمات سیستم');
const settings = await api('/admin/settings', { token: adminToken });
check('خواندن تنظیمات', settings.status < 300 && typeof settings.data?.maxMeteragePerInvoice === 'number', JSON.stringify(settings.data)?.slice(0, 160));
const savedSettings = await api('/admin/settings', {
method: 'PATCH',
token: adminToken,
body: { smsPricingReady: false, maxMeteragePerInvoice: 6000 },
});
check('ذخیره تنظیمات', savedSettings.data?.maxMeteragePerInvoice === 6000 && savedSettings.data?.smsPricingReady === false, JSON.stringify(savedSettings.data)?.slice(0, 200));
const companyAfter = await api('/companies/public');
check(
'سقف متراژ شرکت از تنظیمات ارث می‌برد',
companyAfter.data?.[0]?.maxMeteragePerInvoice === 6000,
`max=${companyAfter.data?.[0]?.maxMeteragePerInvoice}`,
);
await api('/admin/settings', {
method: 'PATCH',
token: adminToken,
body: { smsPricingReady: true, maxMeteragePerInvoice: 5000 },
});
console.log('\n[13] دسترسی متقاطع بین مشتری‌ها');
const otherPhone = '09129999001';
await api('/auth/otp/request', {
method: 'POST',
body: { phone: otherPhone, firstName: 'تست', lastName: 'دوم', isRegister: true },
});
const otherVerify = await api('/auth/otp/verify', {
method: 'POST',
body: { phone: otherPhone, code: String(code) },
});
const otherToken = otherVerify.data?.accessToken;
check('ثبت‌نام مشتری دوم', !!otherToken, JSON.stringify(otherVerify.data).slice(0, 120));
if (otherToken) {
const foreign = await api(`/orders/${cashOrderId}`, { token: otherToken });
check('سفارش دیگری قابل خواندن نیست', foreign.status === 403, `status=${foreign.status}`);
const foreignSettlement = await api(`/orders/${checkOrderId}/settlement`, {
method: 'POST',
token: otherToken,
body: { settlementType: 'cash' },
});
check('انتخاب تسویه روی سفارش دیگری رد می‌شود', foreignSettlement.status === 403, `status=${foreignSettlement.status}`);
const foreignNotifs = await api('/notifications', { token: otherToken });
check('اعلان‌ها بین مشتری‌ها جدا است', (foreignNotifs.data ?? []).length === 0, `count=${(foreignNotifs.data ?? []).length}`);
}
console.log('\n[14] محافظت از ویرایش بعد از تحویل');
const forceDeliver = await api(`/admin/orders/${checkOrderId}/status`, {
method: 'PATCH',
token: adminToken,
body: { status: 'delivered', force: true, reason: 'تحویل دستی برای تست' },
});
check('گذر دستی با دلیل پذیرفته می‌شود', forceDeliver.status < 300, `status=${forceDeliver.status} ${JSON.stringify(forceDeliver.data).slice(0, 150)}`);
const lateEdit = await api(`/orders/${checkOrderId}/checks`, {
method: 'PATCH',
token: userToken,
body: { checks: [checkPayload(1000, 10)] },
});
check('ویرایش چک بعد از تحویل رد می‌شود', lateEdit.status === 400, `status=${lateEdit.status}`);
const lateReceipt = await api(`/orders/${checkOrderId}/cash-receipt`, {
method: 'POST',
token: userToken,
body: {
amount: 1000,
transferDate: isoInDays(0),
transferTime: '12:00',
trackingNumber: '1',
destinationAccountId: account.id,
},
});
check('رسید نقدی روی سفارش چکی رد می‌شود', lateReceipt.status === 400, `status=${lateReceipt.status}`);
console.log(`\n== نتیجه: ${passed} موفق، ${failed} ناموفق ==\n`);
process.exit(failed === 0 ? 0 : 1);
}
main().catch((e) => {
console.error('اجرای تست شکست خورد:', e);
process.exit(1);
});
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
echo "==> Checking Docker..."
if ! docker info >/dev/null 2>&1; then
echo "Docker در دسترس نیست. کاربر را به گروه docker اضافه کنید:"
echo " sudo usermod -aG docker \"\$USER\""
echo "سپس یک‌بار از سیستم خارج و دوباره وارد شوید."
exit 1
fi
echo "==> Starting PostgreSQL..."
docker compose up -d
echo "==> Waiting for Postgres health..."
for i in $(seq 1 30); do
if docker compose exec -T postgres pg_isready -U fabric -d fabric_trade >/dev/null 2>&1; then
break
fi
sleep 1
done
echo "==> Installing deps (if needed)..."
npm install
echo "==> Prisma generate + migrate + seed..."
npx prisma generate
npx prisma migrate deploy
npm run prisma:seed
echo ""
echo "آماده است. سرور را با این دستور اجرا کنید:"
echo " npm run start:dev"
echo ""
echo "Swagger: http://localhost:3000/api/docs"
echo "Admin: 09128081392 / 123456"
echo "OTP: 09121234567 / 1234"
+40
View File
@@ -0,0 +1,40 @@
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
import { AdminUsersService } from './admin-users.service';
import { AuthType, Roles } from '../common/decorators/auth.decorators';
import {
CreateAdminUserDto,
SetAdminActiveDto,
UpdateAdminUserDto,
} from './dto/admin-user.dto';
@Controller('admin/users')
@AuthType('admin')
@Roles('admin')
export class AdminUsersController {
constructor(private readonly adminUsersService: AdminUsersService) {}
@Get()
findAll() {
return this.adminUsersService.findAll();
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.adminUsersService.findOne(id);
}
@Post()
create(@Body() dto: CreateAdminUserDto) {
return this.adminUsersService.create(dto);
}
@Patch(':id')
update(@Param('id') id: string, @Body() dto: UpdateAdminUserDto) {
return this.adminUsersService.update(id, dto);
}
@Patch(':id/active')
setActive(@Param('id') id: string, @Body() dto: SetAdminActiveDto) {
return this.adminUsersService.setActive(id, dto.isActive);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { AdminUsersController } from './admin-users.controller';
import { AdminUsersService } from './admin-users.service';
@Module({
controllers: [AdminUsersController],
providers: [AdminUsersService],
exports: [AdminUsersService],
})
export class AdminUsersModule {}
+154
View File
@@ -0,0 +1,154 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { AdminRole, Prisma } from '@prisma/client';
import * as bcrypt from 'bcryptjs';
import { PrismaService } from '../prisma/prisma.service';
import { CreateAdminUserDto, UpdateAdminUserDto } from './dto/admin-user.dto';
import {
isValidPhone,
normalizePhone,
toEnglishDigits,
} from '../common/utils/phone';
const DEFAULT_PASSWORD = '123456';
@Injectable()
export class AdminUsersService {
constructor(private readonly prisma: PrismaService) {}
async findAll() {
const users = await this.prisma.adminUser.findMany({
orderBy: { createdAt: 'desc' },
});
return users.map((u) => this.map(u));
}
async findOne(id: string) {
const user = await this.prisma.adminUser.findUnique({ where: { id } });
if (!user) throw new NotFoundException('کاربر ادمین یافت نشد');
return this.map(user);
}
async create(dto: CreateAdminUserDto) {
const phone = normalizePhone(toEnglishDigits(dto.phone));
if (!isValidPhone(phone)) {
throw new BadRequestException('شماره موبایل نامعتبر است');
}
const passwordHash = await bcrypt.hash(
dto.password?.trim() || DEFAULT_PASSWORD,
10,
);
const email =
dto.email?.trim().toLowerCase() ||
`${phone.replace(/\D/g, '')}@noemail.local`
try {
const user = await this.prisma.adminUser.create({
data: {
fullName: dto.fullName.trim(),
phone,
email,
passwordHash,
role: dto.role ?? AdminRole.admin,
isActive: dto.isActive ?? true,
},
});
return this.map(user);
} catch (e) {
if (
e instanceof Prisma.PrismaClientKnownRequestError &&
e.code === 'P2002'
) {
throw new ConflictException('شماره یا ایمیل تکراری است');
}
throw e;
}
}
async update(id: string, dto: UpdateAdminUserDto) {
await this.ensure(id);
const phone =
dto.phone !== undefined
? normalizePhone(toEnglishDigits(dto.phone))
: undefined;
if (phone !== undefined && !isValidPhone(phone)) {
throw new BadRequestException('شماره موبایل نامعتبر است');
}
const data: Prisma.AdminUserUpdateInput = {
fullName: dto.fullName?.trim(),
phone,
email:
dto.email === undefined
? undefined
: dto.email.trim()
? dto.email.trim().toLowerCase()
: undefined,
role: dto.role,
isActive: dto.isActive,
};
if (dto.password?.trim()) {
data.passwordHash = await bcrypt.hash(dto.password.trim(), 10);
}
try {
const user = await this.prisma.adminUser.update({
where: { id },
data,
});
return this.map(user);
} catch (e) {
if (
e instanceof Prisma.PrismaClientKnownRequestError &&
e.code === 'P2002'
) {
throw new ConflictException('شماره یا ایمیل تکراری است');
}
throw e;
}
}
async setActive(id: string, isActive: boolean) {
await this.ensure(id);
const user = await this.prisma.adminUser.update({
where: { id },
data: { isActive },
});
return this.map(user);
}
private async ensure(id: string) {
const user = await this.prisma.adminUser.findUnique({ where: { id } });
if (!user) throw new NotFoundException('کاربر ادمین یافت نشد');
return user;
}
private map(user: {
id: string;
fullName: string;
phone: string;
email: string;
role: AdminRole;
isActive: boolean;
lastLoginAt: Date | null;
createdAt: Date;
updatedAt: Date;
}) {
return {
id: user.id,
fullName: user.fullName,
phone: user.phone,
email: user.email,
role: user.role,
isActive: user.isActive,
lastLoginAt: user.lastLoginAt?.toISOString() ?? null,
createdAt: user.createdAt.toISOString(),
updatedAt: user.updatedAt.toISOString(),
};
}
}
+69
View File
@@ -0,0 +1,69 @@
import {
IsBoolean,
IsEmail,
IsEnum,
IsOptional,
IsString,
MinLength,
} from 'class-validator';
import { AdminRole } from '@prisma/client';
export class CreateAdminUserDto {
@IsString()
@MinLength(1)
fullName!: string;
@IsString()
@MinLength(10)
phone!: string;
@IsOptional()
@IsEmail()
email?: string;
@IsOptional()
@IsString()
@MinLength(4)
password?: string;
@IsOptional()
@IsEnum(AdminRole)
role?: AdminRole;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class UpdateAdminUserDto {
@IsOptional()
@IsString()
@MinLength(1)
fullName?: string;
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsEmail()
email?: string;
@IsOptional()
@IsString()
@MinLength(4)
password?: string;
@IsOptional()
@IsEnum(AdminRole)
role?: AdminRole;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class SetAdminActiveDto {
@IsBoolean()
isActive!: boolean;
}
+119
View File
@@ -0,0 +1,119 @@
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
import { AuthType, Roles } from '../common/decorators/auth.decorators';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import type { AuthPrincipal } from '../common/decorators/current-user.decorator';
import { AgentService } from './agent.service';
import {
CompleteReminderDto,
CreateAgentCustomerDto,
CreateMeetingDto,
CreateReminderDto,
} from './dto/agent.dto';
@Controller()
export class AgentController {
constructor(private readonly agentService: AgentService) {}
@AuthType('any')
@Get('catalog/categories')
categories() {
return this.agentService.listCategories();
}
@AuthType('customer')
@Roles('agent')
@Get('agent/profile')
profile(@CurrentUser() user: AuthPrincipal) {
return this.agentService.getProfile(user.id);
}
@AuthType('customer')
@Roles('agent')
@Get('agent/customers')
customers(
@CurrentUser() user: AuthPrincipal,
@Query('q') q?: string,
@Query('status') status?: string,
) {
return this.agentService.listMyCustomers(user.id, q, status);
}
@AuthType('customer')
@Roles('agent')
@Post('agent/customers')
createCustomer(
@CurrentUser() user: AuthPrincipal,
@Body() dto: CreateAgentCustomerDto,
) {
return this.agentService.createCustomer(user.id, dto);
}
@AuthType('customer')
@Roles('agent')
@Get('agent/customers/:id')
customerDetail(
@CurrentUser() user: AuthPrincipal,
@Param('id') id: string,
) {
return this.agentService.getCustomerDetail(user.id, id);
}
@AuthType('customer')
@Roles('agent')
@Post('agent/meetings')
createMeeting(
@CurrentUser() user: AuthPrincipal,
@Body() dto: CreateMeetingDto,
) {
return this.agentService.createMeeting(user.id, dto);
}
@AuthType('customer')
@Roles('agent')
@Get('agent/reminders')
reminders(
@CurrentUser() user: AuthPrincipal,
@Query('filter') filter?: string,
) {
return this.agentService.listReminders(user.id, filter);
}
@AuthType('customer')
@Roles('agent')
@Get('agent/reminders/:id')
reminderDetail(
@CurrentUser() user: AuthPrincipal,
@Param('id') id: string,
) {
return this.agentService.getReminder(user.id, id);
}
@AuthType('customer')
@Roles('agent')
@Post('agent/reminders')
createReminder(
@CurrentUser() user: AuthPrincipal,
@Body() dto: CreateReminderDto,
) {
return this.agentService.createReminder(user.id, dto);
}
@AuthType('customer')
@Roles('agent')
@Patch('agent/reminders/:id/complete')
completeReminder(
@CurrentUser() user: AuthPrincipal,
@Param('id') id: string,
@Body() dto: CompleteReminderDto,
) {
return this.agentService.completeReminder(user.id, id, dto);
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AgentController } from './agent.controller';
import { AgentService } from './agent.service';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [AgentController],
providers: [AgentService],
exports: [AgentService],
})
export class AgentModule {}
+468
View File
@@ -0,0 +1,468 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { AppUserRole, InteractionType, Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import {
isValidPhone,
normalizePhone,
toEnglishDigits,
} from '../common/utils/phone';
import {
CompleteReminderDto,
CreateAgentCustomerDto,
CreateMeetingDto,
CreateReminderDto,
} from './dto/agent.dto';
import { loadCatalogCategories } from '../catalog/category-defs';
const AVATAR_COLORS = ['#f9a8d4', '#c4b5fd', '#93c5fd', '#86efac', '#fcd34d', '#fdba74'];
@Injectable()
export class AgentService {
constructor(private readonly prisma: PrismaService) {}
private async requireAgent(agentId: string) {
const agent = await this.prisma.customer.findUnique({
where: { id: agentId },
});
if (!agent || agent.appRole !== AppUserRole.agent) {
throw new ForbiddenException('فقط واسطه به این بخش دسترسی دارد');
}
return agent;
}
async getProfile(agentId: string) {
const agent = await this.requireAgent(agentId);
const [customersCount, ordersCount] = await Promise.all([
this.prisma.customer.count({
where: { assignedAgentId: agentId, isActive: true },
}),
this.prisma.order.count({
where: {
user: { assignedAgentId: agentId },
},
}),
]);
const sales = await this.prisma.order.aggregate({
where: {
user: { assignedAgentId: agentId },
finalAmount: { not: null },
},
_sum: { finalAmount: true },
});
const salesVolume = sales._sum.finalAmount ?? 0;
return {
id: agent.id,
firstName: agent.firstName,
lastName: agent.lastName,
code: agent.agentCode ?? '',
levelLabel: agent.levelLabel ?? 'برنزی',
nextLevelLabel: agent.nextLevelLabel ?? 'نقره‌ای',
pointsToNext: agent.pointsToNext ?? 0,
levelProgress: agent.levelProgress ?? 0,
phone: agent.phone,
customersCount,
ordersCount,
salesVolumeToman: salesVolume,
profitToman: Math.round(salesVolume * 0.1),
periodLabel: '۳۰۰ روز اخیر',
};
}
async listMyCustomers(agentId: string, q?: string, status?: string) {
await this.requireAgent(agentId);
const where: Prisma.CustomerWhereInput = {
assignedAgentId: agentId,
...(status === 'inactive' ? { isActive: false } : {}),
...(status === 'active' || status === 'new' ? { isActive: true } : {}),
...(q
? {
OR: [
{ firstName: { contains: q, mode: 'insensitive' } },
{ lastName: { contains: q, mode: 'insensitive' } },
{ businessName: { contains: q, mode: 'insensitive' } },
{ phone: { contains: q } },
],
}
: {}),
};
const customers = await this.prisma.customer.findMany({
where,
orderBy: { createdAt: 'desc' },
include: {
orders: {
select: { finalAmount: true, id: true },
},
},
});
const mapped = customers.map((c) => {
const amounts = c.orders
.map((o) => o.finalAmount ?? 0)
.filter((n) => n > 0);
const total = amounts.reduce((a, b) => a + b, 0);
const orderCount = c.orders.length;
return {
id: c.id,
name: c.businessName || `${c.firstName} ${c.lastName}`,
contactName: `${c.firstName} ${c.lastName}`,
phone: c.phone,
status: (!c.isActive
? 'inactive'
: orderCount === 0
? 'new'
: 'active') as 'active' | 'inactive' | 'new',
avatarLetter: c.avatarLetter || c.firstName.charAt(0),
avatarColor: c.avatarColor || '#c4b5fd',
totalPurchaseToman: total,
orderCount,
averageOrderToman: orderCount ? Math.round(total / orderCount) : 0,
levelLabel: c.levelLabel || 'برنزی',
firstPurchaseDate: '—',
agentCode: undefined as string | undefined,
agentName: undefined as string | undefined,
};
});
if (status === 'active' || status === 'new' || status === 'inactive') {
return mapped.filter((c) => c.status === status);
}
return mapped;
}
async createCustomer(agentId: string, dto: CreateAgentCustomerDto) {
await this.requireAgent(agentId);
const phone = normalizePhone(toEnglishDigits(dto.phone));
if (!isValidPhone(phone)) {
throw new BadRequestException('شماره موبایل نامعتبر است');
}
const firstName = dto.firstName.trim();
const lastName = dto.lastName.trim();
const businessName = dto.businessName?.trim() || null;
if (firstName.length < 2) {
throw new BadRequestException('نام الزامی است');
}
if (lastName.length < 2) {
throw new BadRequestException('نام خانوادگی الزامی است');
}
const existing = await this.prisma.customer.findUnique({ where: { phone } });
if (existing) {
if (existing.assignedAgentId === agentId) {
throw new ConflictException('این مشتری از قبل در لیست شماست');
}
throw new ConflictException(
'این شماره قبلاً ثبت شده است. برای انتساب به لیست خود، از ادمین درخواست کنید.',
);
}
const colorIndex =
phone.split('').reduce((sum, ch) => sum + ch.charCodeAt(0), 0) %
AVATAR_COLORS.length;
const avatarColor = AVATAR_COLORS[colorIndex] ?? '#c4b5fd';
try {
const created = await this.prisma.customer.create({
data: {
firstName,
lastName,
phone,
businessName,
assignedAgentId: agentId,
appRole: AppUserRole.customer,
isActive: true,
avatarLetter: firstName.charAt(0),
avatarColor,
},
});
return {
id: created.id,
name: created.businessName || `${created.firstName} ${created.lastName}`,
contactName: `${created.firstName} ${created.lastName}`,
phone: created.phone,
status: 'new' as const,
avatarLetter: created.avatarLetter || created.firstName.charAt(0),
avatarColor: created.avatarColor || avatarColor,
totalPurchaseToman: 0,
orderCount: 0,
averageOrderToman: 0,
levelLabel: created.levelLabel || 'برنزی',
firstPurchaseDate: '—',
};
} catch (e) {
if (
e instanceof Prisma.PrismaClientKnownRequestError &&
e.code === 'P2002'
) {
throw new ConflictException('این شماره قبلاً ثبت شده است');
}
throw e;
}
}
async getCustomerDetail(agentId: string, customerId: string) {
await this.requireAgent(agentId);
const customer = await this.prisma.customer.findFirst({
where: { id: customerId, assignedAgentId: agentId },
include: {
orders: {
orderBy: { createdAt: 'desc' },
take: 20,
},
},
});
if (!customer) throw new NotFoundException('مشتری یافت نشد');
const agent = await this.prisma.customer.findUnique({
where: { id: agentId },
});
const amounts = customer.orders
.map((o) => o.finalAmount ?? 0)
.filter((n) => n > 0);
const total = amounts.reduce((a, b) => a + b, 0);
const orderCount = customer.orders.length;
const meetings = await this.prisma.meetingInteraction.findMany({
where: { customerId, agentId },
orderBy: { createdAt: 'desc' },
take: 50,
});
return {
customer: {
id: customer.id,
name: customer.businessName || `${customer.firstName} ${customer.lastName}`,
contactName: `${customer.firstName} ${customer.lastName}`,
phone: customer.phone,
status: customer.isActive ? 'active' : 'inactive',
avatarLetter: customer.avatarLetter || customer.firstName.charAt(0),
avatarColor: customer.avatarColor || '#f9a8d4',
totalPurchaseToman: total,
orderCount,
averageOrderToman: orderCount ? Math.round(total / orderCount) : 0,
levelLabel: customer.levelLabel || 'برنزی',
firstPurchaseDate: customer.createdAt.toISOString().slice(0, 10),
agentCode: agent?.agentCode,
agentName: agent ? `${agent.firstName} ${agent.lastName}` : undefined,
},
orders: customer.orders.map((o) => ({
id: o.id,
customerId: customer.id,
title: `سفارش #${o.orderNumber}`,
amountToman: o.finalAmount ?? 0,
statusLabel: o.status,
statusTone: 'info' as const,
date: o.createdAt.toISOString().slice(0, 10),
})),
meetings: meetings.map((m) => ({
id: m.id,
customerId: m.customerId,
type: m.type,
title: m.title,
description: m.description ?? '',
date: m.date,
time: m.time ?? undefined,
})),
};
}
async createMeeting(agentId: string, dto: CreateMeetingDto) {
await this.requireAgent(agentId);
const customer = await this.prisma.customer.findFirst({
where: { id: dto.customerId, assignedAgentId: agentId },
});
if (!customer) throw new NotFoundException('مشتری یافت نشد');
const meeting = await this.prisma.meetingInteraction.create({
data: {
agentId,
customerId: dto.customerId,
type: dto.type,
title: dto.title.trim(),
description: dto.description?.trim(),
subject: dto.subject?.trim(),
content: dto.content?.trim(),
date: dto.date,
time: dto.time,
},
});
let reminder = null;
if (dto.createReminder && dto.reminderTitle && dto.reminderDate && dto.reminderTime) {
reminder = await this.prisma.reminder.create({
data: {
ownerId: agentId,
customerId: dto.customerId,
title: dto.reminderTitle.trim(),
description: dto.reminderNote?.trim(),
note: dto.reminderNote?.trim(),
date: dto.reminderDate,
time: dto.reminderTime,
interactionType: dto.type,
},
});
}
return { meeting, reminder };
}
async listReminders(agentId: string, filter?: string) {
await this.requireAgent(agentId);
const where: Prisma.ReminderWhereInput = { ownerId: agentId };
const today = new Date();
const yyyy = today.getFullYear();
const mm = String(today.getMonth() + 1).padStart(2, '0');
const dd = String(today.getDate()).padStart(2, '0');
const todayStr = `${yyyy}-${mm}-${dd}`;
const weekEnd = new Date(today);
weekEnd.setDate(weekEnd.getDate() + 7);
const weekEndStr = `${weekEnd.getFullYear()}-${String(weekEnd.getMonth() + 1).padStart(2, '0')}-${String(weekEnd.getDate()).padStart(2, '0')}`;
if (filter === 'past') {
where.completed = true;
} else if (filter === 'today') {
where.completed = false;
where.date = todayStr;
} else if (filter === 'week') {
where.completed = false;
where.date = { gte: todayStr, lte: weekEndStr };
} else if (filter === 'all') {
// no extra filters
} else {
where.completed = false;
}
const reminders = await this.prisma.reminder.findMany({
where,
orderBy: [{ date: 'asc' }, { time: 'asc' }],
include: {
customer: true,
},
});
return reminders.map((r) => ({
id: r.id,
customerId: r.customerId,
title: r.title,
description: r.description ?? '',
time: r.time,
date: r.date,
interactionType: r.interactionType,
completed: r.completed,
note: r.note ?? undefined,
customerName:
r.customer.businessName ||
`${r.customer.firstName} ${r.customer.lastName}`,
contactName: `${r.customer.firstName} ${r.customer.lastName}`,
avatarLetter: r.customer.avatarLetter || r.customer.firstName.charAt(0),
avatarColor: r.customer.avatarColor || '#c4b5fd',
}));
}
async getReminder(agentId: string, reminderId: string) {
await this.requireAgent(agentId);
const reminder = await this.prisma.reminder.findFirst({
where: { id: reminderId, ownerId: agentId },
include: { customer: true },
});
if (!reminder) throw new NotFoundException('یادآور یافت نشد');
const history = await this.prisma.meetingInteraction.findMany({
where: { customerId: reminder.customerId, agentId },
orderBy: { createdAt: 'desc' },
take: 20,
});
return {
reminder: {
id: reminder.id,
customerId: reminder.customerId,
title: reminder.title,
description: reminder.description ?? '',
time: reminder.time,
date: reminder.date,
interactionType: reminder.interactionType,
completed: reminder.completed,
note: reminder.note ?? undefined,
},
customer: {
id: reminder.customer.id,
name:
reminder.customer.businessName ||
`${reminder.customer.firstName} ${reminder.customer.lastName}`,
contactName: `${reminder.customer.firstName} ${reminder.customer.lastName}`,
phone: reminder.customer.phone,
avatarLetter:
reminder.customer.avatarLetter || reminder.customer.firstName.charAt(0),
avatarColor: reminder.customer.avatarColor || '#f9a8d4',
},
history: history.map((m) => ({
id: m.id,
type: m.type,
title: m.title,
description: m.description ?? '',
date: m.date,
})),
};
}
async createReminder(agentId: string, dto: CreateReminderDto) {
await this.requireAgent(agentId);
const customer = await this.prisma.customer.findFirst({
where: { id: dto.customerId, assignedAgentId: agentId },
});
if (!customer) throw new NotFoundException('مشتری یافت نشد');
return this.prisma.reminder.create({
data: {
ownerId: agentId,
customerId: dto.customerId,
title: dto.title.trim(),
description: dto.description?.trim(),
note: dto.note?.trim(),
date: dto.date,
time: dto.time,
interactionType: dto.interactionType ?? InteractionType.note,
},
});
}
async completeReminder(
agentId: string,
reminderId: string,
dto: CompleteReminderDto,
) {
await this.requireAgent(agentId);
const reminder = await this.prisma.reminder.findFirst({
where: { id: reminderId, ownerId: agentId },
});
if (!reminder) throw new NotFoundException('یادآور یافت نشد');
const completed = dto.completed ?? true;
return this.prisma.reminder.update({
where: { id: reminderId },
data: {
completed,
completedAt: completed ? new Date() : null,
},
});
}
async listCategories() {
return loadCatalogCategories(this.prisma);
}
}
+112
View File
@@ -0,0 +1,112 @@
import {
IsBoolean,
IsEnum,
IsOptional,
IsString,
MaxLength,
MinLength,
} from 'class-validator';
import { InteractionType } from '@prisma/client';
export class CreateMeetingDto {
@IsString()
customerId!: string;
@IsEnum(InteractionType)
type!: InteractionType;
@IsString()
@MinLength(2)
title!: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsString()
subject?: string;
@IsOptional()
@IsString()
@MaxLength(1000)
content?: string;
@IsString()
date!: string;
@IsOptional()
@IsString()
time?: string;
@IsOptional()
@IsBoolean()
createReminder?: boolean;
@IsOptional()
@IsString()
reminderTitle?: string;
@IsOptional()
@IsString()
reminderDate?: string;
@IsOptional()
@IsString()
reminderTime?: string;
@IsOptional()
@IsString()
reminderNote?: string;
}
export class CreateReminderDto {
@IsString()
customerId!: string;
@IsString()
@MinLength(2)
title!: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsString()
note?: string;
@IsString()
date!: string;
@IsString()
time!: string;
@IsOptional()
@IsEnum(InteractionType)
interactionType?: InteractionType;
}
export class CompleteReminderDto {
@IsOptional()
@IsBoolean()
completed?: boolean;
}
export class CreateAgentCustomerDto {
@IsString()
@MinLength(2)
firstName!: string;
@IsString()
@MinLength(2)
lastName!: string;
@IsString()
@MinLength(10)
phone!: string;
@IsOptional()
@IsString()
businessName?: string;
}
+63
View File
@@ -0,0 +1,63 @@
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { PrismaModule } from './prisma/prisma.module';
import { AuthModule } from './auth/auth.module';
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
import { CompaniesModule } from './companies/companies.module';
import { CatalogsModule } from './catalogs/catalogs.module';
import { ProductsModule } from './products/products.module';
import { CustomersModule } from './customers/customers.module';
import { AdminUsersModule } from './admin-users/admin-users.module';
import { OrdersModule } from './orders/orders.module';
import { ChecksModule } from './checks/checks.module';
import { CashPaymentsModule } from './cash-payments/cash-payments.module';
import { ShipmentsModule } from './shipments/shipments.module';
import { DashboardModule } from './dashboard/dashboard.module';
import { SettingsModule } from './settings/settings.module';
import { CartsModule } from './carts/carts.module';
import { NotificationsModule } from './notifications/notifications.module';
import { AgentModule } from './agent/agent.module';
import { CatalogRequestsModule } from './catalog-requests/catalog-requests.module';
import { BrandsModule } from './brands/brands.module';
import { LevelsModule } from './levels/levels.module';
import { SuggestionsModule } from './suggestions/suggestions.module';
import { StockNotifyModule } from './stock-notify/stock-notify.module';
import { BannersModule } from './banners/banners.module';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
ScheduleModule.forRoot(),
PrismaModule,
AuthModule,
CompaniesModule,
CatalogsModule,
ProductsModule,
CustomersModule,
AdminUsersModule,
OrdersModule,
ChecksModule,
CashPaymentsModule,
ShipmentsModule,
DashboardModule,
SettingsModule,
CartsModule,
NotificationsModule,
AgentModule,
CatalogRequestsModule,
BrandsModule,
LevelsModule,
SuggestionsModule,
StockNotifyModule,
BannersModule,
],
providers: [
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
],
})
export class AppModule {}
+55
View File
@@ -0,0 +1,55 @@
import { Body, Controller, Get, Patch, Post } from '@nestjs/common';
import { AuthService } from './auth.service';
import {
AdminLoginDto,
RequestOtpDto,
UpdateProfileDto,
VerifyOtpDto,
} from './dto/auth.dto';
import { AuthType, Public } from '../common/decorators/auth.decorators';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import type { AuthPrincipal } from '../common/decorators/current-user.decorator';
@Controller()
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Public()
@Get('health')
health() {
return { ok: true, service: 'fabric-trade-api' };
}
@Public()
@Post('auth/otp/request')
requestOtp(@Body() dto: RequestOtpDto) {
return this.authService.requestOtp(dto);
}
@Public()
@Post('auth/otp/verify')
verifyOtp(@Body() dto: VerifyOtpDto) {
return this.authService.verifyOtp(dto);
}
@Public()
@Post('admin/auth/login')
adminLogin(@Body() dto: AdminLoginDto) {
return this.authService.adminLogin(dto);
}
@AuthType('any')
@Get('auth/me')
me(@CurrentUser() user: AuthPrincipal) {
return this.authService.me(user.id, user.type);
}
@AuthType('customer')
@Patch('auth/profile')
updateProfile(
@CurrentUser() user: AuthPrincipal,
@Body() dto: UpdateProfileDto,
) {
return this.authService.updateProfile(user.id, dto);
}
}
+27
View File
@@ -0,0 +1,27 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { SmsModule } from '../sms/sms.module';
import { resolveJwtSecret } from '../common/utils/jwt-secret';
@Module({
imports: [
SmsModule,
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: resolveJwtSecret(config),
signOptions: {
expiresIn: config.get('JWT_EXPIRES_IN', '7d'),
},
}),
}),
],
controllers: [AuthController],
providers: [AuthService],
exports: [AuthService, JwtModule],
})
export class AuthModule {}
+302
View File
@@ -0,0 +1,302 @@
import {
BadRequestException,
Injectable,
ServiceUnavailableException,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { randomInt } from 'node:crypto';
import * as bcrypt from 'bcryptjs';
import { PrismaService } from '../prisma/prisma.service';
import { SmsService } from '../sms/sms.service';
import {
AdminLoginDto,
RequestOtpDto,
UpdateProfileDto,
VerifyOtpDto,
} from './dto/auth.dto';
import {
isValidPhone,
normalizePhone,
toEnglishDigits,
} from '../common/utils/phone';
const MAX_OTP_ATTEMPTS = 5;
@Injectable()
export class AuthService {
constructor(
private readonly prisma: PrismaService,
private readonly jwt: JwtService,
private readonly config: ConfigService,
private readonly sms: SmsService,
) {}
async requestOtp(dto: RequestOtpDto) {
const phone = normalizePhone(toEnglishDigits(dto.phone));
if (!isValidPhone(phone)) {
throw new BadRequestException('شماره موبایل نامعتبر است');
}
const existing = await this.prisma.customer.findUnique({
where: { phone },
});
const isRegister = Boolean(dto.isRegister);
if (isRegister) {
if (!dto.firstName?.trim() || !dto.lastName?.trim()) {
throw new BadRequestException('نام و نام خانوادگی الزامی است');
}
if (existing) {
throw new BadRequestException('این شماره قبلاً ثبت شده است');
}
} else if (!existing) {
throw new BadRequestException(
'کاربری با این مشخصات وجود ندارد؛ لطفاً ثبت‌نام کنید',
);
} else if (!existing.isActive) {
throw new UnauthorizedException('حساب کاربری شما غیرفعال است');
}
const smsEnabled = this.sms.isEnabled();
const code = smsEnabled
? String(randomInt(1000, 10000))
: this.config.get<string>('MOCK_OTP_CODE', '1234');
const ttl = Number(this.config.get('OTP_TTL_MINUTES', 5));
const expiresAt = new Date(Date.now() + ttl * 60_000);
const challenge = await this.prisma.$transaction(async (tx) => {
await tx.otpChallenge.deleteMany({ where: { phone } });
return tx.otpChallenge.create({
data: {
phone,
code,
firstName: dto.firstName?.trim(),
lastName: dto.lastName?.trim(),
isRegister,
expiresAt,
},
});
});
if (smsEnabled) {
const sent = await this.sms.send(phone, this.sms.buildOtpMessage(code));
if (!sent.success) {
// چالش ناموفق را پاک می‌کنیم تا کاربر با کدی که هرگز نرسیده گیر نکند
await this.prisma.otpChallenge.deleteMany({
where: { id: challenge.id },
});
throw new ServiceUnavailableException(
'ارسال پیامک با خطا مواجه شد؛ دوباره تلاش کنید',
);
}
}
return {
success: true,
message: 'کد تأیید ارسال شد',
expiresAt,
...(this.exposesDebugCode(smsEnabled) ? { debugCode: code } : {}),
};
}
/** کد فقط در محیط توسعه و وقتی پیامک خاموش است برگردانده می‌شود. */
private exposesDebugCode(smsEnabled: boolean): boolean {
if (smsEnabled) return false;
return this.config.get<string>('NODE_ENV') !== 'production';
}
async verifyOtp(dto: VerifyOtpDto) {
const phone = normalizePhone(toEnglishDigits(dto.phone));
const code = toEnglishDigits(dto.code).trim();
const challenge = await this.prisma.otpChallenge.findFirst({
where: { phone },
orderBy: { createdAt: 'desc' },
});
if (!challenge || challenge.expiresAt < new Date()) {
throw new UnauthorizedException('کد منقضی شده است');
}
if (challenge.attempts >= MAX_OTP_ATTEMPTS) {
await this.prisma.otpChallenge.deleteMany({ where: { phone } });
throw new UnauthorizedException(
'تعداد تلاش‌های نادرست زیاد است؛ کد جدید بگیرید',
);
}
if (challenge.code !== code) {
await this.prisma.otpChallenge.update({
where: { id: challenge.id },
data: { attempts: { increment: 1 } },
});
throw new UnauthorizedException('کد تأیید نادرست است');
}
let user = await this.prisma.customer.findUnique({ where: { phone } });
if (challenge.isRegister) {
if (user) {
throw new BadRequestException('این شماره قبلاً ثبت شده است');
}
user = await this.prisma.customer.create({
data: {
phone,
firstName: challenge.firstName ?? 'کاربر',
lastName: challenge.lastName ?? 'جدید',
},
});
} else if (!user) {
throw new UnauthorizedException('کاربر یافت نشد');
} else if (!user.isActive) {
throw new UnauthorizedException('حساب کاربری شما غیرفعال است');
}
await this.prisma.otpChallenge.deleteMany({ where: { phone } });
const accessToken = await this.signCustomerToken(
user.id,
user.phone,
user.appRole,
);
return {
accessToken,
user: this.mapCustomer(user),
};
}
async adminLogin(dto: AdminLoginDto) {
const phone = normalizePhone(toEnglishDigits(dto.phone));
const admin = await this.prisma.adminUser.findUnique({ where: { phone } });
if (!admin || !admin.isActive) {
throw new UnauthorizedException('نام کاربری یا رمز عبور نادرست است');
}
const ok = await bcrypt.compare(dto.password, admin.passwordHash);
if (!ok) {
throw new UnauthorizedException('نام کاربری یا رمز عبور نادرست است');
}
await this.prisma.adminUser.update({
where: { id: admin.id },
data: { lastLoginAt: new Date() },
});
const accessToken = await this.jwt.signAsync({
sub: admin.id,
type: 'admin',
role: admin.role,
phone: admin.phone,
});
return {
accessToken,
user: {
id: admin.id,
fullName: admin.fullName,
phone: admin.phone,
email: admin.email,
role: admin.role,
},
};
}
async me(userId: string, type: 'customer' | 'admin') {
if (type === 'admin') {
const admin = await this.prisma.adminUser.findUnique({
where: { id: userId },
});
if (!admin) throw new UnauthorizedException();
return {
id: admin.id,
fullName: admin.fullName,
phone: admin.phone,
email: admin.email,
role: admin.role,
};
}
const user = await this.prisma.customer.findUnique({
where: { id: userId },
});
if (!user) throw new UnauthorizedException();
return this.mapCustomer(user);
}
async updateProfile(userId: string, dto: UpdateProfileDto) {
const data: {
firstName?: string;
lastName?: string;
nationalId?: string | null;
avatarUrl?: string | null;
} = {};
if (dto.firstName !== undefined) data.firstName = dto.firstName.trim();
if (dto.lastName !== undefined) data.lastName = dto.lastName.trim();
if (dto.nationalId !== undefined) data.nationalId = dto.nationalId.trim() || null;
if (dto.avatarUrl !== undefined) data.avatarUrl = this.normalizeAvatar(dto.avatarUrl);
const user = await this.prisma.customer.update({
where: { id: userId },
data,
});
return this.mapCustomer(user);
}
private normalizeAvatar(value: string | null): string | null {
if (value == null || value === '') return null;
if (!value.startsWith('data:image/')) {
throw new BadRequestException('فرمت تصویر پروفایل نامعتبر است.');
}
if (value.length > 900_000) {
throw new BadRequestException('حجم تصویر پروفایل خیلی زیاد است.');
}
return value;
}
private async signCustomerToken(
id: string,
phone: string,
appRole: string = 'customer',
) {
return this.jwt.signAsync({
sub: id,
type: 'customer',
role: appRole,
phone,
});
}
private mapCustomer(user: {
id: string;
firstName: string;
lastName: string;
phone: string;
nationalId: string | null;
createdAt: Date;
appRole?: string;
agentCode?: string | null;
levelLabel?: string | null;
nextLevelLabel?: string | null;
pointsToNext?: number | null;
levelProgress?: number | null;
businessName?: string | null;
avatarUrl?: string | null;
}) {
return {
id: user.id,
firstName: user.firstName,
lastName: user.lastName,
phone: user.phone,
nationalId: user.nationalId ?? undefined,
createdAt: user.createdAt.toISOString(),
appRole: user.appRole ?? 'customer',
agentCode: user.agentCode ?? undefined,
levelLabel: user.levelLabel ?? undefined,
nextLevelLabel: user.nextLevelLabel ?? undefined,
pointsToNext: user.pointsToNext ?? undefined,
levelProgress: user.levelProgress ?? undefined,
businessName: user.businessName ?? undefined,
avatarUrl: user.avatarUrl ?? undefined,
};
}
}
+59
View File
@@ -0,0 +1,59 @@
import {
IsBoolean,
IsOptional,
IsString,
Length,
MinLength,
} from 'class-validator';
export class RequestOtpDto {
@IsString()
phone!: string;
@IsOptional()
@IsString()
firstName?: string;
@IsOptional()
@IsString()
lastName?: string;
@IsOptional()
@IsBoolean()
isRegister?: boolean;
}
export class VerifyOtpDto {
@IsString()
phone!: string;
@IsString()
@Length(4, 4)
code!: string;
}
export class AdminLoginDto {
@IsString()
phone!: string;
@IsString()
@MinLength(4)
password!: string;
}
export class UpdateProfileDto {
@IsOptional()
@IsString()
firstName?: string;
@IsOptional()
@IsString()
lastName?: string;
@IsOptional()
@IsString()
nationalId?: string;
@IsOptional()
avatarUrl?: string | null;
}
+52
View File
@@ -0,0 +1,52 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
import { AuthType, Roles } from '../common/decorators/auth.decorators';
import { BannersService } from './banners.service';
import { CreateBannerDto, UpdateBannerDto } from './dto/banner.dto';
@Controller()
export class BannersController {
constructor(private readonly bannersService: BannersService) {}
@AuthType('any')
@Get('banners')
publicList(@Query('slot') slot?: string) {
return this.bannersService.list(slot, true);
}
@AuthType('admin')
@Roles('admin', 'sales')
@Get('admin/banners')
adminList(@Query('slot') slot?: string) {
return this.bannersService.list(slot);
}
@AuthType('admin')
@Roles('admin', 'sales')
@Post('admin/banners')
create(@Body() dto: CreateBannerDto) {
return this.bannersService.create(dto);
}
@AuthType('admin')
@Roles('admin', 'sales')
@Patch('admin/banners/:id')
update(@Param('id') id: string, @Body() dto: UpdateBannerDto) {
return this.bannersService.update(id, dto);
}
@AuthType('admin')
@Roles('admin', 'sales')
@Delete('admin/banners/:id')
remove(@Param('id') id: string) {
return this.bannersService.remove(id);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { BannersController } from './banners.controller';
import { BannersService } from './banners.service';
@Module({
controllers: [BannersController],
providers: [BannersService],
exports: [BannersService],
})
export class BannersModule {}
+126
View File
@@ -0,0 +1,126 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateBannerDto, UpdateBannerDto } from './dto/banner.dto';
const ALLOWED_SLOTS = new Set(['home_hero', 'products_hero', 'categories_hero']);
@Injectable()
export class BannersService {
constructor(private readonly prisma: PrismaService) {}
async list(slot?: string, activeOnly = false) {
const rows = await this.prisma.appBanner.findMany({
where: {
...(slot ? { slot } : {}),
...(activeOnly ? { isActive: true } : {}),
},
orderBy: [{ slot: 'asc' }, { displayOrder: 'asc' }, { createdAt: 'desc' }],
});
return rows.map((row) => this.map(row));
}
async create(dto: CreateBannerDto) {
const slot = this.normalizeSlot(dto.slot);
const banner = await this.prisma.appBanner.create({
data: {
slot,
title: dto.title.trim(),
subtitle: dto.subtitle?.trim() || null,
ctaLabel: dto.ctaLabel?.trim() || null,
ctaLink: dto.ctaLink?.trim() || null,
imageUrl: this.normalizeImage(dto.imageUrl),
displayOrder: dto.displayOrder ?? 0,
isActive: dto.isActive ?? true,
},
});
return this.map(banner);
}
async update(id: string, dto: UpdateBannerDto) {
await this.ensure(id);
const banner = await this.prisma.appBanner.update({
where: { id },
data: {
slot: dto.slot === undefined ? undefined : this.normalizeSlot(dto.slot),
title: dto.title?.trim(),
subtitle:
dto.subtitle === undefined ? undefined : dto.subtitle?.trim() || null,
ctaLabel:
dto.ctaLabel === undefined ? undefined : dto.ctaLabel?.trim() || null,
ctaLink:
dto.ctaLink === undefined ? undefined : dto.ctaLink?.trim() || null,
imageUrl:
dto.imageUrl === undefined
? undefined
: this.normalizeImage(dto.imageUrl),
displayOrder: dto.displayOrder,
isActive: dto.isActive,
},
});
return this.map(banner);
}
async remove(id: string) {
await this.ensure(id);
await this.prisma.appBanner.delete({ where: { id } });
return { ok: true };
}
private async ensure(id: string) {
const row = await this.prisma.appBanner.findUnique({ where: { id } });
if (!row) throw new NotFoundException('بنر یافت نشد');
return row;
}
private normalizeSlot(slot: string) {
const value = slot.trim();
if (!ALLOWED_SLOTS.has(value)) {
throw new BadRequestException('بخش بنر نامعتبر است');
}
return value;
}
private normalizeImage(value?: string | null) {
if (value == null || value === '') return null;
const image = value.trim();
if (
!image.startsWith('data:image/') &&
!image.startsWith('https://') &&
!image.startsWith('http://')
) {
throw new BadRequestException('فرمت تصویر بنر نامعتبر است');
}
if (image.startsWith('data:image/') && image.length > 1_600_000) {
throw new BadRequestException('حجم تصویر بنر خیلی زیاد است');
}
return image;
}
private map(row: {
id: string;
slot: string;
title: string;
subtitle: string | null;
ctaLabel: string | null;
ctaLink: string | null;
imageUrl: string | null;
displayOrder: number;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
}) {
return {
id: row.id,
slot: row.slot,
title: row.title,
subtitle: row.subtitle ?? '',
ctaLabel: row.ctaLabel ?? '',
ctaLink: row.ctaLink ?? '',
imageUrl: row.imageUrl ?? '',
displayOrder: row.displayOrder,
isActive: row.isActive,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
}
+83
View File
@@ -0,0 +1,83 @@
import {
IsBoolean,
IsInt,
IsOptional,
IsString,
Min,
MinLength,
} from 'class-validator';
import { Type } from 'class-transformer';
export class CreateBannerDto {
@IsString()
@MinLength(2)
slot!: string;
@IsString()
@MinLength(2)
title!: string;
@IsOptional()
@IsString()
subtitle?: string | null;
@IsOptional()
@IsString()
ctaLabel?: string | null;
@IsOptional()
@IsString()
ctaLink?: string | null;
@IsOptional()
@IsString()
imageUrl?: string | null;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
displayOrder?: number;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class UpdateBannerDto {
@IsOptional()
@IsString()
@MinLength(2)
slot?: string;
@IsOptional()
@IsString()
@MinLength(2)
title?: string;
@IsOptional()
@IsString()
subtitle?: string | null;
@IsOptional()
@IsString()
ctaLabel?: string | null;
@IsOptional()
@IsString()
ctaLink?: string | null;
@IsOptional()
@IsString()
imageUrl?: string | null;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
displayOrder?: number;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
+49
View File
@@ -0,0 +1,49 @@
import { Body, Controller, Get, Param, Patch, Post, Query } from '@nestjs/common';
import { BrandsService } from './brands.service';
import { AuthType } from '../common/decorators/auth.decorators';
import { CreateBrandDto, UpdateBrandDto } from './dto/brand.dto';
import { IsArray, IsString } from 'class-validator';
class SetBrandsBody {
@IsArray()
@IsString({ each: true })
brandIds!: string[];
}
@Controller()
export class BrandsController {
constructor(private readonly brandsService: BrandsService) {}
@AuthType('admin')
@Get('admin/brands')
list(@Query('activeOnly') activeOnly?: string) {
return this.brandsService.list(activeOnly === 'true');
}
@AuthType('admin')
@Post('admin/brands')
create(@Body() dto: CreateBrandDto) {
return this.brandsService.create(dto);
}
@AuthType('admin')
@Patch('admin/brands/:id')
update(@Param('id') id: string, @Body() dto: UpdateBrandDto) {
return this.brandsService.update(id, dto);
}
@AuthType('admin')
@Get('admin/customers/:customerId/brands')
customerBrands(@Param('customerId') customerId: string) {
return this.brandsService.listCustomerBrands(customerId);
}
@AuthType('admin')
@Post('admin/customers/:customerId/brands')
setCustomerBrands(
@Param('customerId') customerId: string,
@Body() body: SetBrandsBody,
) {
return this.brandsService.setCustomerBrands(customerId, body.brandIds ?? []);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { BrandsController } from './brands.controller';
import { BrandsService } from './brands.service';
@Module({
controllers: [BrandsController],
providers: [BrandsService],
exports: [BrandsService],
})
export class BrandsModule {}
+112
View File
@@ -0,0 +1,112 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateBrandDto, UpdateBrandDto } from './dto/brand.dto';
@Injectable()
export class BrandsService {
constructor(private readonly prisma: PrismaService) {}
async list(activeOnly?: boolean) {
const rows = await this.prisma.brand.findMany({
where: activeOnly ? { isActive: true } : undefined,
orderBy: [{ displayOrder: 'asc' }, { name: 'asc' }],
include: { _count: { select: { customers: true } } },
});
return rows.map((b) => ({
id: b.id,
name: b.name,
slug: b.slug ?? undefined,
description: b.description ?? undefined,
isActive: b.isActive,
displayOrder: b.displayOrder,
customersCount: b._count.customers,
createdAt: b.createdAt.toISOString(),
updatedAt: b.updatedAt.toISOString(),
}));
}
async create(dto: CreateBrandDto) {
const brand = await this.prisma.brand.create({
data: {
name: dto.name.trim(),
slug: dto.slug?.trim() || null,
description: dto.description?.trim() || null,
displayOrder: dto.displayOrder ?? 0,
isActive: dto.isActive ?? true,
},
});
return this.map(brand);
}
async update(id: string, dto: UpdateBrandDto) {
await this.ensure(id);
const brand = await this.prisma.brand.update({
where: { id },
data: {
name: dto.name?.trim(),
slug:
dto.slug === undefined
? undefined
: dto.slug?.trim() || null,
description:
dto.description === undefined
? undefined
: dto.description?.trim() || null,
displayOrder: dto.displayOrder,
isActive: dto.isActive,
},
});
return this.map(brand);
}
async setCustomerBrands(customerId: string, brandIds: string[]) {
const customer = await this.prisma.customer.findUnique({
where: { id: customerId },
});
if (!customer) throw new NotFoundException('مشتری یافت نشد');
await this.prisma.customerBrand.deleteMany({ where: { customerId } });
if (brandIds.length) {
await this.prisma.customerBrand.createMany({
data: brandIds.map((brandId) => ({ customerId, brandId })),
skipDuplicates: true,
});
}
return this.listCustomerBrands(customerId);
}
async listCustomerBrands(customerId: string) {
const rows = await this.prisma.customerBrand.findMany({
where: { customerId },
include: { brand: true },
});
return rows.map((r) => this.map(r.brand));
}
private async ensure(id: string) {
const row = await this.prisma.brand.findUnique({ where: { id } });
if (!row) throw new NotFoundException('برند یافت نشد');
return row;
}
private map(b: {
id: string;
name: string;
slug: string | null;
description: string | null;
isActive: boolean;
displayOrder: number;
createdAt: Date;
updatedAt: Date;
}) {
return {
id: b.id,
name: b.name,
slug: b.slug ?? undefined,
description: b.description ?? undefined,
isActive: b.isActive,
displayOrder: b.displayOrder,
createdAt: b.createdAt.toISOString(),
updatedAt: b.updatedAt.toISOString(),
};
}
}
+62
View File
@@ -0,0 +1,62 @@
import {
IsBoolean,
IsInt,
IsOptional,
IsString,
Min,
MinLength,
} from 'class-validator';
import { Type } from 'class-transformer';
export class CreateBrandDto {
@IsString()
@MinLength(1)
name!: string;
@IsOptional()
@IsString()
slug?: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
displayOrder?: number;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class UpdateBrandDto {
@IsOptional()
@IsString()
@MinLength(1)
name?: string;
@IsOptional()
@IsString()
slug?: string | null;
@IsOptional()
@IsString()
description?: string | null;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
displayOrder?: number;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class SetCustomerBrandsDto {
brands!: string[];
}
+64
View File
@@ -0,0 +1,64 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
} from '@nestjs/common';
import { CartsService } from './carts.service';
import { AuthType } from '../common/decorators/auth.decorators';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import type { AuthPrincipal } from '../common/decorators/current-user.decorator';
import {
AddCartItemDto,
UpdateCartItemMeterageDto,
UpdateCartNotesDto,
} from './dto/cart.dto';
@Controller('carts')
@AuthType('customer')
export class CartsController {
constructor(private readonly cartsService: CartsService) {}
@Get()
getCart(@CurrentUser() user: AuthPrincipal) {
return this.cartsService.getCart(user.id);
}
@Post('items')
addItem(@CurrentUser() user: AuthPrincipal, @Body() dto: AddCartItemDto) {
return this.cartsService.addItem(user.id, dto);
}
@Patch('items/:itemId')
updateItem(
@CurrentUser() user: AuthPrincipal,
@Param('itemId') itemId: string,
@Body() dto: UpdateCartItemMeterageDto,
) {
return this.cartsService.updateItem(user.id, itemId, dto);
}
@Delete('items/:itemId')
removeItem(
@CurrentUser() user: AuthPrincipal,
@Param('itemId') itemId: string,
) {
return this.cartsService.removeItem(user.id, itemId);
}
@Patch('notes')
updateNotes(
@CurrentUser() user: AuthPrincipal,
@Body() dto: UpdateCartNotesDto,
) {
return this.cartsService.updateNotes(user.id, dto);
}
@Delete()
clear(@CurrentUser() user: AuthPrincipal) {
return this.cartsService.clearCart(user.id);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { CartsController } from './carts.controller';
import { CartsService } from './carts.service';
@Module({
controllers: [CartsController],
providers: [CartsService],
exports: [CartsService],
})
export class CartsModule {}
+249
View File
@@ -0,0 +1,249 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import {
AddCartItemDto,
UpdateCartItemMeterageDto,
UpdateCartNotesDto,
} from './dto/cart.dto';
@Injectable()
export class CartsService {
constructor(private readonly prisma: PrismaService) {}
async getCart(userId: string) {
const cart = await this.ensureCart(userId);
return this.mapCart(cart);
}
async addItem(userId: string, dto: AddCartItemDto) {
const product = await this.prisma.product.findUnique({
where: { id: dto.productId },
include: {
catalog: true,
company: true,
},
});
if (!product || !product.isActive) {
throw new NotFoundException('محصول یافت نشد');
}
await this.validateMeterage(product, dto.meterage);
const cart = await this.ensureCart(userId);
const existing = await this.prisma.cartItem.findUnique({
where: {
cartId_productId: { cartId: cart.id, productId: product.id },
},
});
if (existing) {
const newMeterage = existing.meterage + dto.meterage;
await this.validateMeterage(product, newMeterage);
await this.prisma.cartItem.update({
where: { id: existing.id },
data: {
meterage: newMeterage,
notes: dto.notes?.trim() || existing.notes,
},
});
} else {
await this.prisma.cartItem.create({
data: {
cartId: cart.id,
productId: product.id,
catalogCode: product.catalog.code,
productName: product.name,
productCode: product.code,
color: product.color,
meterage: dto.meterage,
notes: dto.notes?.trim() || null,
companyId: product.companyId,
companyName: product.company.name,
},
});
}
await this.prisma.cart.update({
where: { id: cart.id },
data: {
companyId: product.companyId,
updatedAt: new Date(),
},
});
return this.getCart(userId);
}
async updateItem(
userId: string,
itemId: string,
dto: UpdateCartItemMeterageDto,
) {
const cart = await this.ensureCart(userId);
const item = await this.prisma.cartItem.findFirst({
where: { id: itemId, cartId: cart.id },
include: { product: true },
});
if (!item) throw new NotFoundException('آیتم سبد یافت نشد');
await this.validateMeterage(item.product, dto.meterage);
await this.prisma.cartItem.update({
where: { id: itemId },
data: {
meterage: dto.meterage,
notes: dto.notes === undefined ? undefined : dto.notes?.trim() || null,
},
});
await this.prisma.cart.update({
where: { id: cart.id },
data: { updatedAt: new Date() },
});
return this.getCart(userId);
}
async removeItem(userId: string, itemId: string) {
const cart = await this.ensureCart(userId);
const item = await this.prisma.cartItem.findFirst({
where: { id: itemId, cartId: cart.id },
});
if (!item) throw new NotFoundException('آیتم سبد یافت نشد');
await this.prisma.cartItem.delete({ where: { id: itemId } });
const remaining = await this.prisma.cartItem.findMany({
where: { cartId: cart.id },
take: 1,
});
await this.prisma.cart.update({
where: { id: cart.id },
data: {
companyId: remaining[0]?.companyId ?? null,
updatedAt: new Date(),
},
});
return this.getCart(userId);
}
async updateNotes(userId: string, dto: UpdateCartNotesDto) {
const cart = await this.ensureCart(userId);
await this.prisma.cart.update({
where: { id: cart.id },
data: { notes: dto.notes?.trim() || null },
});
return this.getCart(userId);
}
async clearCart(userId: string) {
const cart = await this.ensureCart(userId);
await this.prisma.cartItem.deleteMany({ where: { cartId: cart.id } });
await this.prisma.cart.update({
where: { id: cart.id },
data: { notes: null, companyId: null },
});
return this.getCart(userId);
}
private async validateMeterage(
product: {
name: string;
minMeterage: number;
orderMultiplier: number;
rollMeters?: number | null;
},
meterage: number,
) {
if (meterage < product.minMeterage) {
throw new BadRequestException(
`حداقل متراژ برای ${product.name} برابر ${product.minMeterage} است`,
);
}
const rem = Math.round((meterage / product.orderMultiplier) * 1000) / 1000;
if (Math.abs(rem - Math.round(rem)) > 1e-6) {
throw new BadRequestException(
`متراژ باید مضرب ${product.orderMultiplier} باشد`,
);
}
const settings = await this.prisma.systemSettings.findUnique({
where: { id: 'default' },
});
const minRolls = settings?.minOrderRollsPerProduct ?? 1;
const rollMeters = product.rollMeters && product.rollMeters > 0
? product.rollMeters
: product.minMeterage > 0
? product.minMeterage
: 50;
const rolls = meterage / rollMeters;
if (rolls + 1e-9 < minRolls) {
throw new BadRequestException(
`حداقل سفارش برای هر محصول ${minRolls} طاقه است (حدود ${minRolls * rollMeters} متر)`,
);
}
}
private async ensureCart(userId: string) {
let cart = await this.prisma.cart.findFirst({
where: { userId },
include: { items: true },
});
if (!cart) {
cart = await this.prisma.cart.create({
data: { userId },
include: { items: true },
});
}
return cart;
}
private mapCart(cart: {
id: string;
userId: string;
companyId: string | null;
notes: string | null;
createdAt: Date;
updatedAt: Date;
items: Array<{
id: string;
productId: string;
catalogCode: string;
productName: string;
productCode: string;
color: string;
meterage: number;
notes: string | null;
companyId: string;
companyName: string;
}>;
}) {
const companyNames = [
...new Set(cart.items.map((item) => item.companyName).filter(Boolean)),
];
return {
id: cart.id,
userId: cart.userId,
companyId: cart.companyId ?? cart.items[0]?.companyId,
companyName: companyNames.join('، '),
notes: cart.notes ?? undefined,
items: cart.items.map((item) => ({
id: item.id,
productId: item.productId,
catalogCode: item.catalogCode,
productName: item.productName,
productCode: item.productCode,
color: item.color,
meterage: item.meterage,
notes: item.notes ?? undefined,
companyId: item.companyId,
companyName: item.companyName,
})),
totalMeterage: cart.items.reduce((s, i) => s + i.meterage, 0),
itemCount: cart.items.length,
createdAt: cart.createdAt.toISOString(),
updatedAt: cart.updatedAt.toISOString(),
};
}
}
+33
View File
@@ -0,0 +1,33 @@
import { IsNumber, IsOptional, IsString, Min } from 'class-validator';
import { Type } from 'class-transformer';
export class AddCartItemDto {
@IsString()
productId!: string;
@Type(() => Number)
@IsNumber()
@Min(0.001)
meterage!: number;
@IsOptional()
@IsString()
notes?: string;
}
export class UpdateCartItemMeterageDto {
@Type(() => Number)
@IsNumber()
@Min(0.001)
meterage!: number;
@IsOptional()
@IsString()
notes?: string;
}
export class UpdateCartNotesDto {
@IsOptional()
@IsString()
notes?: string;
}
@@ -0,0 +1,51 @@
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
import { CashPaymentStatus } from '@prisma/client';
import { CashPaymentsService } from './cash-payments.service';
import { AuthType, Roles } from '../common/decorators/auth.decorators';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import type { AuthPrincipal } from '../common/decorators/current-user.decorator';
import {
SubmitCashReceiptDto,
UpdateCashPaymentStatusDto,
} from './dto/cash-payment.dto';
@Controller()
export class CashPaymentsController {
constructor(private readonly cashPaymentsService: CashPaymentsService) {}
@AuthType('customer')
@Post('orders/:orderId/cash-receipt')
submitReceipt(
@CurrentUser() user: AuthPrincipal,
@Param('orderId') orderId: string,
@Body() dto: SubmitCashReceiptDto,
) {
return this.cashPaymentsService.submitReceipt(user.id, orderId, dto);
}
@AuthType('admin')
@Roles('admin', 'finance')
@Get('admin/cash-payments')
findAll(@Query('status') status?: CashPaymentStatus) {
return this.cashPaymentsService.findAll(status);
}
@AuthType('admin')
@Roles('admin', 'finance')
@Patch('admin/cash-payments/:id/status')
updateStatus(
@CurrentUser() user: AuthPrincipal,
@Param('id') id: string,
@Body() dto: UpdateCashPaymentStatusDto,
) {
return this.cashPaymentsService.updateStatus(id, user.id, dto);
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { CashPaymentsController } from './cash-payments.controller';
import { CashPaymentsService } from './cash-payments.service';
import { NotificationsModule } from '../notifications/notifications.module';
@Module({
imports: [NotificationsModule],
controllers: [CashPaymentsController],
providers: [CashPaymentsService],
exports: [CashPaymentsService],
})
export class CashPaymentsModule {}
+271
View File
@@ -0,0 +1,271 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { CashPaymentStatus, OrderStatus, SettlementType } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import {
SubmitCashReceiptDto,
UpdateCashPaymentStatusDto,
} from './dto/cash-payment.dto';
import { joinFullName } from '../common/utils/name';
import { NotificationsService } from '../notifications/notifications.service';
/** بعد از تأیید پرداخت یا ارسال، رسید جدید نباید وضعیت را به عقب برگرداند. */
const RECEIPT_SUBMITTABLE_STATUSES: OrderStatus[] = [
OrderStatus.cash_in_progress,
OrderStatus.cash_invoice_issued,
OrderStatus.awaiting_cash_payment,
OrderStatus.cash_payment_submitted,
OrderStatus.awaiting_cash_confirmation,
];
@Injectable()
export class CashPaymentsService {
constructor(
private readonly prisma: PrismaService,
private readonly notifications: NotificationsService,
) {}
async submitReceipt(
userId: string,
orderId: string,
dto: SubmitCashReceiptDto,
) {
const order = await this.prisma.order.findUnique({
where: { id: orderId },
include: { cashReceipt: true, items: true },
});
if (!order) throw new NotFoundException('سفارش یافت نشد');
if (order.userId !== userId) {
throw new ForbiddenException('دسترسی به این سفارش مجاز نیست');
}
if (order.settlementType !== SettlementType.cash) {
throw new BadRequestException('این سفارش تسویه نقدی نیست');
}
if (!RECEIPT_SUBMITTABLE_STATUSES.includes(order.status)) {
throw new BadRequestException(
'در وضعیت فعلی سفارش امکان ثبت رسید وجود ندارد',
);
}
if (
order.cashInvoiceExpiresAt &&
order.cashInvoiceExpiresAt.getTime() < Date.now()
) {
throw new BadRequestException(
'مهلت پرداخت فاکتور نقدی به پایان رسیده است',
);
}
const companyIds = [
...new Set(
[
order.companyId,
...order.items.map((item) => item.companyId).filter(Boolean),
].filter(Boolean),
),
];
const account = await this.prisma.bankAccount.findFirst({
where: {
id: dto.destinationAccountId,
companyId: { in: companyIds },
isActive: true,
},
});
if (!account) {
throw new BadRequestException('حساب مقصد نامعتبر است');
}
const receipt = await this.prisma.$transaction(async (tx) => {
const data = {
amount: dto.amount,
paidAmount: dto.paidAmount ?? dto.amount,
transferDate: new Date(dto.transferDate),
transferTime: dto.transferTime.trim(),
trackingNumber: dto.trackingNumber.trim(),
destinationAccountId: dto.destinationAccountId,
receiptImage: dto.receiptImage || null,
notes: dto.notes?.trim() || null,
expiresAt: order.cashInvoiceExpiresAt,
status: CashPaymentStatus.pending_review,
submittedAt: new Date(),
};
const saved = order.cashReceipt
? await tx.cashPaymentReceipt.update({
where: { orderId },
data,
})
: await tx.cashPaymentReceipt.create({
data: { orderId, ...data },
});
await tx.order.update({
where: { id: orderId },
data: { status: OrderStatus.awaiting_cash_confirmation },
});
await tx.orderStatusHistory.create({
data: {
orderId,
fromStatus: order.status,
toStatus: OrderStatus.awaiting_cash_confirmation,
changedBy: 'customer',
note: 'رسید پرداخت نقدی ارسال شد',
},
});
return saved;
});
this.notifications.dispatchAdmin({
title: 'رسید پرداخت نقدی ثبت شد',
body: `مشتری برای سفارش ${order.orderNumber} رسید به مبلغ ${Math.round(
dto.amount,
).toLocaleString('fa-IR')} تومان ثبت کرد.`,
link: `/orders/${orderId}`,
});
return this.mapReceipt(receipt, order.orderNumber, '');
}
async findAll(status?: CashPaymentStatus) {
const receipts = await this.prisma.cashPaymentReceipt.findMany({
where: status ? { status } : undefined,
orderBy: { createdAt: 'desc' },
include: {
order: {
include: { user: true },
},
},
});
return receipts.map((r) =>
this.mapReceipt(
r,
r.order.orderNumber,
joinFullName(r.order.user.firstName, r.order.user.lastName),
),
);
}
async updateStatus(
id: string,
adminId: string,
dto: UpdateCashPaymentStatusDto,
) {
const receipt = await this.prisma.cashPaymentReceipt.findUnique({
where: { id },
include: { order: { include: { user: true } } },
});
if (!receipt) throw new NotFoundException('رسید نقدی یافت نشد');
const updated = await this.prisma.$transaction(async (tx) => {
const result = await tx.cashPaymentReceipt.update({
where: { id },
data: {
status: dto.status,
notes: dto.note
? [receipt.notes, dto.note].filter(Boolean).join('\n')
: receipt.notes,
},
include: { order: { include: { user: true } } },
});
// رد رسید باید مشتری را به مرحله پرداخت برگرداند تا بتواند رسید تازه بفرستد.
let nextStatus: OrderStatus | null = null;
if (dto.status === CashPaymentStatus.approved) {
nextStatus = OrderStatus.cash_confirmed;
} else if (dto.status === CashPaymentStatus.rejected) {
nextStatus = OrderStatus.awaiting_cash_payment;
} else if (dto.status === CashPaymentStatus.expired) {
nextStatus = OrderStatus.expired;
}
if (nextStatus && nextStatus !== receipt.order.status) {
await tx.order.update({
where: { id: receipt.orderId },
data: { status: nextStatus },
});
await tx.orderStatusHistory.create({
data: {
orderId: receipt.orderId,
fromStatus: receipt.order.status,
toStatus: nextStatus,
changedBy: `admin:${adminId}`,
note: dto.note || `وضعیت پرداخت نقدی: ${dto.status}`,
},
});
}
return result;
});
if (dto.status === CashPaymentStatus.approved) {
this.notifications.dispatchOrderEvent(
receipt.orderId,
'receipt_approved',
);
} else if (dto.status === CashPaymentStatus.rejected) {
this.notifications.dispatchOrderEvent(
receipt.orderId,
'receipt_rejected',
{ note: dto.note ?? null },
);
} else if (dto.status === CashPaymentStatus.expired) {
this.notifications.dispatchOrderEvent(
receipt.orderId,
'cash_invoice_expired',
);
}
return this.mapReceipt(
updated,
updated.order.orderNumber,
joinFullName(updated.order.user.firstName, updated.order.user.lastName),
);
}
private mapReceipt(
r: {
id: string;
orderId: string;
amount: number;
paidAmount: number | null;
transferDate: Date | null;
transferTime: string | null;
trackingNumber: string | null;
destinationAccountId: string | null;
receiptImage: string | null;
notes: string | null;
expiresAt: Date | null;
status: CashPaymentStatus;
submittedAt: Date | null;
createdAt: Date;
updatedAt: Date;
},
orderNumber: string,
customerName: string,
) {
return {
id: r.id,
orderId: r.orderId,
orderNumber,
customerName,
amount: r.amount,
paidAmount: r.paidAmount,
transferDate: r.transferDate?.toISOString() ?? null,
transferTime: r.transferTime ?? undefined,
trackingNumber: r.trackingNumber ?? '',
trackingCode: r.trackingNumber ?? '',
destinationAccountId: r.destinationAccountId ?? undefined,
receiptImage: r.receiptImage ?? undefined,
notes: r.notes ?? undefined,
expiresAt: r.expiresAt?.toISOString() ?? '',
status: r.status,
submittedAt: r.submittedAt?.toISOString(),
createdAt: r.createdAt.toISOString(),
updatedAt: r.updatedAt.toISOString(),
};
}
}
+55
View File
@@ -0,0 +1,55 @@
import {
IsDateString,
IsEnum,
IsNumber,
IsOptional,
IsString,
Min,
MinLength,
} from 'class-validator';
import { Type } from 'class-transformer';
import { CashPaymentStatus } from '@prisma/client';
export class SubmitCashReceiptDto {
@Type(() => Number)
@IsNumber()
@Min(1)
amount!: number;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
paidAmount?: number;
@IsDateString()
transferDate!: string;
@IsString()
@MinLength(1)
transferTime!: string;
@IsString()
@MinLength(1)
trackingNumber!: string;
@IsString()
destinationAccountId!: string;
@IsOptional()
@IsString()
receiptImage?: string;
@IsOptional()
@IsString()
notes?: string;
}
export class UpdateCashPaymentStatusDto {
@IsEnum(CashPaymentStatus)
status!: CashPaymentStatus;
@IsOptional()
@IsString()
note?: string;
}
@@ -0,0 +1,82 @@
import {
Body,
Controller,
ForbiddenException,
Get,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
import { CatalogRequestStatus } from '@prisma/client';
import { CatalogRequestsService } from './catalog-requests.service';
import { AuthType, Roles } from '../common/decorators/auth.decorators';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import type { AuthPrincipal } from '../common/decorators/current-user.decorator';
import {
CreateCatalogRequestDto,
UpdateCatalogItemHolderDto,
UpdateCatalogRequestStatusDto,
} from './dto/catalog-request.dto';
@Controller()
export class CatalogRequestsController {
constructor(private readonly service: CatalogRequestsService) {}
@AuthType('admin')
@Roles('admin', 'sales', 'warehouse')
@Get('admin/catalog-requests')
adminList(@Query('status') status?: CatalogRequestStatus) {
return this.service.listAdmin(status);
}
@AuthType('admin')
@Roles('admin', 'sales', 'warehouse')
@Get('admin/catalog-requests/:id')
adminGet(@Param('id') id: string) {
return this.service.get(id);
}
@AuthType('admin')
@Roles('admin', 'sales', 'warehouse')
@Patch('admin/catalog-requests/:id/status')
adminStatus(
@Param('id') id: string,
@Body() dto: UpdateCatalogRequestStatusDto,
) {
return this.service.updateStatus(id, dto);
}
@AuthType('admin')
@Roles('admin', 'sales', 'warehouse')
@Patch('admin/catalog-request-items/:id/holder')
adminHolder(
@Param('id') id: string,
@Body() dto: UpdateCatalogItemHolderDto,
) {
return this.service.updateItemHolder(id, dto);
}
@AuthType('customer')
@Get('catalog-requests')
mine(@CurrentUser() user: AuthPrincipal) {
return this.service.listMine(user.id);
}
@AuthType('customer')
@Post('catalog-requests')
create(
@CurrentUser() user: AuthPrincipal,
@Body() dto: CreateCatalogRequestDto,
) {
return this.service.create(user.id, dto);
}
@AuthType('customer')
@Get('catalog-requests/:id')
async mineOne(@CurrentUser() user: AuthPrincipal, @Param('id') id: string) {
const row = await this.service.get(id);
if (row.customerId !== user.id) throw new ForbiddenException();
return row;
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { CatalogRequestsController } from './catalog-requests.controller';
import { CatalogRequestsService } from './catalog-requests.service';
@Module({
controllers: [CatalogRequestsController],
providers: [CatalogRequestsService],
exports: [CatalogRequestsService],
})
export class CatalogRequestsModule {}
@@ -0,0 +1,264 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import {
CatalogItemHolderType,
CatalogRequestStatus,
} from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import {
CreateCatalogRequestDto,
UpdateCatalogRequestStatusDto,
UpdateCatalogItemHolderDto,
} from './dto/catalog-request.dto';
const STATUS_FLOW: CatalogRequestStatus[] = [
'new',
'approved',
'ready_to_ship',
'delivered',
'returned',
'cancelled',
];
@Injectable()
export class CatalogRequestsService {
constructor(private readonly prisma: PrismaService) {}
async listAdmin(status?: CatalogRequestStatus) {
const rows = await this.prisma.catalogRequest.findMany({
where: status ? { status } : undefined,
orderBy: { createdAt: 'desc' },
include: {
customer: {
select: { id: true, firstName: true, lastName: true, phone: true },
},
items: {
include: {
product: { select: { id: true, name: true, code: true, color: true } },
movements: { orderBy: { movedAt: 'desc' }, take: 5 },
},
},
},
});
return rows.map((r) => this.map(r));
}
async listMine(customerId: string) {
const rows = await this.prisma.catalogRequest.findMany({
where: { customerId },
orderBy: { createdAt: 'desc' },
include: {
customer: {
select: { id: true, firstName: true, lastName: true, phone: true },
},
items: {
include: {
product: { select: { id: true, name: true, code: true, color: true } },
movements: { orderBy: { movedAt: 'desc' }, take: 5 },
},
},
},
});
return rows.map((r) => this.map(r));
}
async get(id: string) {
const row = await this.prisma.catalogRequest.findUnique({
where: { id },
include: {
customer: {
select: { id: true, firstName: true, lastName: true, phone: true },
},
items: {
include: {
product: { select: { id: true, name: true, code: true, color: true } },
movements: { orderBy: { movedAt: 'desc' } },
},
},
},
});
if (!row) throw new NotFoundException('درخواست کالیته یافت نشد');
return this.map(row);
}
async create(customerId: string, dto: CreateCatalogRequestDto) {
const items = (dto.items ?? []).filter((i) => i.productId && i.quantity > 0);
if (!items.length) {
throw new BadRequestException('حداقل یک قلم با تعداد معتبر لازم است');
}
const productIds = [...new Set(items.map((i) => i.productId))];
const products = await this.prisma.product.findMany({
where: { id: { in: productIds } },
select: { id: true },
});
if (products.length !== productIds.length) {
throw new BadRequestException('یکی از محصولات معتبر نیست');
}
const row = await this.prisma.catalogRequest.create({
data: {
customerId,
note: dto.note?.trim() || null,
status: 'new',
items: {
create: items.map((i) => ({
productId: i.productId,
quantity: Math.max(1, Math.floor(i.quantity)),
holderType: 'warehouse',
})),
},
},
include: {
customer: {
select: { id: true, firstName: true, lastName: true, phone: true },
},
items: {
include: {
product: { select: { id: true, name: true, code: true, color: true } },
movements: true,
},
},
},
});
return this.map(row);
}
async updateStatus(id: string, dto: UpdateCatalogRequestStatusDto) {
await this.ensure(id);
if (!STATUS_FLOW.includes(dto.status)) {
throw new BadRequestException('وضعیت نامعتبر است');
}
const row = await this.prisma.catalogRequest.update({
where: { id },
data: { status: dto.status },
include: {
customer: {
select: { id: true, firstName: true, lastName: true, phone: true },
},
items: {
include: {
product: { select: { id: true, name: true, code: true, color: true } },
movements: { orderBy: { movedAt: 'desc' }, take: 5 },
},
},
},
});
if (dto.status === 'delivered') {
await this.prisma.catalogRequestItem.updateMany({
where: { requestId: id },
data: { holderType: 'customer' },
});
}
return this.map(row);
}
async updateItemHolder(itemId: string, dto: UpdateCatalogItemHolderDto) {
const item = await this.prisma.catalogRequestItem.findUnique({
where: { id: itemId },
});
if (!item) throw new NotFoundException('قلم کالیته یافت نشد');
const fromHolder = `${item.holderType}:${item.holderCustomerId ?? 'warehouse'}`;
const toHolder = `${dto.holderType}:${dto.holderCustomerId ?? 'warehouse'}`;
const updated = await this.prisma.catalogRequestItem.update({
where: { id: itemId },
data: {
holderType: dto.holderType,
holderCustomerId: dto.holderCustomerId ?? null,
},
include: {
product: { select: { id: true, name: true, code: true, color: true } },
movements: { orderBy: { movedAt: 'desc' } },
},
});
await this.prisma.catalogItemMovement.create({
data: {
itemId,
fromHolder,
toHolder,
note: dto.note?.trim() || null,
},
});
return {
id: updated.id,
productId: updated.productId,
productName: updated.product.name,
productCode: updated.product.code,
quantity: updated.quantity,
holderType: updated.holderType,
holderCustomerId: updated.holderCustomerId,
};
}
private async ensure(id: string) {
const row = await this.prisma.catalogRequest.findUnique({ where: { id } });
if (!row) throw new NotFoundException('درخواست کالیته یافت نشد');
return row;
}
private map(row: {
id: string;
customerId: string;
status: CatalogRequestStatus;
note: string | null;
createdAt: Date;
updatedAt: Date;
customer: {
id: string;
firstName: string;
lastName: string;
phone: string;
};
items: Array<{
id: string;
productId: string;
quantity: number;
holderType: CatalogItemHolderType;
holderCustomerId: string | null;
product: { id: string; name: string; code: string; color: string };
movements: Array<{
id: string;
fromHolder: string;
toHolder: string;
note: string | null;
movedAt: Date;
}>;
}>;
}) {
return {
id: row.id,
customerId: row.customerId,
customerName: `${row.customer.firstName} ${row.customer.lastName}`.trim(),
customerPhone: row.customer.phone,
status: row.status,
note: row.note ?? undefined,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
items: row.items.map((i) => ({
id: i.id,
productId: i.productId,
productName: i.product.name,
productCode: i.product.code,
color: i.product.color,
quantity: i.quantity,
holderType: i.holderType,
holderCustomerId: i.holderCustomerId,
movements: i.movements.map((m) => ({
id: m.id,
fromHolder: m.fromHolder,
toHolder: m.toHolder,
note: m.note ?? undefined,
movedAt: m.movedAt.toISOString(),
})),
})),
};
}
}
@@ -0,0 +1,57 @@
import {
ArrayMinSize,
IsArray,
IsEnum,
IsInt,
IsOptional,
IsString,
Min,
MinLength,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
import {
CatalogItemHolderType,
CatalogRequestStatus,
} from '@prisma/client';
export class CatalogRequestItemInputDto {
@IsString()
@MinLength(1)
productId!: string;
@Type(() => Number)
@IsInt()
@Min(1)
quantity!: number;
}
export class CreateCatalogRequestDto {
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => CatalogRequestItemInputDto)
items!: CatalogRequestItemInputDto[];
@IsOptional()
@IsString()
note?: string;
}
export class UpdateCatalogRequestStatusDto {
@IsEnum(CatalogRequestStatus)
status!: CatalogRequestStatus;
}
export class UpdateCatalogItemHolderDto {
@IsEnum(CatalogItemHolderType)
holderType!: CatalogItemHolderType;
@IsOptional()
@IsString()
holderCustomerId?: string | null;
@IsOptional()
@IsString()
note?: string;
}
+58
View File
@@ -0,0 +1,58 @@
import type { PrismaService } from '../prisma/prisma.service'
export const USAGE_CATEGORIES = [
{ id: 'trousers', title: 'پارچه‌های شلواری', subtitle: 'مشاهده محصولات' },
{ id: 'suit', title: 'پارچه‌های کت‌شلواری', subtitle: 'مشاهده محصولات' },
{ id: 'shirt', title: 'پارچه‌های پیراهنی', subtitle: 'مشاهده محصولات' },
] as const
export const SEASON_CATEGORIES = [
{
id: 'summer',
title: 'تابستانه',
description: 'سبک، خنک و مناسب فصل گرم',
},
{
id: 'winter',
title: 'زمستانه',
description: 'گرم و مناسب فصل سرد',
},
{
id: 'all_season',
title: 'چهارفصل',
description: 'مناسب استفاده در بیشتر فصل‌های سال',
},
] as const
export const CATEGORY_IMAGE_KEYS = [
...USAGE_CATEGORIES.map((c) => c.id),
...SEASON_CATEGORIES.map((c) => c.id),
] as const
export type CategoryImageKey = (typeof CATEGORY_IMAGE_KEYS)[number]
export function asImageMap(value: unknown): Record<string, string> {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
const out: Record<string, string> = {}
for (const [key, image] of Object.entries(value as Record<string, unknown>)) {
if (typeof image === 'string' && image.trim()) out[key] = image.trim()
}
return out
}
export async function loadCatalogCategories(prisma: PrismaService) {
const settings = await prisma.systemSettings.findUnique({
where: { id: 'default' },
})
const images = asImageMap(settings?.categoryImagesJson)
return {
usage: USAGE_CATEGORIES.map((item) => ({
...item,
imageUrl: images[item.id] || undefined,
})),
seasons: SEASON_CATEGORIES.map((item) => ({
...item,
imageUrl: images[item.id] || undefined,
})),
}
}
+57
View File
@@ -0,0 +1,57 @@
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
import { CatalogsService } from './catalogs.service';
import { AuthType } from '../common/decorators/auth.decorators';
import {
CreateCatalogDto,
SetCatalogActiveDto,
UpdateCatalogDto,
} from './dto/catalog.dto';
@Controller('catalogs')
export class CatalogsController {
constructor(private readonly catalogsService: CatalogsService) {}
@AuthType('any')
@Get()
findAll(
@Query('companyId') companyId?: string,
@Query('activeOnly') activeOnly?: string,
) {
return this.catalogsService.findAll(
companyId,
activeOnly === 'true' || activeOnly === '1',
);
}
@AuthType('any')
@Get(':id')
findOne(@Param('id') id: string) {
return this.catalogsService.findOne(id);
}
@AuthType('admin')
@Post()
create(@Body() dto: CreateCatalogDto) {
return this.catalogsService.create(dto);
}
@AuthType('admin')
@Patch(':id')
update(@Param('id') id: string, @Body() dto: UpdateCatalogDto) {
return this.catalogsService.update(id, dto);
}
@AuthType('admin')
@Patch(':id/active')
setActive(@Param('id') id: string, @Body() dto: SetCatalogActiveDto) {
return this.catalogsService.setActive(id, dto.isActive);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { CatalogsController } from './catalogs.controller';
import { CatalogsService } from './catalogs.service';
@Module({
controllers: [CatalogsController],
providers: [CatalogsService],
exports: [CatalogsService],
})
export class CatalogsModule {}
+152
View File
@@ -0,0 +1,152 @@
import {
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { CreateCatalogDto, UpdateCatalogDto } from './dto/catalog.dto';
@Injectable()
export class CatalogsService {
constructor(private readonly prisma: PrismaService) {}
async findAll(companyId?: string, activeOnly = false) {
const catalogs = await this.prisma.catalog.findMany({
where: {
...(companyId ? { companyId } : {}),
...(activeOnly ? { isActive: true } : {}),
},
orderBy: [{ displayOrder: 'asc' }, { name: 'asc' }],
include: {
company: { select: { name: true } },
_count: { select: { products: true } },
},
});
return catalogs.map((c) => this.map(c));
}
async findOne(id: string) {
const catalog = await this.prisma.catalog.findUnique({
where: { id },
include: {
company: { select: { name: true } },
_count: { select: { products: true } },
},
});
if (!catalog) throw new NotFoundException('کاتالوگ یافت نشد');
return this.map(catalog);
}
async create(dto: CreateCatalogDto) {
const company = await this.prisma.company.findUnique({
where: { id: dto.companyId },
});
if (!company) throw new NotFoundException('شرکت یافت نشد');
try {
const catalog = await this.prisma.catalog.create({
data: {
companyId: dto.companyId,
name: dto.name.trim(),
code: dto.code.trim(),
description: dto.description?.trim() || null,
displayOrder: dto.displayOrder ?? 0,
isActive: dto.isActive ?? true,
},
include: {
company: { select: { name: true } },
_count: { select: { products: true } },
},
});
return this.map(catalog);
} catch (e) {
if (
e instanceof Prisma.PrismaClientKnownRequestError &&
e.code === 'P2002'
) {
throw new ConflictException('کد کاتالوگ در این شرکت تکراری است');
}
throw e;
}
}
async update(id: string, dto: UpdateCatalogDto) {
await this.ensure(id);
try {
const catalog = await this.prisma.catalog.update({
where: { id },
data: {
name: dto.name?.trim(),
code: dto.code?.trim(),
description:
dto.description === undefined
? undefined
: dto.description?.trim() || null,
displayOrder: dto.displayOrder,
isActive: dto.isActive,
},
include: {
company: { select: { name: true } },
_count: { select: { products: true } },
},
});
return this.map(catalog);
} catch (e) {
if (
e instanceof Prisma.PrismaClientKnownRequestError &&
e.code === 'P2002'
) {
throw new ConflictException('کد کاتالوگ در این شرکت تکراری است');
}
throw e;
}
}
async setActive(id: string, isActive: boolean) {
await this.ensure(id);
const catalog = await this.prisma.catalog.update({
where: { id },
data: { isActive },
include: {
company: { select: { name: true } },
_count: { select: { products: true } },
},
});
return this.map(catalog);
}
private async ensure(id: string) {
const catalog = await this.prisma.catalog.findUnique({ where: { id } });
if (!catalog) throw new NotFoundException('کاتالوگ یافت نشد');
return catalog;
}
private map(catalog: {
id: string;
companyId: string;
name: string;
code: string;
description: string | null;
displayOrder: number;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
company: { name: string };
_count: { products: number };
}) {
return {
id: catalog.id,
companyId: catalog.companyId,
companyName: catalog.company.name,
name: catalog.name,
code: catalog.code,
description: catalog.description ?? undefined,
displayOrder: catalog.displayOrder,
isActive: catalog.isActive,
productCount: catalog._count.products,
createdAt: catalog.createdAt.toISOString(),
updatedAt: catalog.updatedAt.toISOString(),
};
}
}
+64
View File
@@ -0,0 +1,64 @@
import {
IsBoolean,
IsInt,
IsOptional,
IsString,
MinLength,
} from 'class-validator';
import { Type } from 'class-transformer';
export class CreateCatalogDto {
@IsString()
companyId!: string;
@IsString()
@MinLength(1)
name!: string;
@IsString()
@MinLength(1)
code!: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
displayOrder?: number;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class UpdateCatalogDto {
@IsOptional()
@IsString()
@MinLength(1)
name?: string;
@IsOptional()
@IsString()
@MinLength(1)
code?: string;
@IsOptional()
@IsString()
description?: string | null;
@IsOptional()
@Type(() => Number)
@IsInt()
displayOrder?: number;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class SetCatalogActiveDto {
@IsBoolean()
isActive!: boolean;
}
+32
View File
@@ -0,0 +1,32 @@
import { Body, Controller, Get, Param, Patch, Query } from '@nestjs/common';
import { ChecksService } from './checks.service';
import { AuthType, Roles } from '../common/decorators/auth.decorators';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import type { AuthPrincipal } from '../common/decorators/current-user.decorator';
import { UpdateCheckStatusDto } from './dto/check.dto';
@Controller()
@AuthType('admin')
@Roles('admin', 'finance')
export class ChecksController {
constructor(private readonly checksService: ChecksService) {}
@Get('admin/checks')
findAll(@Query('status') status?: string) {
return this.checksService.findAll(status);
}
@Get('admin/orders/:orderId/checks')
findByOrder(@Param('orderId') orderId: string) {
return this.checksService.findByOrder(orderId);
}
@Patch('admin/checks/:id/status')
updateStatus(
@CurrentUser() user: AuthPrincipal,
@Param('id') id: string,
@Body() dto: UpdateCheckStatusDto,
) {
return this.checksService.updateStatus(id, user.id, dto);
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { ChecksController } from './checks.controller';
import { ChecksService } from './checks.service';
import { NotificationsModule } from '../notifications/notifications.module';
@Module({
imports: [NotificationsModule],
controllers: [ChecksController],
providers: [ChecksService],
exports: [ChecksService],
})
export class ChecksModule {}
+164
View File
@@ -0,0 +1,164 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { CheckStatus, OrderStatus } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { UpdateCheckStatusDto } from './dto/check.dto';
import { NotificationsService } from '../notifications/notifications.service';
import { joinFullName } from '../common/utils/name';
@Injectable()
export class ChecksService {
constructor(
private readonly prisma: PrismaService,
private readonly notifications: NotificationsService,
) {}
async findByOrder(orderId: string) {
const order = await this.prisma.order.findUnique({
where: { id: orderId },
});
if (!order) throw new NotFoundException('سفارش یافت نشد');
const checks = await this.prisma.check.findMany({
where: { orderId },
orderBy: { row: 'asc' },
});
return checks.map((c) => this.map(c));
}
async findAll(status?: string) {
const where =
status && Object.values(CheckStatus).includes(status as CheckStatus)
? { status: status as CheckStatus }
: {};
const checks = await this.prisma.check.findMany({
where,
include: {
order: {
include: { user: true },
},
},
orderBy: [{ dueDate: 'asc' }, { createdAt: 'desc' }],
});
return checks.map((c) => ({
...this.map(c),
orderNumber: c.order.orderNumber,
customerName: joinFullName(c.order.user.firstName, c.order.user.lastName),
orderStatus: c.order.status,
settlementType: c.order.settlementType,
}));
}
async updateStatus(id: string, adminId: string, dto: UpdateCheckStatusDto) {
const check = await this.prisma.check.findUnique({
where: { id },
include: { order: { include: { checks: true } } },
});
if (!check) throw new NotFoundException('چک یافت نشد');
let notifyStatus: OrderStatus | null = null;
const updated = await this.prisma.$transaction(async (tx) => {
const result = await tx.check.update({
where: { id },
data: {
status: dto.status,
notes: dto.note
? [check.notes, dto.note].filter(Boolean).join('\n')
: check.notes,
},
});
const siblings = await tx.check.findMany({
where: { orderId: check.orderId },
});
const allApproved = siblings.every(
(c) => c.status === CheckStatus.approved,
);
const anyRejected = siblings.some(
(c) => c.status === CheckStatus.rejected,
);
// رد چک باید قابل اصلاح بماند؛ check_rejected راه برگشتی برای مشتری ندارد.
let nextStatus: OrderStatus | null = null;
if (anyRejected) {
nextStatus = OrderStatus.check_needs_fix;
} else if (allApproved) {
nextStatus = OrderStatus.checks_approved;
}
if (nextStatus && nextStatus !== check.order.status) {
await tx.order.update({
where: { id: check.orderId },
data: { status: nextStatus },
});
await tx.orderStatusHistory.create({
data: {
orderId: check.orderId,
fromStatus: check.order.status,
toStatus: nextStatus,
changedBy: `admin:${adminId}`,
note: dto.note || `وضعیت چک به ${dto.status} تغییر کرد`,
},
});
notifyStatus = nextStatus;
}
return result;
});
if (notifyStatus === OrderStatus.checks_approved) {
this.notifications.dispatchOrderEvent(check.orderId, 'checks_approved');
} else if (notifyStatus === OrderStatus.check_needs_fix) {
this.notifications.dispatchOrderEvent(check.orderId, 'checks_need_fix', {
note: dto.note ?? null,
});
}
return this.map(updated);
}
private map(c: {
id: string;
orderId: string;
row: number;
amount: number;
dueDate: Date;
sayadId: string | null;
checkNumber: string | null;
ownerNationalId: string | null;
ownerFirstName: string | null;
ownerLastName: string | null;
bankName: string | null;
branchName: string | null;
frontImage: string | null;
backImage: string | null;
notes: string | null;
mode: string;
status: CheckStatus;
createdAt: Date;
updatedAt: Date;
}) {
return {
id: c.id,
orderId: c.orderId,
row: c.row,
amount: c.amount,
dueDate: c.dueDate.toISOString(),
sayadId: c.sayadId ?? '',
checkNumber: c.checkNumber ?? undefined,
ownerNationalId: c.ownerNationalId ?? undefined,
ownerFirstName: c.ownerFirstName ?? undefined,
ownerLastName: c.ownerLastName ?? undefined,
bankName: c.bankName ?? '',
branchName: c.branchName ?? undefined,
frontImage: c.frontImage ?? undefined,
backImage: c.backImage ?? undefined,
notes: c.notes ?? undefined,
mode: c.mode,
status: c.status,
createdAt: c.createdAt.toISOString(),
updatedAt: c.updatedAt.toISOString(),
};
}
}
+11
View File
@@ -0,0 +1,11 @@
import { IsEnum, IsOptional, IsString } from 'class-validator';
import { CheckStatus } from '@prisma/client';
export class UpdateCheckStatusDto {
@IsEnum(CheckStatus)
status!: CheckStatus;
@IsOptional()
@IsString()
note?: string;
}
+11
View File
@@ -0,0 +1,11 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
export const ROLES_KEY = 'roles';
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
export const AUTH_TYPE_KEY = 'authType';
export const AuthType = (type: 'customer' | 'admin' | 'any') =>
SetMetadata(AUTH_TYPE_KEY, type);
@@ -0,0 +1,15 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export interface AuthPrincipal {
id: string;
type: 'customer' | 'admin';
role?: string;
phone?: string;
}
export const CurrentUser = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): AuthPrincipal => {
const request = ctx.switchToHttp().getRequest<{ user: AuthPrincipal }>();
return request.user;
},
);
+78
View File
@@ -0,0 +1,78 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
ForbiddenException,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import {
AUTH_TYPE_KEY,
IS_PUBLIC_KEY,
ROLES_KEY,
} from '../decorators/auth.decorators';
import { AuthPrincipal } from '../decorators/current-user.decorator';
import { resolveJwtSecret } from '../utils/jwt-secret';
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly jwtService: JwtService,
private readonly config: ConfigService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
const request = context.switchToHttp().getRequest<{
headers: Record<string, string | undefined>;
user?: AuthPrincipal;
}>();
const authHeader = request.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
throw new UnauthorizedException('توکن احراز هویت الزامی است');
}
const token = authHeader.slice(7);
let payload: AuthPrincipal & { sub?: string };
try {
payload = await this.jwtService.verifyAsync(token, {
secret: resolveJwtSecret(this.config),
});
} catch {
throw new UnauthorizedException('توکن نامعتبر یا منقضی شده است');
}
const user: AuthPrincipal = {
id: payload.sub ?? payload.id,
type: payload.type,
role: payload.role,
phone: payload.phone,
};
request.user = user;
const authType = this.reflector.getAllAndOverride<
'customer' | 'admin' | 'any'
>(AUTH_TYPE_KEY, [context.getHandler(), context.getClass()]);
if (authType && authType !== 'any' && user.type !== authType) {
throw new ForbiddenException('دسترسی مجاز نیست');
}
const roles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (roles?.length && (!user.role || !roles.includes(user.role))) {
throw new ForbiddenException('نقش کاربری کافی نیست');
}
return true;
}
}
+39
View File
@@ -0,0 +1,39 @@
import { addDays, tehranAtHour } from './tehran-time';
export function computeCashInvoiceExpiry(company: {
cashInvoiceValidUntilHour: number | null;
cashInvoiceValidityHours: number;
}): Date {
const now = new Date();
if (
company.cashInvoiceValidUntilHour != null &&
Number.isFinite(company.cashInvoiceValidUntilHour)
) {
const expires = tehranAtHour(now, company.cashInvoiceValidUntilHour);
if (expires.getTime() <= now.getTime()) {
return tehranAtHour(addDays(now, 1), company.cashInvoiceValidUntilHour);
}
return expires;
}
return new Date(
now.getTime() + company.cashInvoiceValidityHours * 60 * 60 * 1000,
);
}
/** برای فاکتور چندشرکتی نزدیک‌ترین مهلت را انتخاب می‌کند. */
export function earliestCashInvoiceExpiry(
companies: Array<{
cashInvoiceValidUntilHour: number | null;
cashInvoiceValidityHours: number;
}>,
): Date {
if (!companies.length) {
return new Date(Date.now() + 8 * 60 * 60 * 1000);
}
return companies
.map((company) => computeCashInvoiceExpiry(company))
.reduce((earliest, current) =>
current.getTime() < earliest.getTime() ? current : earliest,
);
}
+62
View File
@@ -0,0 +1,62 @@
export type CheckRassInput = {
amount: number;
dueDate: Date | string;
};
export type CheckRassResult = {
checkCount: number;
totalAmount: number;
nearestDueDate: string | null;
farthestDueDate: string | null;
rassDays: number;
rassDate: string | null;
};
import {
addDays,
calendarDayOf,
diffInDays,
tehranCalendarDay,
} from './tehran-time';
export function calculateCheckRass(
checks: CheckRassInput[],
baseDate: Date = new Date(),
): CheckRassResult {
if (checks.length === 0) {
return {
checkCount: 0,
totalAmount: 0,
nearestDueDate: null,
farthestDueDate: null,
rassDays: 0,
rassDate: null,
};
}
const base = tehranCalendarDay(baseDate);
let totalAmount = 0;
let weightedDays = 0;
const dueDates: Date[] = [];
for (const check of checks) {
const due = calendarDayOf(check.dueDate);
dueDates.push(due);
const days = Math.max(0, diffInDays(base, due));
totalAmount += check.amount;
weightedDays += check.amount * days;
}
const rassDays = totalAmount > 0 ? Math.round(weightedDays / totalAmount) : 0;
const rassDate = addDays(base, rassDays);
dueDates.sort((a, b) => a.getTime() - b.getTime());
return {
checkCount: checks.length,
totalAmount,
nearestDueDate: dueDates[0]?.toISOString() ?? null,
farthestDueDate: dueDates[dueDates.length - 1]?.toISOString() ?? null,
rassDays,
rassDate: rassDate.toISOString(),
};
}
+16
View File
@@ -0,0 +1,16 @@
import { ConfigService } from '@nestjs/config';
const DEV_FALLBACK = 'fabric-trade-dev-secret';
/**
* امضا و اعتبارسنجی توکن باید از یک منبع بخوانند؛ در production نبود مقدار خطای راه‌اندازی است.
*/
export function resolveJwtSecret(config: ConfigService): string {
const secret = config.get<string>('JWT_SECRET')?.trim();
if (secret) return secret;
if (config.get<string>('NODE_ENV') === 'production') {
throw new Error('JWT_SECRET تنظیم نشده است');
}
return DEV_FALLBACK;
}

Some files were not shown because too many files have changed in this diff Show More