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:
2026-08-05 20:26:39 +03:30
co-authored by Cursor
commit 9f0422c1db
86 changed files with 18369 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
node_modules
dist
coverage
.git
.gitignore
.env
.env.*
!.env.example
*.md
.vscode
.idea
test
*.spec.ts
.pgdata
.tools
+16
View File
@@ -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
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}
+39
View File
@@ -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"]
+91
View File
@@ -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
+42
View File
@@ -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:
+22
View File
@@ -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
+35
View File
@@ -0,0 +1,35 @@
// @ts-check
import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['eslint.config.mjs'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended,
{
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
sourceType: 'commonjs',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
},
{
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
"prettier/prettier": ["error", { endOfLine: "auto" }],
},
},
);
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
+11062
View File
File diff suppressed because it is too large Load Diff
+99
View File
@@ -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"
}
}
+14
View File
@@ -0,0 +1,14 @@
import "dotenv/config";
import { defineConfig, env } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
seed: 'ts-node --compiler-options {"module":"CommonJS"} prisma/seed.ts',
},
engine: "classic",
datasource: {
url: env("DATABASE_URL"),
},
});
@@ -0,0 +1,469 @@
-- CreateSchema
CREATE SCHEMA IF NOT EXISTS "public";
-- CreateEnum
CREATE TYPE "SettlementType" AS ENUM ('cash', 'check');
-- CreateEnum
CREATE TYPE "CheckRegistrationMode" AS ENUM ('full', 'brief');
-- CreateEnum
CREATE TYPE "OrderStatus" AS ENUM ('cart', 'awaiting_settlement', 'cash_in_progress', 'check_in_progress', 'submitted', 'awaiting_review', 'pricing', 'needs_correction', 'awaiting_settlement_fix', 'cash_invoice_issued', 'awaiting_cash_payment', 'cash_payment_submitted', 'awaiting_cash_confirmation', 'cash_confirmed', 'awaiting_check_review', 'check_needs_fix', 'check_rejected', 'checks_approved', 'final_approved', 'preparing', 'ready_to_ship', 'shipped', 'delivered', 'cancelled', 'rejected', 'expired');
-- CreateEnum
CREATE TYPE "CheckStatus" AS ENUM ('pending_review', 'approved', 'rejected');
-- CreateEnum
CREATE TYPE "CashPaymentStatus" AS ENUM ('pending_review', 'approved', 'rejected', 'expired');
-- CreateEnum
CREATE TYPE "ShipmentStatus" AS ENUM ('preparing', 'ready', 'shipped', 'delivered');
-- CreateEnum
CREATE TYPE "CustomerType" AS ENUM ('wholesale', 'retail');
-- CreateEnum
CREATE TYPE "AdminRole" AS ENUM ('admin', 'sales', 'finance', 'warehouse');
-- CreateEnum
CREATE TYPE "NotificationTone" AS ENUM ('info', 'warning', 'success', 'danger');
-- CreateTable
CREATE TABLE "Company" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT,
"logoUrl" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"displayOrder" INTEGER NOT NULL DEFAULT 0,
"maxMeteragePerInvoice" INTEGER NOT NULL DEFAULT 5000,
"cashInvoiceValidityHours" INTEGER NOT NULL DEFAULT 8,
"cashInvoiceValidUntilHour" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Company_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BankAccount" (
"id" TEXT NOT NULL,
"companyId" TEXT NOT NULL,
"bankName" TEXT NOT NULL,
"accountHolder" TEXT NOT NULL,
"accountNumber" TEXT NOT NULL,
"cardNumber" TEXT NOT NULL,
"iban" TEXT NOT NULL,
"notes" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"displayOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BankAccount_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Catalog" (
"id" TEXT NOT NULL,
"companyId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"code" TEXT NOT NULL,
"description" TEXT,
"displayOrder" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Catalog_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Product" (
"id" TEXT NOT NULL,
"companyId" TEXT NOT NULL,
"catalogId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"code" TEXT NOT NULL,
"color" TEXT NOT NULL,
"description" TEXT,
"minMeterage" DOUBLE PRECISION NOT NULL DEFAULT 30,
"orderMultiplier" DOUBLE PRECISION NOT NULL DEFAULT 10,
"basePrice" DOUBLE PRECISION,
"stock" DOUBLE PRECISION,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Product_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Customer" (
"id" TEXT NOT NULL,
"firstName" TEXT NOT NULL,
"lastName" TEXT NOT NULL,
"phone" TEXT NOT NULL,
"nationalId" TEXT,
"customerType" "CustomerType" NOT NULL DEFAULT 'wholesale',
"companyId" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Customer_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AdminUser" (
"id" TEXT NOT NULL,
"fullName" TEXT NOT NULL,
"phone" TEXT NOT NULL,
"email" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"role" "AdminRole" NOT NULL DEFAULT 'admin',
"isActive" BOOLEAN NOT NULL DEFAULT true,
"lastLoginAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AdminUser_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OtpChallenge" (
"id" TEXT NOT NULL,
"phone" TEXT NOT NULL,
"code" TEXT NOT NULL,
"firstName" TEXT,
"lastName" TEXT,
"isRegister" BOOLEAN NOT NULL DEFAULT false,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "OtpChallenge_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Cart" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"companyId" TEXT,
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Cart_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CartItem" (
"id" TEXT NOT NULL,
"cartId" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"catalogCode" TEXT NOT NULL,
"productName" TEXT NOT NULL,
"productCode" TEXT NOT NULL,
"color" TEXT NOT NULL,
"meterage" DOUBLE PRECISION NOT NULL,
"unitPrice" DOUBLE PRECISION,
"notes" TEXT,
"companyId" TEXT NOT NULL,
"companyName" TEXT NOT NULL,
CONSTRAINT "CartItem_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Order" (
"id" TEXT NOT NULL,
"orderNumber" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"companyId" TEXT NOT NULL,
"companyName" TEXT NOT NULL,
"settlementType" "SettlementType" NOT NULL,
"checkMode" "CheckRegistrationMode",
"status" "OrderStatus" NOT NULL DEFAULT 'awaiting_review',
"totalMeterage" DOUBLE PRECISION NOT NULL DEFAULT 0,
"totalAmount" DOUBLE PRECISION,
"shippingCost" DOUBLE PRECISION,
"totalDiscount" DOUBLE PRECISION,
"finalAmount" DOUBLE PRECISION,
"notes" TEXT,
"checkRassDays" INTEGER,
"checkRassDate" TIMESTAMP(3),
"cashInvoiceExpiresAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Order_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OrderItem" (
"id" TEXT NOT NULL,
"orderId" TEXT NOT NULL,
"productId" TEXT,
"catalogCode" TEXT NOT NULL,
"productName" TEXT NOT NULL,
"productCode" TEXT,
"color" TEXT NOT NULL,
"requestedMeterage" DOUBLE PRECISION NOT NULL,
"confirmedMeterage" DOUBLE PRECISION,
"unitPrice" DOUBLE PRECISION,
"unitDiscount" DOUBLE PRECISION DEFAULT 0,
"lineTotal" DOUBLE PRECISION,
"notes" TEXT,
CONSTRAINT "OrderItem_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "OrderStatusHistory" (
"id" TEXT NOT NULL,
"orderId" TEXT NOT NULL,
"fromStatus" "OrderStatus",
"toStatus" "OrderStatus" NOT NULL,
"changedBy" TEXT NOT NULL,
"note" TEXT,
"reason" TEXT,
"changedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "OrderStatusHistory_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Check" (
"id" TEXT NOT NULL,
"orderId" TEXT NOT NULL,
"row" INTEGER NOT NULL,
"amount" DOUBLE PRECISION NOT NULL,
"dueDate" TIMESTAMP(3) NOT NULL,
"sayadId" TEXT,
"checkNumber" TEXT,
"ownerNationalId" TEXT,
"ownerFirstName" TEXT,
"ownerLastName" TEXT,
"bankName" TEXT,
"branchName" TEXT,
"frontImage" TEXT,
"backImage" TEXT,
"notes" TEXT,
"mode" "CheckRegistrationMode" NOT NULL DEFAULT 'full',
"status" "CheckStatus" NOT NULL DEFAULT 'pending_review',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Check_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "CashPaymentReceipt" (
"id" TEXT NOT NULL,
"orderId" TEXT NOT NULL,
"amount" DOUBLE PRECISION NOT NULL,
"paidAmount" DOUBLE PRECISION,
"transferDate" TIMESTAMP(3),
"transferTime" TEXT,
"trackingNumber" TEXT,
"destinationAccountId" TEXT,
"receiptImage" TEXT,
"notes" TEXT,
"expiresAt" TIMESTAMP(3),
"status" "CashPaymentStatus" NOT NULL DEFAULT 'pending_review',
"submittedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "CashPaymentReceipt_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Shipment" (
"id" TEXT NOT NULL,
"orderId" TEXT NOT NULL,
"carrier" TEXT NOT NULL,
"trackingNumber" TEXT,
"meterage" DOUBLE PRECISION NOT NULL,
"status" "ShipmentStatus" NOT NULL DEFAULT 'preparing',
"shippedAt" TIMESTAMP(3),
"deliveredAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Shipment_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SystemSettings" (
"id" TEXT NOT NULL DEFAULT 'default',
"maxMeteragePerInvoice" INTEGER NOT NULL DEFAULT 5000,
"cashInvoiceValidityHours" INTEGER NOT NULL DEFAULT 8,
"defaultSettlementCheck" BOOLEAN NOT NULL DEFAULT true,
"defaultSettlementCash" BOOLEAN NOT NULL DEFAULT true,
"smsOrderRegistered" BOOLEAN NOT NULL DEFAULT true,
"smsPaymentReminder" BOOLEAN NOT NULL DEFAULT true,
"smsCheckReview" BOOLEAN NOT NULL DEFAULT true,
"smsShippingUpdate" BOOLEAN NOT NULL DEFAULT true,
"supportPhone" TEXT,
"supportEmail" TEXT,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SystemSettings_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AdminNotification" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"body" TEXT NOT NULL,
"tone" "NotificationTone" DEFAULT 'info',
"link" TEXT,
"isRead" BOOLEAN NOT NULL DEFAULT false,
"sentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AdminNotification_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "InternalMessage" (
"id" TEXT NOT NULL,
"senderName" TEXT NOT NULL,
"subject" TEXT NOT NULL,
"preview" TEXT NOT NULL,
"isRead" BOOLEAN NOT NULL DEFAULT false,
"sentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "InternalMessage_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Company_slug_key" ON "Company"("slug");
-- CreateIndex
CREATE INDEX "BankAccount_companyId_idx" ON "BankAccount"("companyId");
-- CreateIndex
CREATE INDEX "Catalog_companyId_idx" ON "Catalog"("companyId");
-- CreateIndex
CREATE UNIQUE INDEX "Catalog_companyId_code_key" ON "Catalog"("companyId", "code");
-- CreateIndex
CREATE INDEX "Product_catalogId_idx" ON "Product"("catalogId");
-- CreateIndex
CREATE INDEX "Product_companyId_idx" ON "Product"("companyId");
-- CreateIndex
CREATE UNIQUE INDEX "Product_companyId_code_key" ON "Product"("companyId", "code");
-- CreateIndex
CREATE UNIQUE INDEX "Customer_phone_key" ON "Customer"("phone");
-- CreateIndex
CREATE INDEX "Customer_companyId_idx" ON "Customer"("companyId");
-- CreateIndex
CREATE UNIQUE INDEX "AdminUser_phone_key" ON "AdminUser"("phone");
-- CreateIndex
CREATE UNIQUE INDEX "AdminUser_email_key" ON "AdminUser"("email");
-- CreateIndex
CREATE INDEX "OtpChallenge_phone_idx" ON "OtpChallenge"("phone");
-- CreateIndex
CREATE INDEX "Cart_userId_idx" ON "Cart"("userId");
-- CreateIndex
CREATE INDEX "CartItem_cartId_idx" ON "CartItem"("cartId");
-- CreateIndex
CREATE UNIQUE INDEX "CartItem_cartId_productId_key" ON "CartItem"("cartId", "productId");
-- CreateIndex
CREATE UNIQUE INDEX "Order_orderNumber_key" ON "Order"("orderNumber");
-- CreateIndex
CREATE INDEX "Order_userId_idx" ON "Order"("userId");
-- CreateIndex
CREATE INDEX "Order_companyId_idx" ON "Order"("companyId");
-- CreateIndex
CREATE INDEX "Order_status_idx" ON "Order"("status");
-- CreateIndex
CREATE INDEX "Order_createdAt_idx" ON "Order"("createdAt");
-- CreateIndex
CREATE INDEX "OrderItem_orderId_idx" ON "OrderItem"("orderId");
-- CreateIndex
CREATE INDEX "OrderStatusHistory_orderId_idx" ON "OrderStatusHistory"("orderId");
-- CreateIndex
CREATE INDEX "Check_orderId_idx" ON "Check"("orderId");
-- CreateIndex
CREATE INDEX "Check_dueDate_idx" ON "Check"("dueDate");
-- CreateIndex
CREATE UNIQUE INDEX "CashPaymentReceipt_orderId_key" ON "CashPaymentReceipt"("orderId");
-- CreateIndex
CREATE UNIQUE INDEX "Shipment_orderId_key" ON "Shipment"("orderId");
-- AddForeignKey
ALTER TABLE "BankAccount" ADD CONSTRAINT "BankAccount_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Catalog" ADD CONSTRAINT "Catalog_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Product" ADD CONSTRAINT "Product_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Product" ADD CONSTRAINT "Product_catalogId_fkey" FOREIGN KEY ("catalogId") REFERENCES "Catalog"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Customer" ADD CONSTRAINT "Customer_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Cart" ADD CONSTRAINT "Cart_userId_fkey" FOREIGN KEY ("userId") REFERENCES "Customer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CartItem" ADD CONSTRAINT "CartItem_cartId_fkey" FOREIGN KEY ("cartId") REFERENCES "Cart"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CartItem" ADD CONSTRAINT "CartItem_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Order" ADD CONSTRAINT "Order_userId_fkey" FOREIGN KEY ("userId") REFERENCES "Customer"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Order" ADD CONSTRAINT "Order_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OrderItem" ADD CONSTRAINT "OrderItem_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OrderItem" ADD CONSTRAINT "OrderItem_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "OrderStatusHistory" ADD CONSTRAINT "OrderStatusHistory_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Check" ADD CONSTRAINT "Check_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CashPaymentReceipt" ADD CONSTRAINT "CashPaymentReceipt_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "CashPaymentReceipt" ADD CONSTRAINT "CashPaymentReceipt_destinationAccountId_fkey" FOREIGN KEY ("destinationAccountId") REFERENCES "BankAccount"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Shipment" ADD CONSTRAINT "Shipment_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "Order"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+1
View File
@@ -0,0 +1 @@
provider = "postgresql"
+419
View File
@@ -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
View File
@@ -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();
});
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
echo "==> Checking Docker..."
if ! docker info >/dev/null 2>&1; then
echo "Docker در دسترس نیست. کاربر را به گروه docker اضافه کنید:"
echo " sudo usermod -aG docker \"\$USER\""
echo "سپس یک‌بار از سیستم خارج و دوباره وارد شوید."
exit 1
fi
echo "==> Starting PostgreSQL..."
docker compose up -d
echo "==> Waiting for Postgres health..."
for i in $(seq 1 30); do
if docker compose exec -T postgres pg_isready -U fabric -d fabric_trade >/dev/null 2>&1; then
break
fi
sleep 1
done
echo "==> Installing deps (if needed)..."
npm install
echo "==> Prisma generate + migrate + seed..."
npx prisma generate
npx prisma migrate deploy
npm run prisma:seed
echo ""
echo "آماده است. سرور را با این دستور اجرا کنید:"
echo " npm run start:dev"
echo ""
echo "Swagger: http://localhost:3000/api/docs"
echo "Admin: 09121112222 / admin123"
echo "OTP: 09121234567 / 123456"
+39
View File
@@ -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);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { AdminUsersController } from './admin-users.controller';
import { AdminUsersService } from './admin-users.service';
@Module({
controllers: [AdminUsersController],
providers: [AdminUsersService],
exports: [AdminUsersService],
})
export class AdminUsersModule {}
+139
View File
@@ -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(),
};
}
}
+68
View File
@@ -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;
}
+45
View File
@@ -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 {}
+55
View File
@@ -0,0 +1,55 @@
import { Body, Controller, Get, Patch, Post } from '@nestjs/common';
import { AuthService } from './auth.service';
import {
AdminLoginDto,
RequestOtpDto,
UpdateProfileDto,
VerifyOtpDto,
} from './dto/auth.dto';
import { AuthType, Public } from '../common/decorators/auth.decorators';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import type { AuthPrincipal } from '../common/decorators/current-user.decorator';
@Controller()
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Public()
@Get('health')
health() {
return { ok: true, service: 'fabric-trade-api' };
}
@Public()
@Post('auth/otp/request')
requestOtp(@Body() dto: RequestOtpDto) {
return this.authService.requestOtp(dto);
}
@Public()
@Post('auth/otp/verify')
verifyOtp(@Body() dto: VerifyOtpDto) {
return this.authService.verifyOtp(dto);
}
@Public()
@Post('admin/auth/login')
adminLogin(@Body() dto: AdminLoginDto) {
return this.authService.adminLogin(dto);
}
@AuthType('any')
@Get('auth/me')
me(@CurrentUser() user: AuthPrincipal) {
return this.authService.me(user.id, user.type);
}
@AuthType('customer')
@Patch('auth/profile')
updateProfile(
@CurrentUser() user: AuthPrincipal,
@Body() dto: UpdateProfileDto,
) {
return this.authService.updateProfile(user.id, dto);
}
}
+24
View File
@@ -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 {}
+203
View File
@@ -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(),
};
}
}
+50
View File
@@ -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;
}
+67
View File
@@ -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);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { CartsController } from './carts.controller';
import { CartsService } from './carts.service';
@Module({
controllers: [CartsController],
providers: [CartsService],
exports: [CartsService],
})
export class CartsModule {}
+225
View File
@@ -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(),
};
}
}
+33
View File
@@ -0,0 +1,33 @@
import { IsNumber, IsOptional, IsString, Min } from 'class-validator';
import { Type } from 'class-transformer';
export class AddCartItemDto {
@IsString()
productId!: string;
@Type(() => Number)
@IsNumber()
@Min(0.001)
meterage!: number;
@IsOptional()
@IsString()
notes?: string;
}
export class UpdateCartItemMeterageDto {
@Type(() => Number)
@IsNumber()
@Min(0.001)
meterage!: number;
@IsOptional()
@IsString()
notes?: string;
}
export class UpdateCartNotesDto {
@IsOptional()
@IsString()
notes?: string;
}
@@ -0,0 +1,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);
}
}
+10
View File
@@ -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 {}
+220
View File
@@ -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(),
};
}
}
+55
View File
@@ -0,0 +1,55 @@
import {
IsDateString,
IsEnum,
IsNumber,
IsOptional,
IsString,
Min,
MinLength,
} from 'class-validator';
import { Type } from 'class-transformer';
import { CashPaymentStatus } from '@prisma/client';
export class SubmitCashReceiptDto {
@Type(() => Number)
@IsNumber()
@Min(1)
amount!: number;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
paidAmount?: number;
@IsDateString()
transferDate!: string;
@IsString()
@MinLength(1)
transferTime!: string;
@IsString()
@MinLength(1)
trackingNumber!: string;
@IsString()
destinationAccountId!: string;
@IsOptional()
@IsString()
receiptImage?: string;
@IsOptional()
@IsString()
notes?: string;
}
export class UpdateCashPaymentStatusDto {
@IsEnum(CashPaymentStatus)
status!: CashPaymentStatus;
@IsOptional()
@IsString()
note?: string;
}
+57
View File
@@ -0,0 +1,57 @@
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
import { CatalogsService } from './catalogs.service';
import { AuthType } from '../common/decorators/auth.decorators';
import {
CreateCatalogDto,
SetCatalogActiveDto,
UpdateCatalogDto,
} from './dto/catalog.dto';
@Controller('catalogs')
export class CatalogsController {
constructor(private readonly catalogsService: CatalogsService) {}
@AuthType('any')
@Get()
findAll(
@Query('companyId') companyId?: string,
@Query('activeOnly') activeOnly?: string,
) {
return this.catalogsService.findAll(
companyId,
activeOnly === 'true' || activeOnly === '1',
);
}
@AuthType('any')
@Get(':id')
findOne(@Param('id') id: string) {
return this.catalogsService.findOne(id);
}
@AuthType('admin')
@Post()
create(@Body() dto: CreateCatalogDto) {
return this.catalogsService.create(dto);
}
@AuthType('admin')
@Patch(':id')
update(@Param('id') id: string, @Body() dto: UpdateCatalogDto) {
return this.catalogsService.update(id, dto);
}
@AuthType('admin')
@Patch(':id/active')
setActive(@Param('id') id: string, @Body() dto: SetCatalogActiveDto) {
return this.catalogsService.setActive(id, dto.isActive);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { CatalogsController } from './catalogs.controller';
import { CatalogsService } from './catalogs.service';
@Module({
controllers: [CatalogsController],
providers: [CatalogsService],
exports: [CatalogsService],
})
export class CatalogsModule {}
+146
View File
@@ -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(),
};
}
}
+64
View File
@@ -0,0 +1,64 @@
import {
IsBoolean,
IsInt,
IsOptional,
IsString,
MinLength,
} from 'class-validator';
import { Type } from 'class-transformer';
export class CreateCatalogDto {
@IsString()
companyId!: string;
@IsString()
@MinLength(1)
name!: string;
@IsString()
@MinLength(1)
code!: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
displayOrder?: number;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class UpdateCatalogDto {
@IsOptional()
@IsString()
@MinLength(1)
name?: string;
@IsOptional()
@IsString()
@MinLength(1)
code?: string;
@IsOptional()
@IsString()
description?: string | null;
@IsOptional()
@Type(() => Number)
@IsInt()
displayOrder?: number;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class SetCatalogActiveDto {
@IsBoolean()
isActive!: boolean;
}
+26
View File
@@ -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);
}
}
+10
View File
@@ -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 {}
+127
View File
@@ -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(),
};
}
}
+11
View File
@@ -0,0 +1,11 @@
import { IsEnum, IsOptional, IsString } from 'class-validator';
import { CheckStatus } from '@prisma/client';
export class UpdateCheckStatusDto {
@IsEnum(CheckStatus)
status!: CheckStatus;
@IsOptional()
@IsString()
note?: string;
}
+11
View File
@@ -0,0 +1,11 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
export const ROLES_KEY = 'roles';
export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles);
export const AUTH_TYPE_KEY = 'authType';
export const AuthType = (type: 'customer' | 'admin' | 'any') =>
SetMetadata(AUTH_TYPE_KEY, type);
@@ -0,0 +1,15 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export interface AuthPrincipal {
id: string;
type: 'customer' | 'admin';
role?: string;
phone?: string;
}
export const CurrentUser = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): AuthPrincipal => {
const request = ctx.switchToHttp().getRequest();
return request.user;
},
);
+75
View File
@@ -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;
}
}
+21
View File
@@ -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,
);
}
+65
View File
@@ -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(),
};
}
+21
View File
@@ -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();
}
+8
View File
@@ -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}`;
}
+63
View File
@@ -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: 'منقضی',
};
+20
View File
@@ -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)));
}
+93
View File
@@ -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);
}
}
+13
View File
@@ -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 {}
+250
View File
@@ -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(),
};
}
}
+176
View File
@@ -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;
}
+47
View File
@@ -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);
}
}
+10
View File
@@ -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 {}
+213
View File
@@ -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(),
};
}
}
+82
View File
@@ -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;
}
+19
View File
@@ -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();
}
}
+11
View File
@@ -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 {}
+257
View File
@@ -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
View File
@@ -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();
+232
View File
@@ -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;
}
+101
View File
@@ -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);
}
}
+13
View File
@@ -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 {}
+643
View File
@@ -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;
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}
+16
View File
@@ -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();
}
}
+166
View File
@@ -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;
}
+61
View File
@@ -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);
}
}
+10
View File
@@ -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 {}
+215
View File
@@ -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(),
};
}
}
+48
View File
@@ -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;
}
+20
View File
@@ -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);
}
}
+10
View File
@@ -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 {}
+77
View File
@@ -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(),
};
}
}
+63
View File
@@ -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;
}
+57
View File
@@ -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);
}
}
+10
View File
@@ -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 {}
+221
View File
@@ -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(),
};
}
}
+29
View File
@@ -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();
});
});
+9
View File
@@ -0,0 +1,9 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}
+25
View File
@@ -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
}
}