Add NestJS fabric trade API with Prisma and Docker
Provide backend for admin and user apps with Postgres, migrations, seed data, and docker-compose for LiteSpeed reverse-proxy deployment. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,15 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
coverage
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.md
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
test
|
||||||
|
*.spec.ts
|
||||||
|
.pgdata
|
||||||
|
.tools
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Local / host Prisma
|
||||||
|
DATABASE_URL="postgresql://fabric:fabric123@localhost:5432/fabric_trade?schema=public"
|
||||||
|
|
||||||
|
# Docker Compose
|
||||||
|
POSTGRES_USER=fabric
|
||||||
|
POSTGRES_PASSWORD=fabric123
|
||||||
|
POSTGRES_DB=fabric_trade
|
||||||
|
POSTGRES_PORT=5432
|
||||||
|
API_PORT=3000
|
||||||
|
RUN_SEED=false
|
||||||
|
|
||||||
|
JWT_SECRET="fabric-trade-dev-secret-change-me"
|
||||||
|
JWT_EXPIRES_IN="7d"
|
||||||
|
PORT=3000
|
||||||
|
MOCK_OTP_CODE="123456"
|
||||||
|
OTP_TTL_MINUTES=5
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
# compiled output
|
||||||
|
/dist
|
||||||
|
/node_modules
|
||||||
|
/build
|
||||||
|
/generated
|
||||||
|
|
||||||
|
# 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"
|
||||||
|
}
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
# Build
|
||||||
|
FROM node:22-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY prisma ./prisma
|
||||||
|
COPY prisma.config.ts ./
|
||||||
|
COPY tsconfig.json tsconfig.build.json nest-cli.json ./
|
||||||
|
COPY src ./src
|
||||||
|
|
||||||
|
RUN npx prisma generate
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Production
|
||||||
|
FROM node:22-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
RUN apk add --no-cache openssl
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci --omit=dev && npm install prisma ts-node typescript --no-save
|
||||||
|
|
||||||
|
COPY prisma ./prisma
|
||||||
|
COPY prisma.config.ts ./
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||||
|
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
|
||||||
|
COPY docker-entrypoint.sh ./
|
||||||
|
|
||||||
|
RUN chmod +x docker-entrypoint.sh \
|
||||||
|
&& npx prisma generate
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
ENTRYPOINT ["./docker-entrypoint.sh"]
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# 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
|
||||||
|
```
|
||||||
|
|
||||||
|
## دیپلوی با Docker Compose (پیشنهادی برای سرور)
|
||||||
|
|
||||||
|
روی سرور داخل `fabric_trade_server`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# اولین بار با دیتای فیک:
|
||||||
|
RUN_SEED=true docker compose up -d --build
|
||||||
|
|
||||||
|
# بعداً بدون seed:
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
- API روی `127.0.0.1:3000` بالا میآید
|
||||||
|
- مایگریشن موقع استارت کانتینر اجرا میشود
|
||||||
|
- LiteSpeed را Reverse Proxy کنید به `http://127.0.0.1:3000`
|
||||||
|
|
||||||
|
مثال مسیر 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 |
|
||||||
|
|------|------|-----------|
|
||||||
|
| ادمین | `09121112222` | `admin123` |
|
||||||
|
| مشتری | `09121234567` | OTP: `123456` |
|
||||||
|
|
||||||
|
## متغیرهای محیطی
|
||||||
|
|
||||||
|
فایل `.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=123456
|
||||||
|
OTP_TTL_MINUTES=5
|
||||||
|
```
|
||||||
|
|
||||||
|
## اسکریپتها
|
||||||
|
|
||||||
|
| دستور | کار |
|
||||||
|
|--------|-----|
|
||||||
|
| `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
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: fabric_trade_db
|
||||||
|
restart: unless-stopped
|
||||||
|
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
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://${POSTGRES_USER:-fabric}:${POSTGRES_PASSWORD:-fabric123}@postgres:5432/${POSTGRES_DB:-fabric_trade}?schema=public
|
||||||
|
JWT_SECRET: ${JWT_SECRET:-fabric-trade-dev-secret-change-me}
|
||||||
|
JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-7d}
|
||||||
|
PORT: 3000
|
||||||
|
MOCK_OTP_CODE: ${MOCK_OTP_CODE:-123456}
|
||||||
|
OTP_TTL_MINUTES: ${OTP_TTL_MINUTES:-5}
|
||||||
|
RUN_SEED: ${RUN_SEED:-false}
|
||||||
|
ports:
|
||||||
|
# فقط روی localhost سرور اکسپوز شود؛ LiteSpeed به همین پورت پروکسی میزند
|
||||||
|
- '127.0.0.1:${API_PORT:-3000}:3000'
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
fabric_pg_data:
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "Waiting for database & applying migrations..."
|
||||||
|
i=0
|
||||||
|
until npx prisma migrate deploy; do
|
||||||
|
i=$((i + 1))
|
||||||
|
if [ "$i" -ge 30 ]; then
|
||||||
|
echo "Database not ready after 30 attempts"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Retry $i..."
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "${RUN_SEED:-false}" = "true" ]; then
|
||||||
|
echo "Seeding database..."
|
||||||
|
npx prisma db seed || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Starting API..."
|
||||||
|
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,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+11062
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,99 @@
|
|||||||
|
{
|
||||||
|
"name": "fabric_trade_server",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "",
|
||||||
|
"author": "",
|
||||||
|
"private": true,
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"scripts": {
|
||||||
|
"build": "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",
|
||||||
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
|
"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",
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"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/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",
|
||||||
|
"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",
|
||||||
|
"prisma": "^6.19.3",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 @@
|
|||||||
|
provider = "postgresql"
|
||||||
@@ -0,0 +1,419 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
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 AdminRole {
|
||||||
|
admin
|
||||||
|
sales
|
||||||
|
finance
|
||||||
|
warehouse
|
||||||
|
}
|
||||||
|
|
||||||
|
enum NotificationTone {
|
||||||
|
info
|
||||||
|
warning
|
||||||
|
success
|
||||||
|
danger
|
||||||
|
}
|
||||||
|
|
||||||
|
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 @default(5000)
|
||||||
|
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)
|
||||||
|
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[]
|
||||||
|
|
||||||
|
@@unique([companyId, code])
|
||||||
|
@@index([catalogId])
|
||||||
|
@@index([companyId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Customer {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
firstName String
|
||||||
|
lastName String
|
||||||
|
phone String @unique
|
||||||
|
nationalId String?
|
||||||
|
customerType CustomerType @default(wholesale)
|
||||||
|
companyId String?
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
company Company? @relation(fields: [companyId], references: [id], onDelete: SetNull)
|
||||||
|
carts Cart[]
|
||||||
|
orders Order[]
|
||||||
|
|
||||||
|
@@index([companyId])
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
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?
|
||||||
|
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[]
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([companyId])
|
||||||
|
@@index([status])
|
||||||
|
@@index([createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
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?
|
||||||
|
|
||||||
|
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
||||||
|
product Product? @relation(fields: [productId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
@@index([orderId])
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
smsOrderRegistered Boolean @default(true)
|
||||||
|
smsPaymentReminder Boolean @default(true)
|
||||||
|
smsCheckReview Boolean @default(true)
|
||||||
|
smsShippingUpdate Boolean @default(true)
|
||||||
|
supportPhone String?
|
||||||
|
supportEmail String?
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
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 InternalMessage {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
senderName String
|
||||||
|
subject String
|
||||||
|
preview String
|
||||||
|
isRead Boolean @default(false)
|
||||||
|
sentAt DateTime @default(now())
|
||||||
|
}
|
||||||
+649
@@ -0,0 +1,649 @@
|
|||||||
|
import { PrismaClient, AdminRole, CustomerType, OrderStatus, SettlementType, CheckRegistrationMode, CheckStatus, CashPaymentStatus, ShipmentStatus, NotificationTone } from '@prisma/client';
|
||||||
|
import * as bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('Seeding fabric_trade database...');
|
||||||
|
|
||||||
|
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.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('admin123', 10);
|
||||||
|
|
||||||
|
await prisma.adminUser.createMany({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: 'usr-1',
|
||||||
|
fullName: 'علی محمدی',
|
||||||
|
phone: '09121112222',
|
||||||
|
email: 'admin@fabrictrade.ir',
|
||||||
|
passwordHash,
|
||||||
|
role: AdminRole.admin,
|
||||||
|
isActive: true,
|
||||||
|
lastLoginAt: new Date('2024-10-25T08:00:00'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'usr-2',
|
||||||
|
fullName: 'سارا احمدی',
|
||||||
|
phone: '09133334444',
|
||||||
|
email: 'sales@fabrictrade.ir',
|
||||||
|
passwordHash,
|
||||||
|
role: AdminRole.sales,
|
||||||
|
isActive: true,
|
||||||
|
lastLoginAt: new Date('2024-10-25T07:45:00'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'usr-3',
|
||||||
|
fullName: 'رضا کریمی',
|
||||||
|
phone: '09155556666',
|
||||||
|
email: 'finance@fabrictrade.ir',
|
||||||
|
passwordHash,
|
||||||
|
role: AdminRole.finance,
|
||||||
|
isActive: true,
|
||||||
|
lastLoginAt: new Date('2024-10-24T17:10:00'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'usr-4',
|
||||||
|
fullName: 'مریم حسینی',
|
||||||
|
phone: '09177778888',
|
||||||
|
email: 'warehouse@fabrictrade.ir',
|
||||||
|
passwordHash,
|
||||||
|
role: AdminRole.warehouse,
|
||||||
|
isActive: true,
|
||||||
|
lastLoginAt: new Date('2024-10-24T14:00:00'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'usr-5',
|
||||||
|
fullName: 'امیر نوری',
|
||||||
|
phone: '09199990000',
|
||||||
|
email: 'amir.n@fabrictrade.ir',
|
||||||
|
passwordHash,
|
||||||
|
role: AdminRole.sales,
|
||||||
|
isActive: false,
|
||||||
|
lastLoginAt: new Date('2024-09-30T11:00:00'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.company.createMany({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: 'company-par',
|
||||||
|
name: 'شرکت پَر',
|
||||||
|
slug: 'par',
|
||||||
|
description: 'تولیدکننده پارچههای باکیفیت',
|
||||||
|
isActive: true,
|
||||||
|
displayOrder: 1,
|
||||||
|
maxMeteragePerInvoice: 5000,
|
||||||
|
cashInvoiceValidityHours: 8,
|
||||||
|
cashInvoiceValidUntilHour: 17,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'company-fam-baft',
|
||||||
|
name: 'فامبافت',
|
||||||
|
slug: 'fam-baft',
|
||||||
|
description: 'عرضه پارچههای تخصصی',
|
||||||
|
isActive: true,
|
||||||
|
displayOrder: 2,
|
||||||
|
maxMeteragePerInvoice: 5000,
|
||||||
|
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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
{ 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 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.customer.createMany({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: 'cust-1',
|
||||||
|
firstName: 'پارچهسرای',
|
||||||
|
lastName: 'نیکو',
|
||||||
|
phone: '09121234567',
|
||||||
|
nationalId: '0012345678',
|
||||||
|
customerType: CustomerType.wholesale,
|
||||||
|
companyId: 'company-par',
|
||||||
|
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',
|
||||||
|
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',
|
||||||
|
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',
|
||||||
|
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',
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date('2024-06-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.adminNotification.createMany({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: 'ntf-1',
|
||||||
|
title: 'سفارش جدید ثبت شد',
|
||||||
|
body: 'سفارش ORD-1403-1260 از مشتری پارچهسرای نیکو در انتظار بررسی است.',
|
||||||
|
tone: NotificationTone.info,
|
||||||
|
link: '/orders',
|
||||||
|
isRead: false,
|
||||||
|
sentAt: new Date('2024-10-25T10:05:00'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ntf-2',
|
||||||
|
title: 'چک در انتظار تأیید',
|
||||||
|
body: '۳ فقره چک برای سفارش ORD-1403-1254 نیاز به بررسی دارد.',
|
||||||
|
tone: NotificationTone.warning,
|
||||||
|
link: '/orders/ord-1/checks',
|
||||||
|
isRead: false,
|
||||||
|
sentAt: new Date('2024-10-25T09:50:00'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ntf-3',
|
||||||
|
title: 'پیشفاکتور ارسال شد',
|
||||||
|
body: 'پیشفاکتور سفارش ORD-1403-1252 برای مشتری ارسال گردید.',
|
||||||
|
tone: NotificationTone.success,
|
||||||
|
isRead: false,
|
||||||
|
sentAt: new Date('2024-10-25T09:20:00'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.internalMessage.createMany({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: 'msg-1',
|
||||||
|
senderName: 'سارا احمدی',
|
||||||
|
subject: 'هماهنگی ارسال سفارش ORD-1403-1256',
|
||||||
|
preview: 'لطفاً وضعیت باربری سفارش مشتری پارچهسرای نیکو را بررسی کنید.',
|
||||||
|
sentAt: new Date('2024-10-25T09:15:00'),
|
||||||
|
isRead: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'msg-2',
|
||||||
|
senderName: 'رضا کریمی',
|
||||||
|
subject: 'درخواست اصلاح قیمت',
|
||||||
|
preview: 'مشتری درخواست تخفیف ۵٪ روی متراژ بالای ۳۰۰۰ متر داده است.',
|
||||||
|
sentAt: new Date('2024-10-25T08:40:00'),
|
||||||
|
isRead: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
void order1;
|
||||||
|
console.log('Seed completed successfully.');
|
||||||
|
console.log('Admin login: 09121112222 / admin123');
|
||||||
|
console.log('Customer OTP login: 09121234567 / code 123456');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
@@ -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: 09121112222 / admin123"
|
||||||
|
echo "OTP: 09121234567 / 123456"
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
|
||||||
|
import { AdminUsersService } from './admin-users.service';
|
||||||
|
import { AuthType } from '../common/decorators/auth.decorators';
|
||||||
|
import {
|
||||||
|
CreateAdminUserDto,
|
||||||
|
SetAdminActiveDto,
|
||||||
|
UpdateAdminUserDto,
|
||||||
|
} from './dto/admin-user.dto';
|
||||||
|
|
||||||
|
@Controller('admin/users')
|
||||||
|
@AuthType('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,139 @@
|
|||||||
|
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 = 'admin123';
|
||||||
|
|
||||||
|
@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,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await this.prisma.adminUser.create({
|
||||||
|
data: {
|
||||||
|
fullName: dto.fullName.trim(),
|
||||||
|
phone,
|
||||||
|
email: dto.email.trim().toLowerCase(),
|
||||||
|
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?.trim().toLowerCase(),
|
||||||
|
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,68 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
@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,45 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
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';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
ConfigModule.forRoot({ isGlobal: true }),
|
||||||
|
PrismaModule,
|
||||||
|
AuthModule,
|
||||||
|
CompaniesModule,
|
||||||
|
CatalogsModule,
|
||||||
|
ProductsModule,
|
||||||
|
CustomersModule,
|
||||||
|
AdminUsersModule,
|
||||||
|
OrdersModule,
|
||||||
|
ChecksModule,
|
||||||
|
CashPaymentsModule,
|
||||||
|
ShipmentsModule,
|
||||||
|
DashboardModule,
|
||||||
|
SettingsModule,
|
||||||
|
CartsModule,
|
||||||
|
],
|
||||||
|
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,24 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
JwtModule.registerAsync({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (config: ConfigService) => ({
|
||||||
|
secret: config.get<string>('JWT_SECRET', 'fabric-trade-dev-secret'),
|
||||||
|
signOptions: {
|
||||||
|
expiresIn: config.get('JWT_EXPIRES_IN', '7d') as `${number}d`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
controllers: [AuthController],
|
||||||
|
providers: [AuthService],
|
||||||
|
exports: [AuthService, JwtModule],
|
||||||
|
})
|
||||||
|
export class AuthModule {}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import * as bcrypt from 'bcryptjs';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import {
|
||||||
|
AdminLoginDto,
|
||||||
|
RequestOtpDto,
|
||||||
|
UpdateProfileDto,
|
||||||
|
VerifyOtpDto,
|
||||||
|
} from './dto/auth.dto';
|
||||||
|
import { isValidPhone, normalizePhone, toEnglishDigits } from '../common/utils/phone';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly jwt: JwtService,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
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('کاربری با این شماره یافت نشد');
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = this.config.get<string>('MOCK_OTP_CODE', '123456');
|
||||||
|
const ttl = Number(this.config.get('OTP_TTL_MINUTES', 5));
|
||||||
|
const expiresAt = new Date(Date.now() + ttl * 60_000);
|
||||||
|
|
||||||
|
await this.prisma.otpChallenge.deleteMany({ where: { phone } });
|
||||||
|
await this.prisma.otpChallenge.create({
|
||||||
|
data: {
|
||||||
|
phone,
|
||||||
|
code,
|
||||||
|
firstName: dto.firstName?.trim(),
|
||||||
|
lastName: dto.lastName?.trim(),
|
||||||
|
isRegister,
|
||||||
|
expiresAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'کد تأیید ارسال شد',
|
||||||
|
expiresAt,
|
||||||
|
// فقط برای محیط توسعه
|
||||||
|
debugCode: code,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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.code !== code) {
|
||||||
|
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('کاربر یافت نشد');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.otpChallenge.deleteMany({ where: { phone } });
|
||||||
|
|
||||||
|
const accessToken = await this.signCustomerToken(user.id, user.phone);
|
||||||
|
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 user = await this.prisma.customer.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: {
|
||||||
|
firstName: dto.firstName?.trim(),
|
||||||
|
lastName: dto.lastName?.trim(),
|
||||||
|
nationalId: dto.nationalId?.trim() || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.mapCustomer(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async signCustomerToken(id: string, phone: string) {
|
||||||
|
return this.jwt.signAsync({
|
||||||
|
sub: id,
|
||||||
|
type: 'customer',
|
||||||
|
phone,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapCustomer(user: {
|
||||||
|
id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
phone: string;
|
||||||
|
nationalId: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
firstName: user.firstName,
|
||||||
|
lastName: user.lastName,
|
||||||
|
phone: user.phone,
|
||||||
|
nationalId: user.nationalId ?? undefined,
|
||||||
|
createdAt: user.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { IsBoolean, IsOptional, IsString, 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()
|
||||||
|
@MinLength(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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
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,225 @@
|
|||||||
|
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('محصول یافت نشد');
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
this.validateMeterage(product, newMeterage);
|
||||||
|
await this.prisma.cartItem.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
meterage: newMeterage,
|
||||||
|
notes: dto.notes?.trim() || existing.notes,
|
||||||
|
unitPrice: product.basePrice,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} 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,
|
||||||
|
unitPrice: product.basePrice,
|
||||||
|
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('آیتم سبد یافت نشد');
|
||||||
|
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 } });
|
||||||
|
await this.prisma.cart.update({
|
||||||
|
where: { id: cart.id },
|
||||||
|
data: { 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 validateMeterage(
|
||||||
|
product: { name: string; minMeterage: number; orderMultiplier: number },
|
||||||
|
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} باشد`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
unitPrice: number | null;
|
||||||
|
notes: string | null;
|
||||||
|
companyId: string;
|
||||||
|
companyName: string;
|
||||||
|
}>;
|
||||||
|
}) {
|
||||||
|
const companyName = cart.items[0]?.companyName;
|
||||||
|
return {
|
||||||
|
id: cart.id,
|
||||||
|
userId: cart.userId,
|
||||||
|
companyId: cart.companyId ?? cart.items[0]?.companyId,
|
||||||
|
companyName: companyName ?? '',
|
||||||
|
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,
|
||||||
|
unitPrice: item.unitPrice ?? undefined,
|
||||||
|
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,49 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { CashPaymentStatus } from '@prisma/client';
|
||||||
|
import { CashPaymentsService } from './cash-payments.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 {
|
||||||
|
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')
|
||||||
|
@Get('admin/cash-payments')
|
||||||
|
findAll(@Query('status') status?: CashPaymentStatus) {
|
||||||
|
return this.cashPaymentsService.findAll(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
@AuthType('admin')
|
||||||
|
@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,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { CashPaymentsController } from './cash-payments.controller';
|
||||||
|
import { CashPaymentsService } from './cash-payments.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [CashPaymentsController],
|
||||||
|
providers: [CashPaymentsService],
|
||||||
|
exports: [CashPaymentsService],
|
||||||
|
})
|
||||||
|
export class CashPaymentsModule {}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CashPaymentsService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async submitReceipt(
|
||||||
|
userId: string,
|
||||||
|
orderId: string,
|
||||||
|
dto: SubmitCashReceiptDto,
|
||||||
|
) {
|
||||||
|
const order = await this.prisma.order.findUnique({
|
||||||
|
where: { id: orderId },
|
||||||
|
include: { cashReceipt: true },
|
||||||
|
});
|
||||||
|
if (!order) throw new NotFoundException('سفارش یافت نشد');
|
||||||
|
if (order.userId !== userId) {
|
||||||
|
throw new ForbiddenException('دسترسی به این سفارش مجاز نیست');
|
||||||
|
}
|
||||||
|
if (order.settlementType !== SettlementType.cash) {
|
||||||
|
throw new BadRequestException('این سفارش تسویه نقدی نیست');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
order.cashInvoiceExpiresAt &&
|
||||||
|
order.cashInvoiceExpiresAt.getTime() < Date.now()
|
||||||
|
) {
|
||||||
|
throw new BadRequestException('مهلت پرداخت فاکتور نقدی به پایان رسیده است');
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = await this.prisma.bankAccount.findFirst({
|
||||||
|
where: {
|
||||||
|
id: dto.destinationAccountId,
|
||||||
|
companyId: order.companyId,
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
|
||||||
|
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.needs_correction;
|
||||||
|
} 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;
|
||||||
|
});
|
||||||
|
|
||||||
|
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,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,146 @@
|
|||||||
|
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,26 @@
|
|||||||
|
import { Body, Controller, Get, Param, Patch } from '@nestjs/common';
|
||||||
|
import { ChecksService } from './checks.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 { UpdateCheckStatusDto } from './dto/check.dto';
|
||||||
|
|
||||||
|
@Controller()
|
||||||
|
@AuthType('admin')
|
||||||
|
export class ChecksController {
|
||||||
|
constructor(private readonly checksService: ChecksService) {}
|
||||||
|
|
||||||
|
@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,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ChecksController } from './checks.controller';
|
||||||
|
import { ChecksService } from './checks.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [ChecksController],
|
||||||
|
providers: [ChecksService],
|
||||||
|
exports: [ChecksService],
|
||||||
|
})
|
||||||
|
export class ChecksModule {}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
CheckStatus,
|
||||||
|
OrderStatus,
|
||||||
|
} from '@prisma/client';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { UpdateCheckStatusDto } from './dto/check.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ChecksService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
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 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('چک یافت نشد');
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
let nextStatus: OrderStatus | null = null;
|
||||||
|
if (anyRejected) {
|
||||||
|
nextStatus = OrderStatus.check_rejected;
|
||||||
|
} else if (allApproved) {
|
||||||
|
nextStatus = OrderStatus.checks_approved;
|
||||||
|
} else if (dto.status === CheckStatus.rejected) {
|
||||||
|
nextStatus = OrderStatus.check_needs_fix;
|
||||||
|
}
|
||||||
|
|
||||||
|
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} تغییر کرد`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
return request.user;
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
@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();
|
||||||
|
const authHeader = request.headers.authorization as string | undefined;
|
||||||
|
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: this.config.get<string>('JWT_SECRET'),
|
||||||
|
});
|
||||||
|
} 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,21 @@
|
|||||||
|
export function computeCashInvoiceExpiry(company: {
|
||||||
|
cashInvoiceValidUntilHour: number | null;
|
||||||
|
cashInvoiceValidityHours: number;
|
||||||
|
}): Date {
|
||||||
|
const now = new Date();
|
||||||
|
if (
|
||||||
|
company.cashInvoiceValidUntilHour != null &&
|
||||||
|
Number.isFinite(company.cashInvoiceValidUntilHour)
|
||||||
|
) {
|
||||||
|
const expires = new Date(now);
|
||||||
|
expires.setHours(company.cashInvoiceValidUntilHour, 0, 0, 0);
|
||||||
|
if (expires.getTime() <= now.getTime()) {
|
||||||
|
expires.setDate(expires.getDate() + 1);
|
||||||
|
}
|
||||||
|
return expires;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(
|
||||||
|
now.getTime() + company.cashInvoiceValidityHours * 60 * 60 * 1000,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
function startOfDay(date: Date): Date {
|
||||||
|
const d = new Date(date);
|
||||||
|
d.setHours(0, 0, 0, 0);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = startOfDay(baseDate);
|
||||||
|
let totalAmount = 0;
|
||||||
|
let weightedDays = 0;
|
||||||
|
const dueDates: Date[] = [];
|
||||||
|
|
||||||
|
for (const check of checks) {
|
||||||
|
const due = startOfDay(new Date(check.dueDate));
|
||||||
|
dueDates.push(due);
|
||||||
|
const days = Math.max(
|
||||||
|
0,
|
||||||
|
Math.round((due.getTime() - base.getTime()) / (1000 * 60 * 60 * 24)),
|
||||||
|
);
|
||||||
|
totalAmount += check.amount;
|
||||||
|
weightedDays += check.amount * days;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rassDays = totalAmount > 0 ? Math.round(weightedDays / totalAmount) : 0;
|
||||||
|
const rassDate = new Date(base);
|
||||||
|
rassDate.setDate(rassDate.getDate() + 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,21 @@
|
|||||||
|
export function splitFullName(name: string): {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
} {
|
||||||
|
const trimmed = name.trim().replace(/\s+/g, ' ');
|
||||||
|
if (!trimmed) {
|
||||||
|
return { firstName: 'مشتری', lastName: 'جدید' };
|
||||||
|
}
|
||||||
|
const parts = trimmed.split(' ');
|
||||||
|
if (parts.length === 1) {
|
||||||
|
return { firstName: parts[0]!, lastName: '' };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
firstName: parts[0]!,
|
||||||
|
lastName: parts.slice(1).join(' '),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function joinFullName(firstName: string, lastName: string): string {
|
||||||
|
return `${firstName} ${lastName}`.trim();
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export function generateOrderNumber(date: Date = new Date()): string {
|
||||||
|
const year = new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
|
||||||
|
year: 'numeric',
|
||||||
|
}).format(date);
|
||||||
|
const persianYear = year.replace(/[^\d]/g, '') || '1403';
|
||||||
|
const seq = Math.floor(Math.random() * 9000) + 1000;
|
||||||
|
return `ORD-${persianYear}-${seq}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { OrderStatus } from '@prisma/client';
|
||||||
|
|
||||||
|
/** Admin UI simplified statuses → full Prisma OrderStatus values */
|
||||||
|
export const ADMIN_STATUS_MAP: Record<string, OrderStatus[]> = {
|
||||||
|
pending_review: [OrderStatus.awaiting_review, OrderStatus.pricing],
|
||||||
|
pending_check: [OrderStatus.awaiting_check_review],
|
||||||
|
needs_correction: [OrderStatus.needs_correction],
|
||||||
|
check_correction: [OrderStatus.check_needs_fix],
|
||||||
|
final_approval: [
|
||||||
|
OrderStatus.final_approved,
|
||||||
|
OrderStatus.checks_approved,
|
||||||
|
OrderStatus.cash_confirmed,
|
||||||
|
],
|
||||||
|
shipped: [OrderStatus.shipped],
|
||||||
|
delivered: [OrderStatus.delivered],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function resolveAdminStatusFilter(
|
||||||
|
status?: string,
|
||||||
|
): OrderStatus[] | undefined {
|
||||||
|
if (!status) return undefined;
|
||||||
|
if (ADMIN_STATUS_MAP[status]) return ADMIN_STATUS_MAP[status];
|
||||||
|
if (Object.values(OrderStatus).includes(status as OrderStatus)) {
|
||||||
|
return [status as OrderStatus];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toAdminStatus(status: OrderStatus): string {
|
||||||
|
for (const [admin, list] of Object.entries(ADMIN_STATUS_MAP)) {
|
||||||
|
if (list.includes(status)) return admin;
|
||||||
|
}
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ORDER_STATUS_LABELS: Record<OrderStatus, string> = {
|
||||||
|
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: 'منقضی',
|
||||||
|
};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export function normalizePhone(raw: string): string {
|
||||||
|
let digits = raw.replace(/\D/g, '');
|
||||||
|
if (digits.startsWith('98') && digits.length === 12) {
|
||||||
|
digits = `0${digits.slice(2)}`;
|
||||||
|
}
|
||||||
|
if (digits.length === 10 && digits.startsWith('9')) {
|
||||||
|
digits = `0${digits}`;
|
||||||
|
}
|
||||||
|
return digits;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidPhone(phone: string): boolean {
|
||||||
|
return /^09\d{9}$/.test(phone);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toEnglishDigits(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/[۰-۹]/g, (d) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(d)))
|
||||||
|
.replace(/[٠-٩]/g, (d) => String('٠١٢٣٤٥٦٧٨٩'.indexOf(d)));
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { CompaniesService } from './companies.service';
|
||||||
|
import { AuthType, Public } from '../common/decorators/auth.decorators';
|
||||||
|
import {
|
||||||
|
CreateBankAccountDto,
|
||||||
|
CreateCompanyDto,
|
||||||
|
SetActiveDto,
|
||||||
|
UpdateBankAccountDto,
|
||||||
|
UpdateCompanyDto,
|
||||||
|
} from './dto/company.dto';
|
||||||
|
|
||||||
|
@Controller('companies')
|
||||||
|
export class CompaniesController {
|
||||||
|
constructor(private readonly companiesService: CompaniesService) {}
|
||||||
|
|
||||||
|
@AuthType('any')
|
||||||
|
@Get()
|
||||||
|
findAll(@Query('activeOnly') activeOnly?: string) {
|
||||||
|
return this.companiesService.findAll(
|
||||||
|
activeOnly === 'true' || activeOnly === '1',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get('public')
|
||||||
|
findPublic() {
|
||||||
|
return this.companiesService.findAll(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@AuthType('any')
|
||||||
|
@Get(':id')
|
||||||
|
findOne(@Param('id') id: string) {
|
||||||
|
return this.companiesService.findOne(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@AuthType('admin')
|
||||||
|
@Post()
|
||||||
|
create(@Body() dto: CreateCompanyDto) {
|
||||||
|
return this.companiesService.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@AuthType('admin')
|
||||||
|
@Patch(':id')
|
||||||
|
update(@Param('id') id: string, @Body() dto: UpdateCompanyDto) {
|
||||||
|
return this.companiesService.update(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@AuthType('admin')
|
||||||
|
@Patch(':id/active')
|
||||||
|
setActive(@Param('id') id: string, @Body() dto: SetActiveDto) {
|
||||||
|
return this.companiesService.setActive(id, dto.isActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
@AuthType('any')
|
||||||
|
@Get(':companyId/bank-accounts')
|
||||||
|
listBankAccounts(@Param('companyId') companyId: string) {
|
||||||
|
return this.companiesService.listBankAccounts(companyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@AuthType('admin')
|
||||||
|
@Post(':companyId/bank-accounts')
|
||||||
|
createBankAccount(
|
||||||
|
@Param('companyId') companyId: string,
|
||||||
|
@Body() dto: CreateBankAccountDto,
|
||||||
|
) {
|
||||||
|
return this.companiesService.createBankAccount(companyId, dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('bank-accounts')
|
||||||
|
export class BankAccountsController {
|
||||||
|
constructor(private readonly companiesService: CompaniesService) {}
|
||||||
|
|
||||||
|
@AuthType('admin')
|
||||||
|
@Patch(':id')
|
||||||
|
update(@Param('id') id: string, @Body() dto: UpdateBankAccountDto) {
|
||||||
|
return this.companiesService.updateBankAccount(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@AuthType('admin')
|
||||||
|
@Patch(':id/active')
|
||||||
|
setActive(@Param('id') id: string, @Body() dto: SetActiveDto) {
|
||||||
|
return this.companiesService.setBankAccountActive(id, dto.isActive);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
BankAccountsController,
|
||||||
|
CompaniesController,
|
||||||
|
} from './companies.controller';
|
||||||
|
import { CompaniesService } from './companies.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [CompaniesController, BankAccountsController],
|
||||||
|
providers: [CompaniesService],
|
||||||
|
exports: [CompaniesService],
|
||||||
|
})
|
||||||
|
export class CompaniesModule {}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
ConflictException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import {
|
||||||
|
CreateBankAccountDto,
|
||||||
|
CreateCompanyDto,
|
||||||
|
UpdateBankAccountDto,
|
||||||
|
UpdateCompanyDto,
|
||||||
|
} from './dto/company.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CompaniesService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async findAll(activeOnly = false) {
|
||||||
|
const companies = await this.prisma.company.findMany({
|
||||||
|
where: activeOnly ? { isActive: true } : undefined,
|
||||||
|
orderBy: [{ displayOrder: 'asc' }, { name: 'asc' }],
|
||||||
|
include: {
|
||||||
|
_count: { select: { catalogs: true, products: true, bankAccounts: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return companies.map((c) => this.mapCompany(c));
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(id: string) {
|
||||||
|
const company = await this.prisma.company.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
bankAccounts: { orderBy: { displayOrder: 'asc' } },
|
||||||
|
_count: { select: { catalogs: true, products: true, bankAccounts: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!company) throw new NotFoundException('شرکت یافت نشد');
|
||||||
|
return {
|
||||||
|
...this.mapCompany(company),
|
||||||
|
bankAccounts: company.bankAccounts.map((b) => this.mapBankAccount(b)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateCompanyDto) {
|
||||||
|
try {
|
||||||
|
const company = await this.prisma.company.create({
|
||||||
|
data: {
|
||||||
|
name: dto.name.trim(),
|
||||||
|
slug: dto.slug.trim(),
|
||||||
|
description: dto.description?.trim() || null,
|
||||||
|
logoUrl: dto.logoUrl?.trim() || null,
|
||||||
|
displayOrder: dto.displayOrder ?? 0,
|
||||||
|
maxMeteragePerInvoice: dto.maxMeteragePerInvoice ?? 5000,
|
||||||
|
cashInvoiceValidityHours: dto.cashInvoiceValidityHours ?? 8,
|
||||||
|
cashInvoiceValidUntilHour: dto.cashInvoiceValidUntilHour ?? null,
|
||||||
|
isActive: dto.isActive ?? true,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
_count: { select: { catalogs: true, products: true, bankAccounts: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.mapCompany(company);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||||
|
throw new ConflictException('اسلاگ شرکت تکراری است');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateCompanyDto) {
|
||||||
|
await this.ensureCompany(id);
|
||||||
|
try {
|
||||||
|
const company = await this.prisma.company.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
name: dto.name?.trim(),
|
||||||
|
slug: dto.slug?.trim(),
|
||||||
|
description:
|
||||||
|
dto.description === undefined
|
||||||
|
? undefined
|
||||||
|
: dto.description?.trim() || null,
|
||||||
|
logoUrl:
|
||||||
|
dto.logoUrl === undefined ? undefined : dto.logoUrl?.trim() || null,
|
||||||
|
displayOrder: dto.displayOrder,
|
||||||
|
maxMeteragePerInvoice: dto.maxMeteragePerInvoice,
|
||||||
|
cashInvoiceValidityHours: dto.cashInvoiceValidityHours,
|
||||||
|
cashInvoiceValidUntilHour: dto.cashInvoiceValidUntilHour,
|
||||||
|
isActive: dto.isActive,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
_count: { select: { catalogs: true, products: true, bankAccounts: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.mapCompany(company);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||||
|
throw new ConflictException('اسلاگ شرکت تکراری است');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setActive(id: string, isActive: boolean) {
|
||||||
|
await this.ensureCompany(id);
|
||||||
|
const company = await this.prisma.company.update({
|
||||||
|
where: { id },
|
||||||
|
data: { isActive },
|
||||||
|
include: {
|
||||||
|
_count: { select: { catalogs: true, products: true, bankAccounts: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.mapCompany(company);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listBankAccounts(companyId: string) {
|
||||||
|
await this.ensureCompany(companyId);
|
||||||
|
const accounts = await this.prisma.bankAccount.findMany({
|
||||||
|
where: { companyId },
|
||||||
|
orderBy: { displayOrder: 'asc' },
|
||||||
|
});
|
||||||
|
return accounts.map((a) => this.mapBankAccount(a));
|
||||||
|
}
|
||||||
|
|
||||||
|
async createBankAccount(companyId: string, dto: CreateBankAccountDto) {
|
||||||
|
await this.ensureCompany(companyId);
|
||||||
|
const account = await this.prisma.bankAccount.create({
|
||||||
|
data: {
|
||||||
|
companyId,
|
||||||
|
bankName: dto.bankName.trim(),
|
||||||
|
accountHolder: dto.accountHolder.trim(),
|
||||||
|
accountNumber: dto.accountNumber.trim(),
|
||||||
|
cardNumber: dto.cardNumber.trim(),
|
||||||
|
iban: dto.iban.trim(),
|
||||||
|
notes: dto.notes?.trim() || null,
|
||||||
|
displayOrder: dto.displayOrder ?? 0,
|
||||||
|
isActive: dto.isActive ?? true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.mapBankAccount(account);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateBankAccount(id: string, dto: UpdateBankAccountDto) {
|
||||||
|
await this.ensureBankAccount(id);
|
||||||
|
const account = await this.prisma.bankAccount.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
bankName: dto.bankName?.trim(),
|
||||||
|
accountHolder: dto.accountHolder?.trim(),
|
||||||
|
accountNumber: dto.accountNumber?.trim(),
|
||||||
|
cardNumber: dto.cardNumber?.trim(),
|
||||||
|
iban: dto.iban?.trim(),
|
||||||
|
notes:
|
||||||
|
dto.notes === undefined ? undefined : dto.notes?.trim() || null,
|
||||||
|
displayOrder: dto.displayOrder,
|
||||||
|
isActive: dto.isActive,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.mapBankAccount(account);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setBankAccountActive(id: string, isActive: boolean) {
|
||||||
|
await this.ensureBankAccount(id);
|
||||||
|
const account = await this.prisma.bankAccount.update({
|
||||||
|
where: { id },
|
||||||
|
data: { isActive },
|
||||||
|
});
|
||||||
|
return this.mapBankAccount(account);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureCompany(id: string) {
|
||||||
|
const company = await this.prisma.company.findUnique({ where: { id } });
|
||||||
|
if (!company) throw new NotFoundException('شرکت یافت نشد');
|
||||||
|
return company;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureBankAccount(id: string) {
|
||||||
|
const account = await this.prisma.bankAccount.findUnique({ where: { id } });
|
||||||
|
if (!account) throw new NotFoundException('حساب بانکی یافت نشد');
|
||||||
|
return account;
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapCompany(
|
||||||
|
company: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
description: string | null;
|
||||||
|
logoUrl: string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
displayOrder: number;
|
||||||
|
maxMeteragePerInvoice: number;
|
||||||
|
cashInvoiceValidityHours: number;
|
||||||
|
cashInvoiceValidUntilHour: number | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
_count?: { catalogs: number; products: number; bankAccounts: number };
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
id: company.id,
|
||||||
|
name: company.name,
|
||||||
|
slug: company.slug,
|
||||||
|
description: company.description ?? '',
|
||||||
|
logoUrl: company.logoUrl,
|
||||||
|
logo: company.logoUrl ?? undefined,
|
||||||
|
isActive: company.isActive,
|
||||||
|
displayOrder: company.displayOrder,
|
||||||
|
maxMeteragePerInvoice: company.maxMeteragePerInvoice,
|
||||||
|
cashInvoiceValidityHours: company.cashInvoiceValidityHours,
|
||||||
|
cashInvoiceValidUntilHour: company.cashInvoiceValidUntilHour ?? undefined,
|
||||||
|
catalogCount: company._count?.catalogs ?? 0,
|
||||||
|
productCount: company._count?.products ?? 0,
|
||||||
|
bankAccountCount: company._count?.bankAccounts ?? 0,
|
||||||
|
createdAt: company.createdAt.toISOString(),
|
||||||
|
updatedAt: company.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapBankAccount(account: {
|
||||||
|
id: string;
|
||||||
|
companyId: string;
|
||||||
|
bankName: string;
|
||||||
|
accountHolder: string;
|
||||||
|
accountNumber: string;
|
||||||
|
cardNumber: string;
|
||||||
|
iban: string;
|
||||||
|
notes: string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
displayOrder: number;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
id: account.id,
|
||||||
|
companyId: account.companyId,
|
||||||
|
bankName: account.bankName,
|
||||||
|
accountHolder: account.accountHolder,
|
||||||
|
accountNumber: account.accountNumber,
|
||||||
|
cardNumber: account.cardNumber,
|
||||||
|
iban: account.iban,
|
||||||
|
notes: account.notes ?? undefined,
|
||||||
|
isActive: account.isActive,
|
||||||
|
displayOrder: account.displayOrder,
|
||||||
|
createdAt: account.createdAt.toISOString(),
|
||||||
|
updatedAt: account.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Min,
|
||||||
|
MinLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
export class CreateCompanyDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
slug!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
logoUrl?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
displayOrder?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
maxMeteragePerInvoice?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
cashInvoiceValidityHours?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
cashInvoiceValidUntilHour?: number | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateCompanyDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
slug?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
logoUrl?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
displayOrder?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
maxMeteragePerInvoice?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
cashInvoiceValidityHours?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
cashInvoiceValidUntilHour?: number | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SetActiveDto {
|
||||||
|
@IsBoolean()
|
||||||
|
isActive!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateBankAccountDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
bankName!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
accountHolder!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
accountNumber!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
cardNumber!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
iban!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
displayOrder?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateBankAccountDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
bankName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
accountHolder?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
accountNumber?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
cardNumber?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
iban?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
displayOrder?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { CustomersService } from './customers.service';
|
||||||
|
import { AuthType } from '../common/decorators/auth.decorators';
|
||||||
|
import {
|
||||||
|
CreateCustomerDto,
|
||||||
|
SetCustomerActiveDto,
|
||||||
|
UpdateCustomerDto,
|
||||||
|
} from './dto/customer.dto';
|
||||||
|
|
||||||
|
@Controller('admin/customers')
|
||||||
|
@AuthType('admin')
|
||||||
|
export class CustomersController {
|
||||||
|
constructor(private readonly customersService: CustomersService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
findAll(@Query('q') q?: string) {
|
||||||
|
return this.customersService.findAll(q);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
findOne(@Param('id') id: string) {
|
||||||
|
return this.customersService.findOne(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
create(@Body() dto: CreateCustomerDto) {
|
||||||
|
return this.customersService.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
update(@Param('id') id: string, @Body() dto: UpdateCustomerDto) {
|
||||||
|
return this.customersService.update(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/active')
|
||||||
|
setActive(@Param('id') id: string, @Body() dto: SetCustomerActiveDto) {
|
||||||
|
return this.customersService.setActive(id, dto.isActive);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { CustomersController } from './customers.controller';
|
||||||
|
import { CustomersService } from './customers.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [CustomersController],
|
||||||
|
providers: [CustomersService],
|
||||||
|
exports: [CustomersService],
|
||||||
|
})
|
||||||
|
export class CustomersModule {}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { CustomerType, Prisma } from '@prisma/client';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { CreateCustomerDto, UpdateCustomerDto } from './dto/customer.dto';
|
||||||
|
import {
|
||||||
|
isValidPhone,
|
||||||
|
normalizePhone,
|
||||||
|
toEnglishDigits,
|
||||||
|
} from '../common/utils/phone';
|
||||||
|
import { joinFullName, splitFullName } from '../common/utils/name';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CustomersService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async findAll(q?: string) {
|
||||||
|
const customers = await this.prisma.customer.findMany({
|
||||||
|
where: q
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ firstName: { contains: q, mode: 'insensitive' } },
|
||||||
|
{ lastName: { contains: q, mode: 'insensitive' } },
|
||||||
|
{ phone: { contains: toEnglishDigits(q) } },
|
||||||
|
{ nationalId: { contains: toEnglishDigits(q) } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: {
|
||||||
|
company: { select: { name: true } },
|
||||||
|
orders: { select: { totalMeterage: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return customers.map((c) => this.map(c));
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(id: string) {
|
||||||
|
const customer = await this.prisma.customer.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
company: { select: { name: true } },
|
||||||
|
orders: { select: { totalMeterage: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!customer) throw new NotFoundException('مشتری یافت نشد');
|
||||||
|
return this.map(customer);
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateCustomerDto) {
|
||||||
|
const phone = normalizePhone(toEnglishDigits(dto.phone));
|
||||||
|
if (!isValidPhone(phone)) {
|
||||||
|
throw new BadRequestException('شماره موبایل نامعتبر است');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { firstName, lastName } = this.resolveNames(dto);
|
||||||
|
if (dto.companyId) {
|
||||||
|
const company = await this.prisma.company.findUnique({
|
||||||
|
where: { id: dto.companyId },
|
||||||
|
});
|
||||||
|
if (!company) throw new NotFoundException('شرکت یافت نشد');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const customer = await this.prisma.customer.create({
|
||||||
|
data: {
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
phone,
|
||||||
|
nationalId: dto.nationalId?.trim() || null,
|
||||||
|
customerType: dto.customerType ?? CustomerType.wholesale,
|
||||||
|
companyId: dto.companyId || null,
|
||||||
|
isActive: dto.isActive ?? true,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
company: { select: { name: true } },
|
||||||
|
orders: { select: { totalMeterage: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.map(customer);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||||
|
throw new ConflictException('این شماره قبلاً ثبت شده است');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateCustomerDto) {
|
||||||
|
await this.ensure(id);
|
||||||
|
const phone =
|
||||||
|
dto.phone !== undefined
|
||||||
|
? normalizePhone(toEnglishDigits(dto.phone))
|
||||||
|
: undefined;
|
||||||
|
if (phone !== undefined && !isValidPhone(phone)) {
|
||||||
|
throw new BadRequestException('شماره موبایل نامعتبر است');
|
||||||
|
}
|
||||||
|
|
||||||
|
let firstName: string | undefined;
|
||||||
|
let lastName: string | undefined;
|
||||||
|
if (dto.name !== undefined || dto.firstName !== undefined || dto.lastName !== undefined) {
|
||||||
|
const names = this.resolveNames(dto);
|
||||||
|
firstName = names.firstName;
|
||||||
|
lastName = names.lastName;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const customer = await this.prisma.customer.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
phone,
|
||||||
|
nationalId:
|
||||||
|
dto.nationalId === undefined
|
||||||
|
? undefined
|
||||||
|
: dto.nationalId?.trim() || null,
|
||||||
|
customerType: dto.customerType,
|
||||||
|
companyId:
|
||||||
|
dto.companyId === undefined ? undefined : dto.companyId || null,
|
||||||
|
isActive: dto.isActive,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
company: { select: { name: true } },
|
||||||
|
orders: { select: { totalMeterage: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.map(customer);
|
||||||
|
} 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 customer = await this.prisma.customer.update({
|
||||||
|
where: { id },
|
||||||
|
data: { isActive },
|
||||||
|
include: {
|
||||||
|
company: { select: { name: true } },
|
||||||
|
orders: { select: { totalMeterage: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.map(customer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolveNames(dto: {
|
||||||
|
name?: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
}) {
|
||||||
|
if (dto.name?.trim()) {
|
||||||
|
return splitFullName(dto.name);
|
||||||
|
}
|
||||||
|
const firstName = dto.firstName?.trim();
|
||||||
|
const lastName = dto.lastName?.trim() ?? '';
|
||||||
|
if (!firstName) {
|
||||||
|
throw new BadRequestException('نام مشتری الزامی است');
|
||||||
|
}
|
||||||
|
return { firstName, lastName };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensure(id: string) {
|
||||||
|
const customer = await this.prisma.customer.findUnique({ where: { id } });
|
||||||
|
if (!customer) throw new NotFoundException('مشتری یافت نشد');
|
||||||
|
return customer;
|
||||||
|
}
|
||||||
|
|
||||||
|
private map(customer: {
|
||||||
|
id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
phone: string;
|
||||||
|
nationalId: string | null;
|
||||||
|
customerType: CustomerType;
|
||||||
|
companyId: string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
company: { name: string } | null;
|
||||||
|
orders: { totalMeterage: number }[];
|
||||||
|
}) {
|
||||||
|
const totalMeterage = customer.orders.reduce(
|
||||||
|
(sum, o) => sum + o.totalMeterage,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const name = joinFullName(customer.firstName, customer.lastName);
|
||||||
|
return {
|
||||||
|
id: customer.id,
|
||||||
|
firstName: customer.firstName,
|
||||||
|
lastName: customer.lastName,
|
||||||
|
name,
|
||||||
|
phone: customer.phone,
|
||||||
|
nationalId: customer.nationalId ?? '',
|
||||||
|
customerType: customer.customerType,
|
||||||
|
companyId: customer.companyId,
|
||||||
|
companyName: customer.company?.name ?? '',
|
||||||
|
orderCount: customer.orders.length,
|
||||||
|
totalMeterage,
|
||||||
|
isActive: customer.isActive,
|
||||||
|
registeredAt: customer.createdAt.toISOString(),
|
||||||
|
createdAt: customer.createdAt.toISOString(),
|
||||||
|
updatedAt: customer.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsEnum,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
MinLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { CustomerType } from '@prisma/client';
|
||||||
|
|
||||||
|
export class CreateCustomerDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
firstName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
lastName?: string;
|
||||||
|
|
||||||
|
/** Admin UI single name field — split into first/last */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(10)
|
||||||
|
phone!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
nationalId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(CustomerType)
|
||||||
|
customerType?: CustomerType;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
companyId?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateCustomerDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
firstName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
lastName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
phone?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
nationalId?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(CustomerType)
|
||||||
|
customerType?: CustomerType;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
companyId?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SetCustomerActiveDto {
|
||||||
|
@IsBoolean()
|
||||||
|
isActive!: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
import { DashboardService } from './dashboard.service';
|
||||||
|
import { AuthType } from '../common/decorators/auth.decorators';
|
||||||
|
|
||||||
|
@Controller('admin')
|
||||||
|
@AuthType('admin')
|
||||||
|
export class DashboardController {
|
||||||
|
constructor(private readonly dashboardService: DashboardService) {}
|
||||||
|
|
||||||
|
@Get('dashboard')
|
||||||
|
getDashboard() {
|
||||||
|
return this.dashboardService.getDashboard();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('reports')
|
||||||
|
getReports() {
|
||||||
|
return this.dashboardService.getReports();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { DashboardController } from './dashboard.controller';
|
||||||
|
import { DashboardService } from './dashboard.service';
|
||||||
|
import { OrdersModule } from '../orders/orders.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [OrdersModule],
|
||||||
|
controllers: [DashboardController],
|
||||||
|
providers: [DashboardService],
|
||||||
|
})
|
||||||
|
export class DashboardModule {}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import {
|
||||||
|
ADMIN_STATUS_MAP,
|
||||||
|
ORDER_STATUS_LABELS,
|
||||||
|
toAdminStatus,
|
||||||
|
} from '../common/utils/order-status';
|
||||||
|
import { OrdersService } from '../orders/orders.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DashboardService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly ordersService: OrdersService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getDashboard() {
|
||||||
|
const now = new Date();
|
||||||
|
const startOfToday = new Date(now);
|
||||||
|
startOfToday.setHours(0, 0, 0, 0);
|
||||||
|
const startOfYesterday = new Date(startOfToday);
|
||||||
|
startOfYesterday.setDate(startOfYesterday.getDate() - 1);
|
||||||
|
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
|
const startOfChart = new Date(startOfToday);
|
||||||
|
startOfChart.setDate(startOfChart.getDate() - 27);
|
||||||
|
|
||||||
|
const [
|
||||||
|
newOrders,
|
||||||
|
pendingReview,
|
||||||
|
pendingCheck,
|
||||||
|
finalApproval,
|
||||||
|
todayOrders,
|
||||||
|
yesterdayOrders,
|
||||||
|
monthOrders,
|
||||||
|
chartOrders,
|
||||||
|
recentOrders,
|
||||||
|
checks,
|
||||||
|
notifications,
|
||||||
|
messages,
|
||||||
|
] = await Promise.all([
|
||||||
|
this.prisma.order.count({
|
||||||
|
where: { createdAt: { gte: startOfToday } },
|
||||||
|
}),
|
||||||
|
this.prisma.order.count({
|
||||||
|
where: { status: { in: ADMIN_STATUS_MAP.pending_review } },
|
||||||
|
}),
|
||||||
|
this.prisma.order.count({
|
||||||
|
where: { status: { in: ADMIN_STATUS_MAP.pending_check } },
|
||||||
|
}),
|
||||||
|
this.prisma.order.count({
|
||||||
|
where: { status: { in: ADMIN_STATUS_MAP.final_approval } },
|
||||||
|
}),
|
||||||
|
this.prisma.order.findMany({
|
||||||
|
where: { createdAt: { gte: startOfToday } },
|
||||||
|
select: { finalAmount: true, totalAmount: true },
|
||||||
|
}),
|
||||||
|
this.prisma.order.findMany({
|
||||||
|
where: {
|
||||||
|
createdAt: { gte: startOfYesterday, lt: startOfToday },
|
||||||
|
},
|
||||||
|
select: { finalAmount: true, totalAmount: true },
|
||||||
|
}),
|
||||||
|
this.prisma.order.findMany({
|
||||||
|
where: { createdAt: { gte: startOfMonth } },
|
||||||
|
select: { finalAmount: true, totalAmount: true },
|
||||||
|
}),
|
||||||
|
this.prisma.order.findMany({
|
||||||
|
where: { createdAt: { gte: startOfChart } },
|
||||||
|
select: {
|
||||||
|
createdAt: true,
|
||||||
|
finalAmount: true,
|
||||||
|
totalAmount: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.order.findMany({
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 5,
|
||||||
|
include: {
|
||||||
|
items: true,
|
||||||
|
checks: { orderBy: { row: 'asc' } },
|
||||||
|
cashReceipt: true,
|
||||||
|
statusHistory: { orderBy: { changedAt: 'asc' } },
|
||||||
|
shipment: true,
|
||||||
|
user: true,
|
||||||
|
company: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.check.findMany({
|
||||||
|
where: {
|
||||||
|
status: { not: 'rejected' },
|
||||||
|
dueDate: {
|
||||||
|
gte: startOfToday,
|
||||||
|
lte: new Date(startOfToday.getTime() + 30 * 24 * 60 * 60 * 1000),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: { dueDate: true, amount: true },
|
||||||
|
}),
|
||||||
|
this.prisma.adminNotification.findMany({
|
||||||
|
orderBy: { sentAt: 'desc' },
|
||||||
|
take: 20,
|
||||||
|
}),
|
||||||
|
this.prisma.internalMessage.findMany({
|
||||||
|
orderBy: { sentAt: 'desc' },
|
||||||
|
take: 20,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const sumAmounts = (
|
||||||
|
rows: { finalAmount: number | null; totalAmount: number | null }[],
|
||||||
|
) =>
|
||||||
|
rows.reduce(
|
||||||
|
(s, o) => s + (o.finalAmount ?? o.totalAmount ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const todaySales = sumAmounts(todayOrders);
|
||||||
|
const yesterdaySales = sumAmounts(yesterdayOrders);
|
||||||
|
const monthSales = sumAmounts(monthOrders);
|
||||||
|
const todaySalesGrowth =
|
||||||
|
yesterdaySales > 0
|
||||||
|
? Math.round(((todaySales - yesterdaySales) / yesterdaySales) * 100)
|
||||||
|
: todaySales > 0
|
||||||
|
? 100
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const salesByDay = Array.from({ length: 28 }, () => 0);
|
||||||
|
for (const order of chartOrders) {
|
||||||
|
const dayIndex = Math.floor(
|
||||||
|
(order.createdAt.getTime() - startOfChart.getTime()) /
|
||||||
|
(24 * 60 * 60 * 1000),
|
||||||
|
);
|
||||||
|
if (dayIndex >= 0 && dayIndex < 28) {
|
||||||
|
salesByDay[dayIndex]! += order.finalAmount ?? order.totalAmount ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const maxSale = Math.max(...salesByDay, 1);
|
||||||
|
const salesChartData = salesByDay.map((v) =>
|
||||||
|
Number((v / maxSale).toFixed(3)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const checkDeadlines = this.buildCheckDeadlines(checks, startOfToday);
|
||||||
|
|
||||||
|
return {
|
||||||
|
stats: {
|
||||||
|
newOrders,
|
||||||
|
pendingReview,
|
||||||
|
pendingCheck,
|
||||||
|
finalApproval,
|
||||||
|
todaySales,
|
||||||
|
todaySalesGrowth,
|
||||||
|
monthSales,
|
||||||
|
},
|
||||||
|
recentOrders: recentOrders.map((o) =>
|
||||||
|
this.ordersService.mapOrder(o, true),
|
||||||
|
),
|
||||||
|
salesChartData,
|
||||||
|
chartAxisLabels: ['۱', '۷', '۱۴', '۲۱', '۲۸'],
|
||||||
|
checkDeadlines,
|
||||||
|
notifications: notifications.map((n) => ({
|
||||||
|
id: n.id,
|
||||||
|
title: n.title,
|
||||||
|
body: n.body,
|
||||||
|
tone: n.tone,
|
||||||
|
link: n.link,
|
||||||
|
isRead: n.isRead,
|
||||||
|
sentAt: n.sentAt.toISOString(),
|
||||||
|
})),
|
||||||
|
messages: messages.map((m) => ({
|
||||||
|
id: m.id,
|
||||||
|
senderName: m.senderName,
|
||||||
|
subject: m.subject,
|
||||||
|
preview: m.preview,
|
||||||
|
isRead: m.isRead,
|
||||||
|
sentAt: m.sentAt.toISOString(),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getReports() {
|
||||||
|
const orders = await this.prisma.order.findMany({
|
||||||
|
select: {
|
||||||
|
companyName: true,
|
||||||
|
status: true,
|
||||||
|
totalMeterage: true,
|
||||||
|
finalAmount: true,
|
||||||
|
totalAmount: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const byCompany = new Map<
|
||||||
|
string,
|
||||||
|
{ companyName: string; orderCount: number; totalAmount: number; meterage: number }
|
||||||
|
>();
|
||||||
|
const byStatus = new Map<string, number>();
|
||||||
|
|
||||||
|
for (const order of orders) {
|
||||||
|
const company = byCompany.get(order.companyName) ?? {
|
||||||
|
companyName: order.companyName,
|
||||||
|
orderCount: 0,
|
||||||
|
totalAmount: 0,
|
||||||
|
meterage: 0,
|
||||||
|
};
|
||||||
|
company.orderCount += 1;
|
||||||
|
company.totalAmount += order.finalAmount ?? order.totalAmount ?? 0;
|
||||||
|
company.meterage += order.totalMeterage;
|
||||||
|
byCompany.set(order.companyName, company);
|
||||||
|
|
||||||
|
const label =
|
||||||
|
ORDER_STATUS_LABELS[order.status] ?? toAdminStatus(order.status);
|
||||||
|
byStatus.set(label, (byStatus.get(label) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
reportSalesByCompany: Array.from(byCompany.values()).sort(
|
||||||
|
(a, b) => b.totalAmount - a.totalAmount,
|
||||||
|
),
|
||||||
|
reportOrdersByStatus: Array.from(byStatus.entries()).map(
|
||||||
|
([status, count]) => ({ status, count }),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildCheckDeadlines(
|
||||||
|
checks: { dueDate: Date; amount: number }[],
|
||||||
|
startOfToday: Date,
|
||||||
|
) {
|
||||||
|
const buckets = [
|
||||||
|
{ id: 'cd-today', label: 'امروز', tone: 'danger' as const, days: 0 },
|
||||||
|
{ id: 'cd-3', label: '۳ روز آینده', tone: 'warning' as const, days: 3 },
|
||||||
|
{ id: 'cd-7', label: '۷ روز آینده', tone: 'warning' as const, days: 7 },
|
||||||
|
{ id: 'cd-30', label: '۳۰ روز آینده', tone: 'neutral' as const, days: 30 },
|
||||||
|
];
|
||||||
|
|
||||||
|
return buckets.map((bucket) => {
|
||||||
|
const end = new Date(startOfToday);
|
||||||
|
end.setDate(end.getDate() + bucket.days);
|
||||||
|
end.setHours(23, 59, 59, 999);
|
||||||
|
|
||||||
|
const matched = checks.filter((c) => {
|
||||||
|
const d = new Date(c.dueDate);
|
||||||
|
d.setHours(0, 0, 0, 0);
|
||||||
|
if (bucket.days === 0) {
|
||||||
|
return d.getTime() === startOfToday.getTime();
|
||||||
|
}
|
||||||
|
return d >= startOfToday && d <= end;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: bucket.id,
|
||||||
|
label: bucket.label,
|
||||||
|
tone: bucket.tone,
|
||||||
|
count: matched.length,
|
||||||
|
amount: matched.reduce((s, c) => s + c.amount, 0),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const app = await NestFactory.create(AppModule);
|
||||||
|
|
||||||
|
app.setGlobalPrefix('api');
|
||||||
|
app.enableCors({
|
||||||
|
origin: true,
|
||||||
|
credentials: true,
|
||||||
|
});
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
transform: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
transformOptions: { enableImplicitConversion: true },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const swaggerConfig = new DocumentBuilder()
|
||||||
|
.setTitle('Fabric Trade API')
|
||||||
|
.setDescription('API سرور تجارت پارچه')
|
||||||
|
.setVersion('1.0')
|
||||||
|
.addBearerAuth()
|
||||||
|
.build();
|
||||||
|
const document = SwaggerModule.createDocument(app, swaggerConfig);
|
||||||
|
SwaggerModule.setup('api/docs', app, document);
|
||||||
|
|
||||||
|
const port = process.env.PORT ?? 3000;
|
||||||
|
await app.listen(port);
|
||||||
|
console.log(`Fabric Trade API listening on http://localhost:${port}/api`);
|
||||||
|
console.log(`Swagger docs at http://localhost:${port}/api/docs`);
|
||||||
|
}
|
||||||
|
bootstrap();
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
import {
|
||||||
|
ArrayMinSize,
|
||||||
|
IsArray,
|
||||||
|
IsDateString,
|
||||||
|
IsEnum,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Min,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
CheckRegistrationMode,
|
||||||
|
OrderStatus,
|
||||||
|
SettlementType,
|
||||||
|
} from '@prisma/client';
|
||||||
|
|
||||||
|
export class SubmitOrderItemDto {
|
||||||
|
@IsString()
|
||||||
|
productId!: string;
|
||||||
|
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0.001)
|
||||||
|
meterage!: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SubmitOrderCheckDto {
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
amount!: number;
|
||||||
|
|
||||||
|
@IsDateString()
|
||||||
|
dueDate!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
sayadId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
checkNumber?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
ownerNationalId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
ownerFirstName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
ownerLastName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
bankName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
branchName?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
frontImage?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
backImage?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(CheckRegistrationMode)
|
||||||
|
mode?: CheckRegistrationMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SubmitOrderDto {
|
||||||
|
@IsString()
|
||||||
|
companyId!: string;
|
||||||
|
|
||||||
|
@IsEnum(SettlementType)
|
||||||
|
settlementType!: SettlementType;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(CheckRegistrationMode)
|
||||||
|
checkMode?: CheckRegistrationMode;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMinSize(1)
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => SubmitOrderItemDto)
|
||||||
|
items!: SubmitOrderItemDto[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => SubmitOrderCheckDto)
|
||||||
|
checks?: SubmitOrderCheckDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AdminCreateOrderDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
userId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
customerPhone?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
customerName?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
companyId!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(SettlementType)
|
||||||
|
settlementType?: SettlementType;
|
||||||
|
|
||||||
|
/** Alias for admin UI */
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(SettlementType)
|
||||||
|
settlementMethod?: SettlementType;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
meterage?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => SubmitOrderItemDto)
|
||||||
|
items?: SubmitOrderItemDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class OrderItemPricingDto {
|
||||||
|
@IsString()
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
confirmedMeterage?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
unitPrice?: number;
|
||||||
|
|
||||||
|
/** Alias from admin UI */
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
supplyPrice?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
unitDiscount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateOrderPricingDto {
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => OrderItemPricingDto)
|
||||||
|
items!: OrderItemPricingDto[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
shippingCost?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
totalDiscount?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateOrderStatusDto {
|
||||||
|
@IsEnum(OrderStatus)
|
||||||
|
status!: OrderStatus;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
note?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateOrderChecksDto {
|
||||||
|
@IsArray()
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => SubmitOrderCheckDto)
|
||||||
|
checks!: SubmitOrderCheckDto[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(CheckRegistrationMode)
|
||||||
|
checkMode?: CheckRegistrationMode;
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { OrdersService } from './orders.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 {
|
||||||
|
AdminCreateOrderDto,
|
||||||
|
SubmitOrderDto,
|
||||||
|
UpdateOrderChecksDto,
|
||||||
|
UpdateOrderPricingDto,
|
||||||
|
UpdateOrderStatusDto,
|
||||||
|
} from './dto/order.dto';
|
||||||
|
|
||||||
|
@Controller('orders')
|
||||||
|
export class CustomerOrdersController {
|
||||||
|
constructor(private readonly ordersService: OrdersService) {}
|
||||||
|
|
||||||
|
@AuthType('customer')
|
||||||
|
@Get()
|
||||||
|
findMine(@CurrentUser() user: AuthPrincipal) {
|
||||||
|
return this.ordersService.findMyOrders(user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@AuthType('customer')
|
||||||
|
@Get(':id')
|
||||||
|
findOne(
|
||||||
|
@CurrentUser() user: AuthPrincipal,
|
||||||
|
@Param('id') id: string,
|
||||||
|
) {
|
||||||
|
return this.ordersService.findMyOrder(user.id, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@AuthType('customer')
|
||||||
|
@Post()
|
||||||
|
submit(
|
||||||
|
@CurrentUser() user: AuthPrincipal,
|
||||||
|
@Body() dto: SubmitOrderDto,
|
||||||
|
) {
|
||||||
|
return this.ordersService.submitOrder(user.id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@AuthType('customer')
|
||||||
|
@Patch(':id/checks')
|
||||||
|
updateChecks(
|
||||||
|
@CurrentUser() user: AuthPrincipal,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateOrderChecksDto,
|
||||||
|
) {
|
||||||
|
return this.ordersService.updateChecks(user.id, id, dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('admin/orders')
|
||||||
|
@AuthType('admin')
|
||||||
|
export class AdminOrdersController {
|
||||||
|
constructor(private readonly ordersService: OrdersService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
findAll(@Query('status') status?: string, @Query('q') q?: string) {
|
||||||
|
return this.ordersService.findAdminOrders({ status, q });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
findOne(@Param('id') id: string) {
|
||||||
|
return this.ordersService.findAdminOrder(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
create(
|
||||||
|
@CurrentUser() user: AuthPrincipal,
|
||||||
|
@Body() dto: AdminCreateOrderDto,
|
||||||
|
) {
|
||||||
|
return this.ordersService.adminCreate(user.id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/pricing')
|
||||||
|
updatePricing(
|
||||||
|
@CurrentUser() user: AuthPrincipal,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateOrderPricingDto,
|
||||||
|
) {
|
||||||
|
return this.ordersService.updatePricing(id, user.id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/status')
|
||||||
|
updateStatus(
|
||||||
|
@CurrentUser() user: AuthPrincipal,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateOrderStatusDto,
|
||||||
|
) {
|
||||||
|
return this.ordersService.updateStatus(id, user.id, dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
AdminOrdersController,
|
||||||
|
CustomerOrdersController,
|
||||||
|
} from './orders.controller';
|
||||||
|
import { OrdersService } from './orders.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [CustomerOrdersController, AdminOrdersController],
|
||||||
|
providers: [OrdersService],
|
||||||
|
exports: [OrdersService],
|
||||||
|
})
|
||||||
|
export class OrdersModule {}
|
||||||
@@ -0,0 +1,643 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
CheckRegistrationMode,
|
||||||
|
OrderStatus,
|
||||||
|
Prisma,
|
||||||
|
SettlementType,
|
||||||
|
} from '@prisma/client';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import {
|
||||||
|
AdminCreateOrderDto,
|
||||||
|
SubmitOrderCheckDto,
|
||||||
|
SubmitOrderDto,
|
||||||
|
UpdateOrderChecksDto,
|
||||||
|
UpdateOrderPricingDto,
|
||||||
|
UpdateOrderStatusDto,
|
||||||
|
} from './dto/order.dto';
|
||||||
|
import { generateOrderNumber } from '../common/utils/order-number';
|
||||||
|
import { calculateCheckRass } from '../common/utils/check-rass';
|
||||||
|
import { computeCashInvoiceExpiry } from '../common/utils/cash-expiry';
|
||||||
|
import {
|
||||||
|
resolveAdminStatusFilter,
|
||||||
|
toAdminStatus,
|
||||||
|
} from '../common/utils/order-status';
|
||||||
|
import { joinFullName, splitFullName } from '../common/utils/name';
|
||||||
|
import {
|
||||||
|
isValidPhone,
|
||||||
|
normalizePhone,
|
||||||
|
toEnglishDigits,
|
||||||
|
} from '../common/utils/phone';
|
||||||
|
|
||||||
|
const orderInclude = {
|
||||||
|
items: true,
|
||||||
|
checks: { orderBy: { row: 'asc' as const } },
|
||||||
|
cashReceipt: true,
|
||||||
|
statusHistory: { orderBy: { changedAt: 'asc' as const } },
|
||||||
|
shipment: true,
|
||||||
|
user: true,
|
||||||
|
company: true,
|
||||||
|
} satisfies Prisma.OrderInclude;
|
||||||
|
|
||||||
|
type OrderFull = Prisma.OrderGetPayload<{ include: typeof orderInclude }>;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OrdersService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async findMyOrders(userId: string) {
|
||||||
|
const orders = await this.prisma.order.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: orderInclude,
|
||||||
|
});
|
||||||
|
return orders.map((o) => this.mapOrder(o));
|
||||||
|
}
|
||||||
|
|
||||||
|
async findMyOrder(userId: string, id: string) {
|
||||||
|
const order = await this.getOrder(id);
|
||||||
|
if (order.userId !== userId) {
|
||||||
|
throw new ForbiddenException('دسترسی به این سفارش مجاز نیست');
|
||||||
|
}
|
||||||
|
return this.mapOrder(order);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAdminOrders(filters: { status?: string; q?: string }) {
|
||||||
|
const statuses = resolveAdminStatusFilter(filters.status);
|
||||||
|
const q = filters.q?.trim();
|
||||||
|
const orders = await this.prisma.order.findMany({
|
||||||
|
where: {
|
||||||
|
...(statuses ? { status: { in: statuses } } : {}),
|
||||||
|
...(q
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ orderNumber: { contains: q, mode: 'insensitive' } },
|
||||||
|
{ companyName: { contains: q, mode: 'insensitive' } },
|
||||||
|
{
|
||||||
|
user: {
|
||||||
|
OR: [
|
||||||
|
{ firstName: { contains: q, mode: 'insensitive' } },
|
||||||
|
{ lastName: { contains: q, mode: 'insensitive' } },
|
||||||
|
{ phone: { contains: toEnglishDigits(q) } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: orderInclude,
|
||||||
|
});
|
||||||
|
return orders.map((o) => this.mapOrder(o, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAdminOrder(id: string) {
|
||||||
|
const order = await this.getOrder(id);
|
||||||
|
return this.mapOrder(order, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitOrder(userId: string, dto: SubmitOrderDto) {
|
||||||
|
const company = await this.prisma.company.findUnique({
|
||||||
|
where: { id: dto.companyId },
|
||||||
|
});
|
||||||
|
if (!company || !company.isActive) {
|
||||||
|
throw new NotFoundException('شرکت یافت نشد یا غیرفعال است');
|
||||||
|
}
|
||||||
|
|
||||||
|
const productIds = dto.items.map((i) => i.productId);
|
||||||
|
const products = await this.prisma.product.findMany({
|
||||||
|
where: { id: { in: productIds }, companyId: company.id, isActive: true },
|
||||||
|
include: { catalog: true },
|
||||||
|
});
|
||||||
|
if (products.length !== productIds.length) {
|
||||||
|
throw new BadRequestException('یک یا چند محصول نامعتبر است');
|
||||||
|
}
|
||||||
|
const productMap = new Map(products.map((p) => [p.id, p]));
|
||||||
|
|
||||||
|
let totalMeterage = 0;
|
||||||
|
const itemsData = dto.items.map((item) => {
|
||||||
|
const product = productMap.get(item.productId)!;
|
||||||
|
if (item.meterage < product.minMeterage) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`حداقل متراژ برای ${product.name} برابر ${product.minMeterage} است`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const rem =
|
||||||
|
Math.round((item.meterage / product.orderMultiplier) * 1000) / 1000;
|
||||||
|
if (Math.abs(rem - Math.round(rem)) > 1e-6) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`متراژ ${product.name} باید مضرب ${product.orderMultiplier} باشد`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
totalMeterage += item.meterage;
|
||||||
|
return {
|
||||||
|
productId: product.id,
|
||||||
|
catalogCode: product.catalog.code,
|
||||||
|
productName: product.name,
|
||||||
|
productCode: product.code,
|
||||||
|
color: product.color,
|
||||||
|
requestedMeterage: item.meterage,
|
||||||
|
unitPrice: product.basePrice,
|
||||||
|
notes: item.notes?.trim() || null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (totalMeterage > company.maxMeteragePerInvoice) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`حداکثر متراژ مجاز هر فاکتور ${company.maxMeteragePerInvoice} متر است`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const settlementType = dto.settlementType;
|
||||||
|
if (settlementType === SettlementType.check) {
|
||||||
|
if (!dto.checks?.length) {
|
||||||
|
throw new BadRequestException('برای تسویه چکی حداقل یک چک لازم است');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkMode =
|
||||||
|
settlementType === SettlementType.check
|
||||||
|
? (dto.checkMode ?? CheckRegistrationMode.full)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const rass =
|
||||||
|
settlementType === SettlementType.check && dto.checks?.length
|
||||||
|
? calculateCheckRass(
|
||||||
|
dto.checks.map((c) => ({
|
||||||
|
amount: c.amount,
|
||||||
|
dueDate: c.dueDate,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const cashInvoiceExpiresAt =
|
||||||
|
settlementType === SettlementType.cash
|
||||||
|
? computeCashInvoiceExpiry(company)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const orderNumber = await this.nextOrderNumber();
|
||||||
|
|
||||||
|
const order = await this.prisma.$transaction(async (tx) => {
|
||||||
|
const created = await tx.order.create({
|
||||||
|
data: {
|
||||||
|
orderNumber,
|
||||||
|
userId,
|
||||||
|
companyId: company.id,
|
||||||
|
companyName: company.name,
|
||||||
|
settlementType,
|
||||||
|
checkMode,
|
||||||
|
status: OrderStatus.awaiting_review,
|
||||||
|
totalMeterage,
|
||||||
|
notes: dto.notes?.trim() || null,
|
||||||
|
checkRassDays: rass?.rassDays ?? null,
|
||||||
|
checkRassDate: rass?.rassDate ? new Date(rass.rassDate) : null,
|
||||||
|
cashInvoiceExpiresAt,
|
||||||
|
items: { create: itemsData },
|
||||||
|
checks:
|
||||||
|
settlementType === SettlementType.check && dto.checks
|
||||||
|
? {
|
||||||
|
create: dto.checks.map((c, index) =>
|
||||||
|
this.mapCheckCreate(c, index + 1, checkMode!),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
statusHistory: {
|
||||||
|
create: [
|
||||||
|
{
|
||||||
|
fromStatus: null,
|
||||||
|
toStatus: OrderStatus.submitted,
|
||||||
|
changedBy: 'customer',
|
||||||
|
note: 'سفارش ثبت شد',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fromStatus: OrderStatus.submitted,
|
||||||
|
toStatus: OrderStatus.awaiting_review,
|
||||||
|
changedBy: 'customer',
|
||||||
|
note: 'ارسال برای بررسی مدیریت',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: orderInclude,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear cart items for this company after successful submit
|
||||||
|
const cart = await tx.cart.findFirst({ where: { userId } });
|
||||||
|
if (cart) {
|
||||||
|
await tx.cartItem.deleteMany({
|
||||||
|
where: { cartId: cart.id, companyId: company.id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.mapOrder(order);
|
||||||
|
}
|
||||||
|
|
||||||
|
async adminCreate(adminId: string, dto: AdminCreateOrderDto) {
|
||||||
|
const settlementType = dto.settlementType ?? dto.settlementMethod;
|
||||||
|
if (!settlementType) {
|
||||||
|
throw new BadRequestException('نوع تسویه الزامی است');
|
||||||
|
}
|
||||||
|
|
||||||
|
const company = await this.prisma.company.findUnique({
|
||||||
|
where: { id: dto.companyId },
|
||||||
|
});
|
||||||
|
if (!company) throw new NotFoundException('شرکت یافت نشد');
|
||||||
|
|
||||||
|
let userId = dto.userId;
|
||||||
|
if (!userId && dto.customerPhone) {
|
||||||
|
const phone = normalizePhone(toEnglishDigits(dto.customerPhone));
|
||||||
|
if (!isValidPhone(phone)) {
|
||||||
|
throw new BadRequestException('شماره موبایل نامعتبر است');
|
||||||
|
}
|
||||||
|
let customer = await this.prisma.customer.findUnique({ where: { phone } });
|
||||||
|
if (!customer) {
|
||||||
|
const names = splitFullName(dto.customerName || 'مشتری جدید');
|
||||||
|
customer = await this.prisma.customer.create({
|
||||||
|
data: {
|
||||||
|
phone,
|
||||||
|
firstName: names.firstName,
|
||||||
|
lastName: names.lastName || 'جدید',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
userId = customer.id;
|
||||||
|
}
|
||||||
|
if (!userId) {
|
||||||
|
throw new BadRequestException('شناسه یا شماره مشتری الزامی است');
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderNumber = await this.nextOrderNumber();
|
||||||
|
const totalMeterage = dto.meterage ?? 0;
|
||||||
|
|
||||||
|
const order = await this.prisma.order.create({
|
||||||
|
data: {
|
||||||
|
orderNumber,
|
||||||
|
userId,
|
||||||
|
companyId: company.id,
|
||||||
|
companyName: company.name,
|
||||||
|
settlementType,
|
||||||
|
status: OrderStatus.awaiting_review,
|
||||||
|
totalMeterage,
|
||||||
|
notes: dto.notes?.trim() || null,
|
||||||
|
statusHistory: {
|
||||||
|
create: {
|
||||||
|
fromStatus: null,
|
||||||
|
toStatus: OrderStatus.awaiting_review,
|
||||||
|
changedBy: `admin:${adminId}`,
|
||||||
|
note: 'سفارش توسط ادمین ثبت شد',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: orderInclude,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.mapOrder(order, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updatePricing(
|
||||||
|
id: string,
|
||||||
|
adminId: string,
|
||||||
|
dto: UpdateOrderPricingDto,
|
||||||
|
) {
|
||||||
|
const order = await this.getOrder(id);
|
||||||
|
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
let totalAmount = 0;
|
||||||
|
let totalMeterage = 0;
|
||||||
|
|
||||||
|
for (const item of dto.items) {
|
||||||
|
const existing = order.items.find((i) => i.id === item.id);
|
||||||
|
if (!existing) {
|
||||||
|
throw new BadRequestException(`آیتم ${item.id} یافت نشد`);
|
||||||
|
}
|
||||||
|
const unitPrice =
|
||||||
|
item.unitPrice !== undefined
|
||||||
|
? item.unitPrice
|
||||||
|
: item.supplyPrice !== undefined
|
||||||
|
? item.supplyPrice
|
||||||
|
: (existing.unitPrice ?? 0);
|
||||||
|
const unitDiscount = item.unitDiscount ?? existing.unitDiscount ?? 0;
|
||||||
|
const confirmed =
|
||||||
|
item.confirmedMeterage ??
|
||||||
|
existing.confirmedMeterage ??
|
||||||
|
existing.requestedMeterage;
|
||||||
|
const lineTotal = Math.max(0, (unitPrice - unitDiscount) * confirmed);
|
||||||
|
totalAmount += lineTotal;
|
||||||
|
totalMeterage += confirmed;
|
||||||
|
|
||||||
|
await tx.orderItem.update({
|
||||||
|
where: { id: item.id },
|
||||||
|
data: {
|
||||||
|
confirmedMeterage: confirmed,
|
||||||
|
unitPrice,
|
||||||
|
unitDiscount,
|
||||||
|
lineTotal,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const shippingCost = dto.shippingCost ?? order.shippingCost ?? 0;
|
||||||
|
const totalDiscount = dto.totalDiscount ?? order.totalDiscount ?? 0;
|
||||||
|
const finalAmount = Math.max(0, totalAmount + shippingCost - totalDiscount);
|
||||||
|
|
||||||
|
const nextStatus =
|
||||||
|
order.status === OrderStatus.awaiting_review ||
|
||||||
|
order.status === OrderStatus.pricing
|
||||||
|
? OrderStatus.pricing
|
||||||
|
: order.status;
|
||||||
|
|
||||||
|
await tx.order.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
totalAmount,
|
||||||
|
totalMeterage,
|
||||||
|
shippingCost,
|
||||||
|
totalDiscount,
|
||||||
|
finalAmount,
|
||||||
|
status: nextStatus,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (nextStatus !== order.status) {
|
||||||
|
await tx.orderStatusHistory.create({
|
||||||
|
data: {
|
||||||
|
orderId: id,
|
||||||
|
fromStatus: order.status,
|
||||||
|
toStatus: nextStatus,
|
||||||
|
changedBy: `admin:${adminId}`,
|
||||||
|
note: dto.note || 'قیمتگذاری انجام شد',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.findAdminOrder(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateStatus(
|
||||||
|
id: string,
|
||||||
|
adminId: string,
|
||||||
|
dto: UpdateOrderStatusDto,
|
||||||
|
) {
|
||||||
|
const order = await this.getOrder(id);
|
||||||
|
if (order.status === dto.status) {
|
||||||
|
return this.mapOrder(order, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
await tx.order.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: dto.status },
|
||||||
|
});
|
||||||
|
await tx.orderStatusHistory.create({
|
||||||
|
data: {
|
||||||
|
orderId: id,
|
||||||
|
fromStatus: order.status,
|
||||||
|
toStatus: dto.status,
|
||||||
|
changedBy: `admin:${adminId}`,
|
||||||
|
note: dto.note || null,
|
||||||
|
reason: dto.reason || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Side effects for common transitions
|
||||||
|
if (
|
||||||
|
dto.status === OrderStatus.cash_invoice_issued ||
|
||||||
|
dto.status === OrderStatus.awaiting_cash_payment
|
||||||
|
) {
|
||||||
|
if (!order.cashInvoiceExpiresAt) {
|
||||||
|
await tx.order.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
cashInvoiceExpiresAt: computeCashInvoiceExpiry(order.company),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.findAdminOrder(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateChecks(userId: string, id: string, dto: UpdateOrderChecksDto) {
|
||||||
|
const order = await this.getOrder(id);
|
||||||
|
if (order.userId !== userId) {
|
||||||
|
throw new ForbiddenException('دسترسی به این سفارش مجاز نیست');
|
||||||
|
}
|
||||||
|
if (order.settlementType !== SettlementType.check) {
|
||||||
|
throw new BadRequestException('این سفارش تسویه چکی نیست');
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkMode = dto.checkMode ?? order.checkMode ?? CheckRegistrationMode.full;
|
||||||
|
const rass = calculateCheckRass(
|
||||||
|
dto.checks.map((c) => ({ amount: c.amount, dueDate: c.dueDate })),
|
||||||
|
order.createdAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
await tx.check.deleteMany({ where: { orderId: id } });
|
||||||
|
await tx.check.createMany({
|
||||||
|
data: dto.checks.map((c, index) => ({
|
||||||
|
orderId: id,
|
||||||
|
...this.mapCheckCreate(c, index + 1, checkMode),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
await tx.order.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
checkMode,
|
||||||
|
checkRassDays: rass.rassDays,
|
||||||
|
checkRassDate: rass.rassDate ? new Date(rass.rassDate) : null,
|
||||||
|
status:
|
||||||
|
order.status === OrderStatus.check_needs_fix
|
||||||
|
? OrderStatus.awaiting_check_review
|
||||||
|
: order.status,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (order.status === OrderStatus.check_needs_fix) {
|
||||||
|
await tx.orderStatusHistory.create({
|
||||||
|
data: {
|
||||||
|
orderId: id,
|
||||||
|
fromStatus: order.status,
|
||||||
|
toStatus: OrderStatus.awaiting_check_review,
|
||||||
|
changedBy: 'customer',
|
||||||
|
note: 'چکها اصلاح و مجدداً ارسال شد',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.findMyOrder(userId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapCheckCreate(
|
||||||
|
c: SubmitOrderCheckDto,
|
||||||
|
row: number,
|
||||||
|
defaultMode: CheckRegistrationMode,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
row,
|
||||||
|
amount: c.amount,
|
||||||
|
dueDate: new Date(c.dueDate),
|
||||||
|
sayadId: c.sayadId?.trim() || null,
|
||||||
|
checkNumber: c.checkNumber?.trim() || null,
|
||||||
|
ownerNationalId: c.ownerNationalId?.trim() || null,
|
||||||
|
ownerFirstName: c.ownerFirstName?.trim() || null,
|
||||||
|
ownerLastName: c.ownerLastName?.trim() || null,
|
||||||
|
bankName: c.bankName?.trim() || null,
|
||||||
|
branchName: c.branchName?.trim() || null,
|
||||||
|
frontImage: c.frontImage || null,
|
||||||
|
backImage: c.backImage || null,
|
||||||
|
notes: c.notes?.trim() || null,
|
||||||
|
mode: c.mode ?? defaultMode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async nextOrderNumber(): Promise<string> {
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const orderNumber = generateOrderNumber();
|
||||||
|
const exists = await this.prisma.order.findUnique({
|
||||||
|
where: { orderNumber },
|
||||||
|
});
|
||||||
|
if (!exists) return orderNumber;
|
||||||
|
}
|
||||||
|
return `${generateOrderNumber()}-${Date.now().toString().slice(-3)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getOrder(id: string): Promise<OrderFull> {
|
||||||
|
const order = await this.prisma.order.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: orderInclude,
|
||||||
|
});
|
||||||
|
if (!order) throw new NotFoundException('سفارش یافت نشد');
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
|
||||||
|
mapOrder(order: OrderFull, adminView = false) {
|
||||||
|
const customerName = joinFullName(
|
||||||
|
order.user.firstName,
|
||||||
|
order.user.lastName,
|
||||||
|
);
|
||||||
|
const base = {
|
||||||
|
id: order.id,
|
||||||
|
orderNumber: order.orderNumber,
|
||||||
|
userId: order.userId,
|
||||||
|
companyId: order.companyId,
|
||||||
|
companyName: order.companyName,
|
||||||
|
settlementType: order.settlementType,
|
||||||
|
settlementMethod: order.settlementType,
|
||||||
|
checkMode: order.checkMode ?? undefined,
|
||||||
|
status: order.status,
|
||||||
|
adminStatus: toAdminStatus(order.status),
|
||||||
|
totalMeterage: order.totalMeterage,
|
||||||
|
meterage: order.totalMeterage,
|
||||||
|
totalAmount: order.totalAmount,
|
||||||
|
shippingCost: order.shippingCost,
|
||||||
|
totalDiscount: order.totalDiscount,
|
||||||
|
finalAmount: order.finalAmount,
|
||||||
|
notes: order.notes ?? undefined,
|
||||||
|
checkRassDays: order.checkRassDays ?? undefined,
|
||||||
|
checkRassDate: order.checkRassDate?.toISOString(),
|
||||||
|
cashInvoiceExpiresAt: order.cashInvoiceExpiresAt?.toISOString(),
|
||||||
|
createdAt: order.createdAt.toISOString(),
|
||||||
|
updatedAt: order.updatedAt.toISOString(),
|
||||||
|
customerName,
|
||||||
|
customerPhone: order.user.phone,
|
||||||
|
customerNationalId: order.user.nationalId ?? undefined,
|
||||||
|
customerType: order.user.customerType,
|
||||||
|
items: order.items.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
productId: item.productId ?? undefined,
|
||||||
|
catalogCode: item.catalogCode,
|
||||||
|
productName: item.productName,
|
||||||
|
productCode: item.productCode ?? undefined,
|
||||||
|
color: item.color,
|
||||||
|
requestedMeterage: item.requestedMeterage,
|
||||||
|
confirmedMeterage: item.confirmedMeterage,
|
||||||
|
unitPrice: item.unitPrice,
|
||||||
|
supplyPrice: item.unitPrice,
|
||||||
|
unitDiscount: item.unitDiscount ?? 0,
|
||||||
|
discount: item.unitDiscount ?? 0,
|
||||||
|
lineTotal: item.lineTotal,
|
||||||
|
totalAmount: item.lineTotal,
|
||||||
|
notes: item.notes ?? undefined,
|
||||||
|
})),
|
||||||
|
checks: order.checks.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
orderId: c.orderId,
|
||||||
|
row: c.row,
|
||||||
|
amount: c.amount,
|
||||||
|
dueDate: c.dueDate.toISOString(),
|
||||||
|
sayadId: c.sayadId ?? undefined,
|
||||||
|
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(),
|
||||||
|
})),
|
||||||
|
cashReceipt: order.cashReceipt
|
||||||
|
? {
|
||||||
|
id: order.cashReceipt.id,
|
||||||
|
amount: order.cashReceipt.amount,
|
||||||
|
paidAmount: order.cashReceipt.paidAmount,
|
||||||
|
transferDate: order.cashReceipt.transferDate?.toISOString() ?? null,
|
||||||
|
transferTime: order.cashReceipt.transferTime ?? undefined,
|
||||||
|
trackingNumber: order.cashReceipt.trackingNumber ?? undefined,
|
||||||
|
trackingCode: order.cashReceipt.trackingNumber ?? undefined,
|
||||||
|
destinationAccountId:
|
||||||
|
order.cashReceipt.destinationAccountId ?? undefined,
|
||||||
|
receiptImage: order.cashReceipt.receiptImage ?? undefined,
|
||||||
|
notes: order.cashReceipt.notes ?? undefined,
|
||||||
|
expiresAt:
|
||||||
|
order.cashReceipt.expiresAt?.toISOString() ??
|
||||||
|
order.cashInvoiceExpiresAt?.toISOString(),
|
||||||
|
status: order.cashReceipt.status,
|
||||||
|
submittedAt: order.cashReceipt.submittedAt?.toISOString(),
|
||||||
|
createdAt: order.cashReceipt.createdAt.toISOString(),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
shipment: order.shipment
|
||||||
|
? {
|
||||||
|
id: order.shipment.id,
|
||||||
|
orderId: order.shipment.orderId,
|
||||||
|
carrier: order.shipment.carrier,
|
||||||
|
trackingNumber: order.shipment.trackingNumber ?? '',
|
||||||
|
meterage: order.shipment.meterage,
|
||||||
|
status: order.shipment.status,
|
||||||
|
shippedAt: order.shipment.shippedAt?.toISOString() ?? null,
|
||||||
|
deliveredAt: order.shipment.deliveredAt?.toISOString() ?? null,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
statusHistory: order.statusHistory.map((h) => ({
|
||||||
|
id: h.id,
|
||||||
|
fromStatus: h.fromStatus,
|
||||||
|
toStatus: h.toStatus,
|
||||||
|
changedBy: h.changedBy,
|
||||||
|
note: h.note ?? undefined,
|
||||||
|
reason: h.reason ?? undefined,
|
||||||
|
changedAt: h.changedAt.toISOString(),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (adminView) {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { PrismaService } from './prisma.service';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [PrismaService],
|
||||||
|
exports: [PrismaService],
|
||||||
|
})
|
||||||
|
export class PrismaModule {}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PrismaService
|
||||||
|
extends PrismaClient
|
||||||
|
implements OnModuleInit, OnModuleDestroy
|
||||||
|
{
|
||||||
|
async onModuleInit() {
|
||||||
|
await this.$connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy() {
|
||||||
|
await this.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Min,
|
||||||
|
MinLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
export class CreateProductDto {
|
||||||
|
@IsString()
|
||||||
|
companyId!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
catalogId!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
code!: string;
|
||||||
|
|
||||||
|
/** Alias accepted from admin UI */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
productCode?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
color!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
minMeterage?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0.001)
|
||||||
|
orderMultiplier?: number;
|
||||||
|
|
||||||
|
/** Alias from admin UI */
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0.001)
|
||||||
|
orderStep?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
basePrice?: number | null;
|
||||||
|
|
||||||
|
/** Alias from admin UI */
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
price?: number | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
stock?: number | null;
|
||||||
|
|
||||||
|
/** Alias from admin UI */
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
internalStock?: number | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateProductDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
catalogId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
code?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
productCode?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
color?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
minMeterage?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0.001)
|
||||||
|
orderMultiplier?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0.001)
|
||||||
|
orderStep?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
basePrice?: number | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
price?: number | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
stock?: number | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
internalStock?: number | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SetProductActiveDto {
|
||||||
|
@IsBoolean()
|
||||||
|
isActive!: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ProductsService } from './products.service';
|
||||||
|
import { AuthType } from '../common/decorators/auth.decorators';
|
||||||
|
import {
|
||||||
|
CreateProductDto,
|
||||||
|
SetProductActiveDto,
|
||||||
|
UpdateProductDto,
|
||||||
|
} from './dto/product.dto';
|
||||||
|
|
||||||
|
@Controller('products')
|
||||||
|
export class ProductsController {
|
||||||
|
constructor(private readonly productsService: ProductsService) {}
|
||||||
|
|
||||||
|
@AuthType('any')
|
||||||
|
@Get()
|
||||||
|
findAll(
|
||||||
|
@Query('catalogId') catalogId?: string,
|
||||||
|
@Query('companyId') companyId?: string,
|
||||||
|
@Query('color') color?: string,
|
||||||
|
@Query('activeOnly') activeOnly?: string,
|
||||||
|
) {
|
||||||
|
return this.productsService.findAll({
|
||||||
|
catalogId,
|
||||||
|
companyId,
|
||||||
|
color,
|
||||||
|
activeOnly: activeOnly === 'true' || activeOnly === '1',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@AuthType('any')
|
||||||
|
@Get(':id')
|
||||||
|
findOne(@Param('id') id: string) {
|
||||||
|
return this.productsService.findOne(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@AuthType('admin')
|
||||||
|
@Post()
|
||||||
|
create(@Body() dto: CreateProductDto) {
|
||||||
|
return this.productsService.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@AuthType('admin')
|
||||||
|
@Patch(':id')
|
||||||
|
update(@Param('id') id: string, @Body() dto: UpdateProductDto) {
|
||||||
|
return this.productsService.update(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@AuthType('admin')
|
||||||
|
@Patch(':id/active')
|
||||||
|
setActive(@Param('id') id: string, @Body() dto: SetProductActiveDto) {
|
||||||
|
return this.productsService.setActive(id, dto.isActive);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ProductsController } from './products.controller';
|
||||||
|
import { ProductsService } from './products.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [ProductsController],
|
||||||
|
providers: [ProductsService],
|
||||||
|
exports: [ProductsService],
|
||||||
|
})
|
||||||
|
export class ProductsModule {}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import {
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { CreateProductDto, UpdateProductDto } from './dto/product.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ProductsService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async findAll(filters: {
|
||||||
|
catalogId?: string;
|
||||||
|
companyId?: string;
|
||||||
|
color?: string;
|
||||||
|
activeOnly?: boolean;
|
||||||
|
}) {
|
||||||
|
const products = await this.prisma.product.findMany({
|
||||||
|
where: {
|
||||||
|
...(filters.catalogId ? { catalogId: filters.catalogId } : {}),
|
||||||
|
...(filters.companyId ? { companyId: filters.companyId } : {}),
|
||||||
|
...(filters.color
|
||||||
|
? { color: { contains: filters.color, mode: 'insensitive' } }
|
||||||
|
: {}),
|
||||||
|
...(filters.activeOnly ? { isActive: true } : {}),
|
||||||
|
},
|
||||||
|
orderBy: [{ name: 'asc' }, { color: 'asc' }],
|
||||||
|
include: {
|
||||||
|
company: { select: { name: true } },
|
||||||
|
catalog: { select: { code: true, name: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return products.map((p) => this.map(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(id: string) {
|
||||||
|
const product = await this.prisma.product.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
company: { select: { name: true } },
|
||||||
|
catalog: { select: { code: true, name: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!product) throw new NotFoundException('محصول یافت نشد');
|
||||||
|
return this.map(product);
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateProductDto) {
|
||||||
|
const catalog = await this.prisma.catalog.findUnique({
|
||||||
|
where: { id: dto.catalogId },
|
||||||
|
});
|
||||||
|
if (!catalog) throw new NotFoundException('کاتالوگ یافت نشد');
|
||||||
|
if (catalog.companyId !== dto.companyId) {
|
||||||
|
throw new ConflictException('کاتالوگ متعلق به این شرکت نیست');
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = (dto.code || dto.productCode || '').trim();
|
||||||
|
if (!code) throw new ConflictException('کد محصول الزامی است');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const product = await this.prisma.product.create({
|
||||||
|
data: {
|
||||||
|
companyId: dto.companyId,
|
||||||
|
catalogId: dto.catalogId,
|
||||||
|
name: dto.name.trim(),
|
||||||
|
code,
|
||||||
|
color: dto.color.trim(),
|
||||||
|
description: dto.description?.trim() || null,
|
||||||
|
minMeterage: dto.minMeterage ?? 30,
|
||||||
|
orderMultiplier: dto.orderMultiplier ?? dto.orderStep ?? 10,
|
||||||
|
basePrice: this.pickNumber(dto.basePrice, dto.price),
|
||||||
|
stock: this.pickNumber(dto.stock, dto.internalStock),
|
||||||
|
isActive: dto.isActive ?? true,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
company: { select: { name: true } },
|
||||||
|
catalog: { select: { code: true, name: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.map(product);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
|
||||||
|
throw new ConflictException('کد محصول در این شرکت تکراری است');
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateProductDto) {
|
||||||
|
await this.ensure(id);
|
||||||
|
const code =
|
||||||
|
dto.code !== undefined
|
||||||
|
? dto.code.trim()
|
||||||
|
: dto.productCode !== undefined
|
||||||
|
? dto.productCode.trim()
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const product = await this.prisma.product.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
catalogId: dto.catalogId,
|
||||||
|
name: dto.name?.trim(),
|
||||||
|
code,
|
||||||
|
color: dto.color?.trim(),
|
||||||
|
description:
|
||||||
|
dto.description === undefined
|
||||||
|
? undefined
|
||||||
|
: dto.description?.trim() || null,
|
||||||
|
minMeterage: dto.minMeterage,
|
||||||
|
orderMultiplier:
|
||||||
|
dto.orderMultiplier !== undefined
|
||||||
|
? dto.orderMultiplier
|
||||||
|
: dto.orderStep,
|
||||||
|
basePrice:
|
||||||
|
dto.basePrice !== undefined
|
||||||
|
? dto.basePrice
|
||||||
|
: dto.price !== undefined
|
||||||
|
? dto.price
|
||||||
|
: undefined,
|
||||||
|
stock:
|
||||||
|
dto.stock !== undefined
|
||||||
|
? dto.stock
|
||||||
|
: dto.internalStock !== undefined
|
||||||
|
? dto.internalStock
|
||||||
|
: undefined,
|
||||||
|
isActive: dto.isActive,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
company: { select: { name: true } },
|
||||||
|
catalog: { select: { code: true, name: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.map(product);
|
||||||
|
} 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 product = await this.prisma.product.update({
|
||||||
|
where: { id },
|
||||||
|
data: { isActive },
|
||||||
|
include: {
|
||||||
|
company: { select: { name: true } },
|
||||||
|
catalog: { select: { code: true, name: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.map(product);
|
||||||
|
}
|
||||||
|
|
||||||
|
private pickNumber(
|
||||||
|
primary?: number | null,
|
||||||
|
alias?: number | null,
|
||||||
|
): number | null {
|
||||||
|
if (primary !== undefined) return primary;
|
||||||
|
if (alias !== undefined) return alias;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensure(id: string) {
|
||||||
|
const product = await this.prisma.product.findUnique({ where: { id } });
|
||||||
|
if (!product) throw new NotFoundException('محصول یافت نشد');
|
||||||
|
return product;
|
||||||
|
}
|
||||||
|
|
||||||
|
private map(product: {
|
||||||
|
id: string;
|
||||||
|
companyId: string;
|
||||||
|
catalogId: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
color: string;
|
||||||
|
description: string | null;
|
||||||
|
minMeterage: number;
|
||||||
|
orderMultiplier: number;
|
||||||
|
basePrice: number | null;
|
||||||
|
stock: number | null;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
company: { name: string };
|
||||||
|
catalog: { code: string; name: string };
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
id: product.id,
|
||||||
|
companyId: product.companyId,
|
||||||
|
companyName: product.company.name,
|
||||||
|
catalogId: product.catalogId,
|
||||||
|
catalogCode: product.catalog.code,
|
||||||
|
catalogName: product.catalog.name,
|
||||||
|
name: product.name,
|
||||||
|
code: product.code,
|
||||||
|
productCode: product.code,
|
||||||
|
color: product.color,
|
||||||
|
description: product.description ?? undefined,
|
||||||
|
minMeterage: product.minMeterage,
|
||||||
|
orderMultiplier: product.orderMultiplier,
|
||||||
|
orderStep: product.orderMultiplier,
|
||||||
|
basePrice: product.basePrice,
|
||||||
|
price: product.basePrice,
|
||||||
|
stock: product.stock,
|
||||||
|
internalStock: product.stock ?? 0,
|
||||||
|
isActive: product.isActive,
|
||||||
|
createdAt: product.createdAt.toISOString(),
|
||||||
|
updatedAt: product.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { IsBoolean, IsInt, IsOptional, IsString, Min } from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
export class UpdateSettingsDto {
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
maxMeteragePerInvoice?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
cashInvoiceValidityHours?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
defaultSettlementCheck?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
defaultSettlementCash?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
smsOrderRegistered?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
smsPaymentReminder?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
smsCheckReview?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
smsShippingUpdate?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
supportPhone?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
supportEmail?: string | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Body, Controller, Get, Patch } from '@nestjs/common';
|
||||||
|
import { SettingsService } from './settings.service';
|
||||||
|
import { AuthType } from '../common/decorators/auth.decorators';
|
||||||
|
import { UpdateSettingsDto } from './dto/settings.dto';
|
||||||
|
|
||||||
|
@Controller('admin/settings')
|
||||||
|
@AuthType('admin')
|
||||||
|
export class SettingsController {
|
||||||
|
constructor(private readonly settingsService: SettingsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
get() {
|
||||||
|
return this.settingsService.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch()
|
||||||
|
update(@Body() dto: UpdateSettingsDto) {
|
||||||
|
return this.settingsService.update(dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { SettingsController } from './settings.controller';
|
||||||
|
import { SettingsService } from './settings.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [SettingsController],
|
||||||
|
providers: [SettingsService],
|
||||||
|
exports: [SettingsService],
|
||||||
|
})
|
||||||
|
export class SettingsModule {}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { UpdateSettingsDto } from './dto/settings.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SettingsService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async get() {
|
||||||
|
const settings = await this.ensureDefaults();
|
||||||
|
return this.map(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(dto: UpdateSettingsDto) {
|
||||||
|
await this.ensureDefaults();
|
||||||
|
const settings = await this.prisma.systemSettings.update({
|
||||||
|
where: { id: 'default' },
|
||||||
|
data: {
|
||||||
|
maxMeteragePerInvoice: dto.maxMeteragePerInvoice,
|
||||||
|
cashInvoiceValidityHours: dto.cashInvoiceValidityHours,
|
||||||
|
defaultSettlementCheck: dto.defaultSettlementCheck,
|
||||||
|
defaultSettlementCash: dto.defaultSettlementCash,
|
||||||
|
smsOrderRegistered: dto.smsOrderRegistered,
|
||||||
|
smsPaymentReminder: dto.smsPaymentReminder,
|
||||||
|
smsCheckReview: dto.smsCheckReview,
|
||||||
|
smsShippingUpdate: dto.smsShippingUpdate,
|
||||||
|
supportPhone:
|
||||||
|
dto.supportPhone === undefined
|
||||||
|
? undefined
|
||||||
|
: dto.supportPhone?.trim() || null,
|
||||||
|
supportEmail:
|
||||||
|
dto.supportEmail === undefined
|
||||||
|
? undefined
|
||||||
|
: dto.supportEmail?.trim() || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.map(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureDefaults() {
|
||||||
|
return this.prisma.systemSettings.upsert({
|
||||||
|
where: { id: 'default' },
|
||||||
|
create: { id: 'default' },
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private map(settings: {
|
||||||
|
id: string;
|
||||||
|
maxMeteragePerInvoice: number;
|
||||||
|
cashInvoiceValidityHours: number;
|
||||||
|
defaultSettlementCheck: boolean;
|
||||||
|
defaultSettlementCash: boolean;
|
||||||
|
smsOrderRegistered: boolean;
|
||||||
|
smsPaymentReminder: boolean;
|
||||||
|
smsCheckReview: boolean;
|
||||||
|
smsShippingUpdate: boolean;
|
||||||
|
supportPhone: string | null;
|
||||||
|
supportEmail: string | null;
|
||||||
|
updatedAt: Date;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
id: settings.id,
|
||||||
|
maxMeteragePerInvoice: settings.maxMeteragePerInvoice,
|
||||||
|
cashInvoiceValidityHours: settings.cashInvoiceValidityHours,
|
||||||
|
defaultSettlementCheck: settings.defaultSettlementCheck,
|
||||||
|
defaultSettlementCash: settings.defaultSettlementCash,
|
||||||
|
smsOrderRegistered: settings.smsOrderRegistered,
|
||||||
|
smsPaymentReminder: settings.smsPaymentReminder,
|
||||||
|
smsCheckReview: settings.smsCheckReview,
|
||||||
|
smsShippingUpdate: settings.smsShippingUpdate,
|
||||||
|
supportPhone: settings.supportPhone,
|
||||||
|
supportEmail: settings.supportEmail,
|
||||||
|
updatedAt: settings.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import {
|
||||||
|
IsEnum,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Min,
|
||||||
|
MinLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { ShipmentStatus } from '@prisma/client';
|
||||||
|
|
||||||
|
export class CreateShipmentDto {
|
||||||
|
@IsString()
|
||||||
|
orderId!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
carrier!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
trackingNumber?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
meterage?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(ShipmentStatus)
|
||||||
|
status?: ShipmentStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateShipmentDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
carrier?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
trackingNumber?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
meterage?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(ShipmentStatus)
|
||||||
|
status?: ShipmentStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AdvanceShipmentStatusDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(ShipmentStatus)
|
||||||
|
status?: ShipmentStatus;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
trackingNumber?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ShipmentStatus } from '@prisma/client';
|
||||||
|
import { ShipmentsService } from './shipments.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 {
|
||||||
|
AdvanceShipmentStatusDto,
|
||||||
|
CreateShipmentDto,
|
||||||
|
UpdateShipmentDto,
|
||||||
|
} from './dto/shipment.dto';
|
||||||
|
|
||||||
|
@Controller('admin/shipments')
|
||||||
|
@AuthType('admin')
|
||||||
|
export class ShipmentsController {
|
||||||
|
constructor(private readonly shipmentsService: ShipmentsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
findAll(@Query('status') status?: ShipmentStatus) {
|
||||||
|
return this.shipmentsService.findAll(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
findOne(@Param('id') id: string) {
|
||||||
|
return this.shipmentsService.findOne(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
create(
|
||||||
|
@CurrentUser() user: AuthPrincipal,
|
||||||
|
@Body() dto: CreateShipmentDto,
|
||||||
|
) {
|
||||||
|
return this.shipmentsService.create(user.id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
update(@Param('id') id: string, @Body() dto: UpdateShipmentDto) {
|
||||||
|
return this.shipmentsService.update(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/status')
|
||||||
|
advanceStatus(
|
||||||
|
@CurrentUser() user: AuthPrincipal,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: AdvanceShipmentStatusDto,
|
||||||
|
) {
|
||||||
|
return this.shipmentsService.advanceStatus(id, user.id, dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ShipmentsController } from './shipments.controller';
|
||||||
|
import { ShipmentsService } from './shipments.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [ShipmentsController],
|
||||||
|
providers: [ShipmentsService],
|
||||||
|
exports: [ShipmentsService],
|
||||||
|
})
|
||||||
|
export class ShipmentsModule {}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { OrderStatus, ShipmentStatus } from '@prisma/client';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import {
|
||||||
|
AdvanceShipmentStatusDto,
|
||||||
|
CreateShipmentDto,
|
||||||
|
UpdateShipmentDto,
|
||||||
|
} from './dto/shipment.dto';
|
||||||
|
import { joinFullName } from '../common/utils/name';
|
||||||
|
|
||||||
|
const STATUS_FLOW: ShipmentStatus[] = [
|
||||||
|
ShipmentStatus.preparing,
|
||||||
|
ShipmentStatus.ready,
|
||||||
|
ShipmentStatus.shipped,
|
||||||
|
ShipmentStatus.delivered,
|
||||||
|
];
|
||||||
|
|
||||||
|
const SHIPMENT_TO_ORDER: Record<ShipmentStatus, OrderStatus> = {
|
||||||
|
preparing: OrderStatus.preparing,
|
||||||
|
ready: OrderStatus.ready_to_ship,
|
||||||
|
shipped: OrderStatus.shipped,
|
||||||
|
delivered: OrderStatus.delivered,
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ShipmentsService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async findAll(status?: ShipmentStatus) {
|
||||||
|
const shipments = await this.prisma.shipment.findMany({
|
||||||
|
where: status ? { status } : undefined,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: {
|
||||||
|
order: { include: { user: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return shipments.map((s) => this.map(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(id: string) {
|
||||||
|
const shipment = await this.prisma.shipment.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { order: { include: { user: true } } },
|
||||||
|
});
|
||||||
|
if (!shipment) throw new NotFoundException('ارسال یافت نشد');
|
||||||
|
return this.map(shipment);
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(adminId: string, dto: CreateShipmentDto) {
|
||||||
|
const order = await this.prisma.order.findUnique({
|
||||||
|
where: { id: dto.orderId },
|
||||||
|
include: { shipment: true, user: true },
|
||||||
|
});
|
||||||
|
if (!order) throw new NotFoundException('سفارش یافت نشد');
|
||||||
|
if (order.shipment) {
|
||||||
|
throw new ConflictException('برای این سفارش قبلاً ارسال ثبت شده است');
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = dto.status ?? ShipmentStatus.preparing;
|
||||||
|
const shipment = await this.prisma.$transaction(async (tx) => {
|
||||||
|
const created = await tx.shipment.create({
|
||||||
|
data: {
|
||||||
|
orderId: order.id,
|
||||||
|
carrier: dto.carrier.trim(),
|
||||||
|
trackingNumber: dto.trackingNumber?.trim() || null,
|
||||||
|
meterage: dto.meterage ?? order.totalMeterage,
|
||||||
|
status,
|
||||||
|
shippedAt: status === ShipmentStatus.shipped ? new Date() : null,
|
||||||
|
deliveredAt: status === ShipmentStatus.delivered ? new Date() : null,
|
||||||
|
},
|
||||||
|
include: { order: { include: { user: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextOrderStatus = SHIPMENT_TO_ORDER[status];
|
||||||
|
if (order.status !== nextOrderStatus) {
|
||||||
|
await tx.order.update({
|
||||||
|
where: { id: order.id },
|
||||||
|
data: { status: nextOrderStatus },
|
||||||
|
});
|
||||||
|
await tx.orderStatusHistory.create({
|
||||||
|
data: {
|
||||||
|
orderId: order.id,
|
||||||
|
fromStatus: order.status,
|
||||||
|
toStatus: nextOrderStatus,
|
||||||
|
changedBy: `admin:${adminId}`,
|
||||||
|
note: 'ثبت ارسال',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.map(shipment);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateShipmentDto) {
|
||||||
|
await this.ensure(id);
|
||||||
|
const shipment = await this.prisma.shipment.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
carrier: dto.carrier?.trim(),
|
||||||
|
trackingNumber:
|
||||||
|
dto.trackingNumber === undefined
|
||||||
|
? undefined
|
||||||
|
: dto.trackingNumber?.trim() || null,
|
||||||
|
meterage: dto.meterage,
|
||||||
|
status: dto.status,
|
||||||
|
},
|
||||||
|
include: { order: { include: { user: true } } },
|
||||||
|
});
|
||||||
|
return this.map(shipment);
|
||||||
|
}
|
||||||
|
|
||||||
|
async advanceStatus(
|
||||||
|
id: string,
|
||||||
|
adminId: string,
|
||||||
|
dto: AdvanceShipmentStatusDto,
|
||||||
|
) {
|
||||||
|
const shipment = await this.prisma.shipment.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { order: { include: { user: true } } },
|
||||||
|
});
|
||||||
|
if (!shipment) throw new NotFoundException('ارسال یافت نشد');
|
||||||
|
|
||||||
|
let nextStatus = dto.status;
|
||||||
|
if (!nextStatus) {
|
||||||
|
const idx = STATUS_FLOW.indexOf(shipment.status);
|
||||||
|
if (idx < 0 || idx >= STATUS_FLOW.length - 1) {
|
||||||
|
throw new BadRequestException('وضعیت ارسال قابل پیشرفت نیست');
|
||||||
|
}
|
||||||
|
nextStatus = STATUS_FLOW[idx + 1]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.prisma.$transaction(async (tx) => {
|
||||||
|
const result = await tx.shipment.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: nextStatus,
|
||||||
|
trackingNumber:
|
||||||
|
dto.trackingNumber !== undefined
|
||||||
|
? dto.trackingNumber.trim() || null
|
||||||
|
: undefined,
|
||||||
|
shippedAt:
|
||||||
|
nextStatus === ShipmentStatus.shipped
|
||||||
|
? (shipment.shippedAt ?? new Date())
|
||||||
|
: shipment.shippedAt,
|
||||||
|
deliveredAt:
|
||||||
|
nextStatus === ShipmentStatus.delivered
|
||||||
|
? new Date()
|
||||||
|
: shipment.deliveredAt,
|
||||||
|
},
|
||||||
|
include: { order: { include: { user: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const nextOrderStatus = SHIPMENT_TO_ORDER[nextStatus!];
|
||||||
|
if (shipment.order.status !== nextOrderStatus) {
|
||||||
|
await tx.order.update({
|
||||||
|
where: { id: shipment.orderId },
|
||||||
|
data: { status: nextOrderStatus },
|
||||||
|
});
|
||||||
|
await tx.orderStatusHistory.create({
|
||||||
|
data: {
|
||||||
|
orderId: shipment.orderId,
|
||||||
|
fromStatus: shipment.order.status,
|
||||||
|
toStatus: nextOrderStatus,
|
||||||
|
changedBy: `admin:${adminId}`,
|
||||||
|
note: `وضعیت ارسال: ${nextStatus}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.map(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensure(id: string) {
|
||||||
|
const shipment = await this.prisma.shipment.findUnique({ where: { id } });
|
||||||
|
if (!shipment) throw new NotFoundException('ارسال یافت نشد');
|
||||||
|
return shipment;
|
||||||
|
}
|
||||||
|
|
||||||
|
private map(s: {
|
||||||
|
id: string;
|
||||||
|
orderId: string;
|
||||||
|
carrier: string;
|
||||||
|
trackingNumber: string | null;
|
||||||
|
meterage: number;
|
||||||
|
status: ShipmentStatus;
|
||||||
|
shippedAt: Date | null;
|
||||||
|
deliveredAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
order: {
|
||||||
|
orderNumber: string;
|
||||||
|
user: { firstName: string; lastName: string };
|
||||||
|
};
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
id: s.id,
|
||||||
|
orderId: s.orderId,
|
||||||
|
orderNumber: s.order.orderNumber,
|
||||||
|
customerName: joinFullName(s.order.user.firstName, s.order.user.lastName),
|
||||||
|
carrier: s.carrier,
|
||||||
|
trackingNumber: s.trackingNumber ?? '',
|
||||||
|
meterage: s.meterage,
|
||||||
|
status: s.status,
|
||||||
|
shippedAt: s.shippedAt?.toISOString() ?? null,
|
||||||
|
deliveredAt: s.deliveredAt?.toISOString() ?? null,
|
||||||
|
createdAt: s.createdAt.toISOString(),
|
||||||
|
updatedAt: s.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import request from 'supertest';
|
||||||
|
import { App } from 'supertest/types';
|
||||||
|
import { AppModule } from './../src/app.module';
|
||||||
|
|
||||||
|
describe('AppController (e2e)', () => {
|
||||||
|
let app: INestApplication<App>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||||
|
imports: [AppModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
app = moduleFixture.createNestApplication();
|
||||||
|
await app.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('/ (GET)', () => {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get('/')
|
||||||
|
.expect(200)
|
||||||
|
.expect('Hello World!');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"moduleFileExtensions": ["js", "json", "ts"],
|
||||||
|
"rootDir": ".",
|
||||||
|
"testEnvironment": "node",
|
||||||
|
"testRegex": ".e2e-spec.ts$",
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "nodenext",
|
||||||
|
"moduleResolution": "nodenext",
|
||||||
|
"resolvePackageJsonExports": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"declaration": true,
|
||||||
|
"removeComments": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"target": "ES2023",
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"baseUrl": "./",
|
||||||
|
"incremental": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noImplicitAny": true,
|
||||||
|
"strictBindCallApply": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user