Initialize the API and add managed app banners.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||||
@@ -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
@@ -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
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all"
|
||||||
|
}
|
||||||
+33
@@ -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"]
|
||||||
@@ -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"]
|
||||||
@@ -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"]
|
||||||
@@ -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.
@@ -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"
|
||||||
Vendored
+191
@@ -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;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
export declare class AgentModule {
|
||||||
|
}
|
||||||
Vendored
+191
@@ -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;
|
||||||
|
}[];
|
||||||
|
}>;
|
||||||
|
}
|
||||||
Vendored
+363
@@ -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
|
||||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
export declare class AppModule {
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
export {};
|
||||||
@@ -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:
|
||||||
@@ -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:
|
||||||
@@ -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:
|
||||||
@@ -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:
|
||||||
@@ -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
|
||||||
@@ -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" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"builder": "tsc",
|
||||||
|
"deleteOutDir": true,
|
||||||
|
"webpack": false
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+11074
File diff suppressed because it is too large
Load Diff
+104
@@ -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.
@@ -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');
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
provider = "postgresql"
|
||||||
@@ -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
@@ -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();
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
@@ -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)"
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 ?? []);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user