feat: initial commit
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/gold_bank?schema=public"
|
||||
PORT=3000
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "api",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "nest start --watch",
|
||||
"build": "nest build",
|
||||
"lint": "eslint \"src/**/*.ts\"",
|
||||
"test": "jest",
|
||||
"prisma:push": "prisma db push",
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:seed": "prisma db seed",
|
||||
"prisma:studio": "prisma studio"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "ts-node --transpile-only prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@gold-bank/shared-types": "workspace:*",
|
||||
"@nestjs/common": "^10.4.0",
|
||||
"@nestjs/core": "^10.4.0",
|
||||
"@nestjs/platform-express": "^10.4.0",
|
||||
"@prisma/client": "^5.19.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.5",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.14.0",
|
||||
"prisma": "^5.19.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.5.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
// Gold Banking System — core schema
|
||||
// Canonical accounting model: legacy "System A" gram ledger (see migration notes).
|
||||
// The external "Tahesab" service is retired — LedgerEntry is the sole source of truth.
|
||||
// All money/weight fields use Decimal, never Float, to avoid rounding drift in debt math.
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Enums
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
enum AdminRole {
|
||||
SUPER_ADMIN
|
||||
SYSTEM_APPROVER // legacy: تایید مدیر سیستم
|
||||
SALES_EXPERT // legacy Customer.type=1, also acts as a referral "reagent"
|
||||
WORKSHOP_SUPERVISOR
|
||||
ACCOUNTANT
|
||||
}
|
||||
|
||||
enum VerificationStep {
|
||||
UNVERIFIED
|
||||
STEP_ONE_SUBMITTED
|
||||
STEP_TWO_SUBMITTED
|
||||
VERIFIED
|
||||
}
|
||||
|
||||
enum OrderStatus {
|
||||
SUSPENDED // credit limit (goldCreditLimitGrams) exceeded at placement
|
||||
PLACED
|
||||
ADMIN_APPROVED
|
||||
SOURCING
|
||||
PREPARING
|
||||
DELIVERED_TO_AGENT
|
||||
DELIVERED_TO_CUSTOMER // debt recognition point — mirrors legacy sts>4
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
enum PreOrderStatus {
|
||||
PLACED
|
||||
ADMIN_APPROVED
|
||||
WORKSHOP_APPROVED
|
||||
READY_TO_SHIP
|
||||
DELIVERED
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
enum LedgerCurrency {
|
||||
GOLD_GRAM
|
||||
RIAL
|
||||
}
|
||||
|
||||
enum LedgerDirection {
|
||||
DEBIT // increases what the customer owes
|
||||
CREDIT // decreases what the customer owes
|
||||
}
|
||||
|
||||
enum LedgerSourceType {
|
||||
ORDER_STOCK
|
||||
ORDER_PRE_ORDER
|
||||
REPAYMENT_MELTED_GOLD // karat-normalized: amount * karatPerMille / 750
|
||||
REPAYMENT_TRANSFER
|
||||
REPAYMENT_CHEQUE
|
||||
REPAYMENT_CASH // price-per-gram-normalized via pricePerGramRialAtEntry
|
||||
WALLET_BONUS
|
||||
ADJUSTMENT
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Identity
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
model City {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
province String?
|
||||
customers Customer[]
|
||||
addresses CustomerAddress[]
|
||||
branches Branch[]
|
||||
preOrders PreOrder[]
|
||||
}
|
||||
|
||||
model AdminUser {
|
||||
id String @id @default(cuid())
|
||||
fullName String
|
||||
mobile String @unique
|
||||
passwordHash String
|
||||
role AdminRole
|
||||
branchId String?
|
||||
branch Branch? @relation(fields: [branchId], references: [id])
|
||||
wagePercent Decimal? @db.Decimal(6, 3) // legacy Customer.percent, commission on referred orders
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
referredCustomers Customer[] @relation("ReferralAgent")
|
||||
postedLedgerEntries LedgerEntry[]
|
||||
orderStatusEvents OrderStatusEvent[]
|
||||
preOrderStatusEvents PreOrderStatusEvent[]
|
||||
|
||||
@@index([role])
|
||||
}
|
||||
|
||||
model Customer {
|
||||
id String @id @default(cuid())
|
||||
fullName String
|
||||
mobile String @unique
|
||||
nationalCode String @unique
|
||||
passwordHash String
|
||||
cityId String?
|
||||
city City? @relation(fields: [cityId], references: [id])
|
||||
|
||||
referredByAgentId String?
|
||||
referredByAgent AdminUser? @relation("ReferralAgent", fields: [referredByAgentId], references: [id])
|
||||
|
||||
// Credit limit + cached ledger balances (source of truth is LedgerEntry; these
|
||||
// are denormalized for fast reads and reconciled by a scheduled job).
|
||||
goldCreditLimitGrams Decimal @default(0) @db.Decimal(14, 4) // legacy gram_level
|
||||
goldDebtGrams Decimal @default(0) @db.Decimal(14, 4) // legacy remain
|
||||
fiatDebtRial Decimal @default(0) @db.Decimal(20, 2) // legacy rial_remain
|
||||
|
||||
verificationStep VerificationStep @default(UNVERIFIED)
|
||||
nationalIdImageUrl String?
|
||||
businessLicenseUrl String?
|
||||
shopImageUrl String?
|
||||
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
addresses CustomerAddress[]
|
||||
orders Order[]
|
||||
preOrders PreOrder[]
|
||||
ledgerEntries LedgerEntry[]
|
||||
walletBonuses WalletBonus[]
|
||||
|
||||
@@index([referredByAgentId])
|
||||
}
|
||||
|
||||
model CustomerAddress {
|
||||
id String @id @default(cuid())
|
||||
customerId String
|
||||
customer Customer @relation(fields: [customerId], references: [id])
|
||||
cityId String?
|
||||
city City? @relation(fields: [cityId], references: [id])
|
||||
addressLine String
|
||||
isDefault Boolean @default(false)
|
||||
}
|
||||
|
||||
model Branch {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
cityId String?
|
||||
city City? @relation(fields: [cityId], references: [id])
|
||||
adminUsers AdminUser[]
|
||||
orders Order[]
|
||||
}
|
||||
|
||||
model Workshop {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
isActive Boolean @default(true)
|
||||
preOrders PreOrder[]
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Catalog — item reference catalog
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
model Category {
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
parentId String?
|
||||
parent Category? @relation("CategoryTree", fields: [parentId], references: [id])
|
||||
children Category[] @relation("CategoryTree")
|
||||
products Product[]
|
||||
}
|
||||
|
||||
model Brand {
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
products Product[]
|
||||
}
|
||||
|
||||
/// Curated product groupings — replaces legacy "Ciny/CinyProduct" (a tagging
|
||||
/// feature, NOT a gold-price snapshot mechanism, despite the misleading legacy name).
|
||||
model Collection {
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
description String?
|
||||
isActive Boolean @default(true)
|
||||
products CollectionProduct[]
|
||||
}
|
||||
|
||||
model CollectionProduct {
|
||||
collectionId String
|
||||
collection Collection @relation(fields: [collectionId], references: [id])
|
||||
productId String
|
||||
product Product @relation(fields: [productId], references: [id])
|
||||
|
||||
@@id([collectionId, productId])
|
||||
}
|
||||
|
||||
model Product {
|
||||
id String @id @default(cuid())
|
||||
title String
|
||||
description String?
|
||||
categoryId String?
|
||||
category Category? @relation(fields: [categoryId], references: [id])
|
||||
brandId String?
|
||||
brand Brand? @relation(fields: [brandId], references: [id])
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
variants ProductVariant[]
|
||||
images ProductImage[]
|
||||
collections CollectionProduct[]
|
||||
}
|
||||
|
||||
model ProductImage {
|
||||
id String @id @default(cuid())
|
||||
productId String
|
||||
product Product @relation(fields: [productId], references: [id])
|
||||
url String
|
||||
sortOrder Int @default(0)
|
||||
}
|
||||
|
||||
model ProductVariant {
|
||||
id String @id @default(cuid())
|
||||
productId String
|
||||
product Product @relation(fields: [productId], references: [id])
|
||||
title String
|
||||
sku String? @unique
|
||||
/// Base/raw weight before making charge ("rash weight").
|
||||
rashWeightGrams Decimal @db.Decimal(14, 4)
|
||||
/// Default making-charge percentage applied on top of rash weight.
|
||||
defaultWagePercent Decimal @db.Decimal(6, 3)
|
||||
isActive Boolean @default(true)
|
||||
|
||||
orderItems OrderItem[]
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Orders — stock orders (debt-bearing)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
model Order {
|
||||
id String @id @default(cuid())
|
||||
orderNumber String @unique
|
||||
customerId String
|
||||
customer Customer @relation(fields: [customerId], references: [id])
|
||||
branchId String?
|
||||
branch Branch? @relation(fields: [branchId], references: [id])
|
||||
status OrderStatus @default(PLACED)
|
||||
|
||||
/// Total gold weight incl. wage across all items ("allGram" / order.w).
|
||||
totalGoldWeightGrams Decimal @db.Decimal(14, 4)
|
||||
/// Pure making-charge weight component (totalGoldWeightGrams minus raw weight sum).
|
||||
wageWeightGrams Decimal @db.Decimal(14, 4)
|
||||
/// Flat Rial charge independent of gold price (e.g. stones) — legacy rial_input.
|
||||
flatRialCharge Decimal @default(0) @db.Decimal(20, 2)
|
||||
|
||||
isSuspendedOverCreditLimit Boolean @default(false)
|
||||
deliveredAt DateTime? // setting this is what triggers debt recognition
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
items OrderItem[]
|
||||
statusEvents OrderStatusEvent[]
|
||||
ledgerEntries LedgerEntry[]
|
||||
convertedFromPreOrder PreOrder?
|
||||
|
||||
@@index([customerId])
|
||||
@@index([status])
|
||||
}
|
||||
|
||||
model OrderItem {
|
||||
id String @id @default(cuid())
|
||||
orderId String
|
||||
order Order @relation(fields: [orderId], references: [id])
|
||||
productVariantId String
|
||||
productVariant ProductVariant @relation(fields: [productVariantId], references: [id])
|
||||
quantity Int
|
||||
|
||||
// Snapshots at order time — never re-read from ProductVariant after placement.
|
||||
rashWeightGramsSnapshot Decimal @db.Decimal(14, 4)
|
||||
wagePercentSnapshot Decimal @db.Decimal(6, 3)
|
||||
discountPercentSnapshot Decimal @default(0) @db.Decimal(6, 3)
|
||||
/// computed: rash*(1 + (wagePercent-discount)/100)
|
||||
computedWeightGrams Decimal @db.Decimal(14, 4)
|
||||
flatRialCharge Decimal @default(0) @db.Decimal(20, 2)
|
||||
}
|
||||
|
||||
model OrderStatusEvent {
|
||||
id String @id @default(cuid())
|
||||
orderId String
|
||||
order Order @relation(fields: [orderId], references: [id])
|
||||
fromStatus OrderStatus?
|
||||
toStatus OrderStatus
|
||||
note String?
|
||||
actorAdminUserId String?
|
||||
actorAdminUser AdminUser? @relation(fields: [actorAdminUserId], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([orderId])
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Pre-orders — custom manufacturing requests (not debt-bearing until converted)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
model PreOrder {
|
||||
id String @id @default(cuid())
|
||||
customerId String
|
||||
customer Customer @relation(fields: [customerId], references: [id])
|
||||
cityId String?
|
||||
city City? @relation(fields: [cityId], references: [id])
|
||||
workshopId String?
|
||||
workshop Workshop? @relation(fields: [workshopId], references: [id])
|
||||
status PreOrderStatus @default(PLACED)
|
||||
|
||||
designImageUrl String?
|
||||
colorName String?
|
||||
sizeSpec String?
|
||||
stoneColor String?
|
||||
minaColor String?
|
||||
estimatedWeightGrams Decimal? @db.Decimal(14, 4)
|
||||
estimatedPriceRial Decimal? @db.Decimal(20, 2)
|
||||
description String?
|
||||
|
||||
/// Once priced, an admin converts this into a real (debt-bearing) Order.
|
||||
convertedOrderId String? @unique
|
||||
convertedOrder Order? @relation(fields: [convertedOrderId], references: [id])
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
statusEvents PreOrderStatusEvent[]
|
||||
|
||||
@@index([customerId])
|
||||
}
|
||||
|
||||
model PreOrderStatusEvent {
|
||||
id String @id @default(cuid())
|
||||
preOrderId String
|
||||
preOrder PreOrder @relation(fields: [preOrderId], references: [id])
|
||||
/// Field-level diff, e.g. {"status": {"old": "PLACED", "new": "ADMIN_APPROVED"}}
|
||||
changes Json
|
||||
actorAdminUserId String?
|
||||
actorAdminUser AdminUser? @relation(fields: [actorAdminUserId], references: [id])
|
||||
ipAddress String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([preOrderId])
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Accounting — the dual-currency ledger (core of the system)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
model LedgerEntry {
|
||||
id String @id @default(cuid())
|
||||
customerId String
|
||||
customer Customer @relation(fields: [customerId], references: [id])
|
||||
|
||||
currency LedgerCurrency
|
||||
direction LedgerDirection
|
||||
sourceType LedgerSourceType
|
||||
/// Always positive; direction determines the sign applied to the customer balance.
|
||||
amount Decimal @db.Decimal(20, 4)
|
||||
|
||||
/// Purity of the physical instrument, for REPAYMENT_MELTED_GOLD (e.g. 750, 916).
|
||||
karatPerMille Int?
|
||||
/// Gold price/gram at conversion time, for REPAYMENT_CASH.
|
||||
pricePerGramRialAtEntry Decimal? @db.Decimal(20, 2)
|
||||
|
||||
orderId String?
|
||||
order Order? @relation(fields: [orderId], references: [id])
|
||||
|
||||
/// Pending entries don't count toward the cached customer balance until confirmed.
|
||||
confirmed Boolean @default(false)
|
||||
confirmedAt DateTime?
|
||||
postedByAdminUserId String?
|
||||
postedByAdminUser AdminUser? @relation(fields: [postedByAdminUserId], references: [id])
|
||||
|
||||
note String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([customerId, currency])
|
||||
@@index([orderId])
|
||||
}
|
||||
|
||||
/// Replaces legacy WebServiceGold / ConfigStatic.gold_price — timestamped price feed.
|
||||
model GoldPriceQuote {
|
||||
id String @id @default(cuid())
|
||||
karatPerMille Int
|
||||
pricePerGramRial Decimal @db.Decimal(20, 2)
|
||||
source String
|
||||
quotedAt DateTime @default(now())
|
||||
|
||||
@@index([karatPerMille, quotedAt])
|
||||
}
|
||||
|
||||
/// Time-limited gold cashback bonus — replaces the legacy Rooyesh\Wallet gold credit.
|
||||
model WalletBonus {
|
||||
id String @id @default(cuid())
|
||||
customerId String
|
||||
customer Customer @relation(fields: [customerId], references: [id])
|
||||
orderId String?
|
||||
goldGrams Decimal @db.Decimal(14, 4)
|
||||
grantedAt DateTime @default(now())
|
||||
expiresAt DateTime
|
||||
consumedAt DateTime?
|
||||
isActive Boolean @default(true)
|
||||
|
||||
@@index([customerId, isActive])
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
import { AdminRole, PrismaClient, VerificationStep } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Placeholder — replace with a real bcrypt hash once the auth module lands.
|
||||
const PLACEHOLDER_PASSWORD_HASH = 'placeholder-hash-not-for-production';
|
||||
|
||||
// Served from the user-app's own public/ folder. This is the same placeholder
|
||||
// image the legacy app used for missing/failed product imagery (no-items.png).
|
||||
// Replace these rows with real photo URLs when product photography exists.
|
||||
const PLACEHOLDER_PRODUCT_IMAGE = '/img/no-items.png';
|
||||
|
||||
async function main() {
|
||||
// ── Cities ────────────────────────────────────────────────────────────
|
||||
const [tehran, mashhad, isfahan, tabriz] = await Promise.all([
|
||||
prisma.city.upsert({ where: { id: 'city-tehran' }, update: {}, create: { id: 'city-tehran', name: 'تهران', province: 'تهران' } }),
|
||||
prisma.city.upsert({ where: { id: 'city-mashhad' }, update: {}, create: { id: 'city-mashhad', name: 'مشهد', province: 'خراسان رضوی' } }),
|
||||
prisma.city.upsert({ where: { id: 'city-isfahan' }, update: {}, create: { id: 'city-isfahan', name: 'اصفهان', province: 'اصفهان' } }),
|
||||
prisma.city.upsert({ where: { id: 'city-tabriz' }, update: {}, create: { id: 'city-tabriz', name: 'تبریز', province: 'آذربایجان شرقی' } }),
|
||||
]);
|
||||
|
||||
// ── Branches & workshop ──────────────────────────────────────────────
|
||||
const branchTehran = await prisma.branch.upsert({
|
||||
where: { id: 'branch-tehran-bazaar' },
|
||||
update: {},
|
||||
create: { id: 'branch-tehran-bazaar', name: 'شعبه بازار بزرگ تهران', cityId: tehran.id },
|
||||
});
|
||||
const branchMashhad = await prisma.branch.upsert({
|
||||
where: { id: 'branch-mashhad-main' },
|
||||
update: {},
|
||||
create: { id: 'branch-mashhad-main', name: 'شعبه مرکزی مشهد', cityId: mashhad.id },
|
||||
});
|
||||
await prisma.workshop.upsert({
|
||||
where: { id: 'workshop-central' },
|
||||
update: {},
|
||||
create: { id: 'workshop-central', name: 'کارگاه مرکزی ساخت طلا' },
|
||||
});
|
||||
|
||||
// ── Admin users (sales agents + one super admin) ────────────────────
|
||||
await prisma.adminUser.upsert({
|
||||
where: { mobile: '09120000001' },
|
||||
update: {},
|
||||
create: {
|
||||
mobile: '09120000001',
|
||||
fullName: 'مدیر ارشد سیستم',
|
||||
passwordHash: PLACEHOLDER_PASSWORD_HASH,
|
||||
role: AdminRole.SUPER_ADMIN,
|
||||
branchId: branchTehran.id,
|
||||
},
|
||||
});
|
||||
const agentReza = await prisma.adminUser.upsert({
|
||||
where: { mobile: '09121234567' },
|
||||
update: {},
|
||||
create: {
|
||||
mobile: '09121234567',
|
||||
fullName: 'رضا احمدی',
|
||||
passwordHash: PLACEHOLDER_PASSWORD_HASH,
|
||||
role: AdminRole.SALES_EXPERT,
|
||||
branchId: branchTehran.id,
|
||||
wagePercent: 1.5,
|
||||
},
|
||||
});
|
||||
const agentSara = await prisma.adminUser.upsert({
|
||||
where: { mobile: '09129876543' },
|
||||
update: {},
|
||||
create: {
|
||||
mobile: '09129876543',
|
||||
fullName: 'سارا کریمی',
|
||||
passwordHash: PLACEHOLDER_PASSWORD_HASH,
|
||||
role: AdminRole.SALES_EXPERT,
|
||||
branchId: branchMashhad.id,
|
||||
wagePercent: 1.2,
|
||||
},
|
||||
});
|
||||
await prisma.adminUser.upsert({
|
||||
where: { mobile: '09123334444' },
|
||||
update: {},
|
||||
create: {
|
||||
mobile: '09123334444',
|
||||
fullName: 'هومن یزدانی',
|
||||
passwordHash: PLACEHOLDER_PASSWORD_HASH,
|
||||
role: AdminRole.WORKSHOP_SUPERVISOR,
|
||||
},
|
||||
});
|
||||
await prisma.adminUser.upsert({
|
||||
where: { mobile: '09125556666' },
|
||||
update: {},
|
||||
create: {
|
||||
mobile: '09125556666',
|
||||
fullName: 'فرزانه محمودی',
|
||||
passwordHash: PLACEHOLDER_PASSWORD_HASH,
|
||||
role: AdminRole.ACCOUNTANT,
|
||||
branchId: branchTehran.id,
|
||||
},
|
||||
});
|
||||
|
||||
// ── Customers ────────────────────────────────────────────────────────
|
||||
const customerData = [
|
||||
{
|
||||
id: 'customer-1',
|
||||
fullName: 'علی محمدی',
|
||||
mobile: '09151112233',
|
||||
nationalCode: '0012345678',
|
||||
cityId: tehran.id,
|
||||
referredByAgentId: agentReza.id,
|
||||
goldCreditLimitGrams: '200',
|
||||
verificationStep: VerificationStep.VERIFIED,
|
||||
},
|
||||
{
|
||||
id: 'customer-2',
|
||||
fullName: 'مریم حسینی',
|
||||
mobile: '09151112234',
|
||||
nationalCode: '0012345679',
|
||||
cityId: tehran.id,
|
||||
referredByAgentId: agentReza.id,
|
||||
goldCreditLimitGrams: '100',
|
||||
verificationStep: VerificationStep.VERIFIED,
|
||||
},
|
||||
{
|
||||
id: 'customer-3',
|
||||
fullName: 'جواد نوروزی',
|
||||
mobile: '09151112235',
|
||||
nationalCode: '0012345680',
|
||||
cityId: mashhad.id,
|
||||
referredByAgentId: agentSara.id,
|
||||
goldCreditLimitGrams: '50',
|
||||
verificationStep: VerificationStep.STEP_TWO_SUBMITTED,
|
||||
},
|
||||
{
|
||||
id: 'customer-4',
|
||||
fullName: 'نرگس ابراهیمی',
|
||||
mobile: '09151112236',
|
||||
nationalCode: '0012345681',
|
||||
cityId: isfahan.id,
|
||||
referredByAgentId: null,
|
||||
goldCreditLimitGrams: '30',
|
||||
verificationStep: VerificationStep.UNVERIFIED,
|
||||
},
|
||||
{
|
||||
id: 'customer-5',
|
||||
fullName: 'کامران صادقی',
|
||||
mobile: '09151112237',
|
||||
nationalCode: '0012345682',
|
||||
cityId: tehran.id,
|
||||
referredByAgentId: agentReza.id,
|
||||
goldCreditLimitGrams: '500',
|
||||
verificationStep: VerificationStep.VERIFIED,
|
||||
},
|
||||
{
|
||||
id: 'customer-6',
|
||||
fullName: 'زهرا قاسمی',
|
||||
mobile: '09151112238',
|
||||
nationalCode: '0012345683',
|
||||
cityId: tabriz.id,
|
||||
referredByAgentId: agentSara.id,
|
||||
goldCreditLimitGrams: '80',
|
||||
verificationStep: VerificationStep.STEP_ONE_SUBMITTED,
|
||||
},
|
||||
];
|
||||
|
||||
const customers = [];
|
||||
for (const c of customerData) {
|
||||
customers.push(
|
||||
await prisma.customer.upsert({
|
||||
where: { id: c.id },
|
||||
update: {},
|
||||
create: {
|
||||
id: c.id,
|
||||
fullName: c.fullName,
|
||||
mobile: c.mobile,
|
||||
nationalCode: c.nationalCode,
|
||||
passwordHash: PLACEHOLDER_PASSWORD_HASH,
|
||||
cityId: c.cityId,
|
||||
referredByAgentId: c.referredByAgentId,
|
||||
goldCreditLimitGrams: c.goldCreditLimitGrams,
|
||||
verificationStep: c.verificationStep,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
const [customer1, customer2, , , customer5] = customers;
|
||||
|
||||
// ── Catalog: categories, brands, products, variants ────────────────
|
||||
const [ringsCategory, braceletsCategory, necklacesCategory, earringsCategory, coinsCategory] = await Promise.all([
|
||||
prisma.category.upsert({ where: { id: 'cat-rings' }, update: {}, create: { id: 'cat-rings', title: 'انگشتر' } }),
|
||||
prisma.category.upsert({ where: { id: 'cat-bracelets' }, update: {}, create: { id: 'cat-bracelets', title: 'دستبند' } }),
|
||||
prisma.category.upsert({ where: { id: 'cat-necklaces' }, update: {}, create: { id: 'cat-necklaces', title: 'گردنبند' } }),
|
||||
prisma.category.upsert({ where: { id: 'cat-earrings' }, update: {}, create: { id: 'cat-earrings', title: 'گوشواره' } }),
|
||||
prisma.category.upsert({ where: { id: 'cat-coins' }, update: {}, create: { id: 'cat-coins', title: 'سکه و شمش' } }),
|
||||
]);
|
||||
|
||||
const brandHenza = await prisma.brand.upsert({
|
||||
where: { id: 'brand-henza' },
|
||||
update: {},
|
||||
create: { id: 'brand-henza', title: 'هنزا طلا' },
|
||||
});
|
||||
const brandArtisan = await prisma.brand.upsert({
|
||||
where: { id: 'brand-artisan' },
|
||||
update: {},
|
||||
create: { id: 'brand-artisan', title: 'مجموعه صنعتگر' },
|
||||
});
|
||||
|
||||
const productDefs = [
|
||||
{
|
||||
id: 'product-classic-ring',
|
||||
title: 'انگشتر تک نگین کلاسیک',
|
||||
description: 'انگشتر طلای ۱۸ عیار با طراحی ساده و مینیمال.',
|
||||
categoryId: ringsCategory.id,
|
||||
brandId: brandHenza.id,
|
||||
variants: [
|
||||
{ id: 'variant-ring-size-6', title: 'سایز ۶', sku: 'RING-CLASSIC-6', rashWeightGrams: '2.850', defaultWagePercent: '9.0' },
|
||||
{ id: 'variant-ring-size-7', title: 'سایز ۷', sku: 'RING-CLASSIC-7', rashWeightGrams: '3.100', defaultWagePercent: '9.0' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'product-woven-bracelet',
|
||||
title: 'دستبند بافت زنجیری',
|
||||
description: 'دستبند بافتدار دستساز از طلای ۱۸ عیار.',
|
||||
categoryId: braceletsCategory.id,
|
||||
brandId: brandArtisan.id,
|
||||
variants: [
|
||||
{ id: 'variant-bracelet-standard', title: 'استاندارد ۱۸ سانتی', sku: 'BRC-WOVEN-18', rashWeightGrams: '14.200', defaultWagePercent: '11.5' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'product-pendant-necklace',
|
||||
title: 'گردنبند آویز اشکی',
|
||||
description: 'گردنبند ظریف با آویز اشکی و زنجیر نازک.',
|
||||
categoryId: necklacesCategory.id,
|
||||
brandId: brandHenza.id,
|
||||
variants: [
|
||||
{ id: 'variant-necklace-40cm', title: 'زنجیر ۴۰ سانتی', sku: 'NCK-TEARDROP-40', rashWeightGrams: '5.600', defaultWagePercent: '10.0' },
|
||||
{ id: 'variant-necklace-45cm', title: 'زنجیر ۴۵ سانتی', sku: 'NCK-TEARDROP-45', rashWeightGrams: '6.100', defaultWagePercent: '10.0' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'product-hoop-earrings',
|
||||
title: 'گوشواره حلقهای کلاسیک',
|
||||
description: 'گوشواره حلقهای طلای ۱۸ عیار مناسب استفاده روزمره.',
|
||||
categoryId: earringsCategory.id,
|
||||
brandId: brandArtisan.id,
|
||||
variants: [
|
||||
{ id: 'variant-hoop-small', title: 'کوچک', sku: 'ERG-HOOP-S', rashWeightGrams: '1.900', defaultWagePercent: '8.5' },
|
||||
{ id: 'variant-hoop-large', title: 'بزرگ', sku: 'ERG-HOOP-L', rashWeightGrams: '3.400', defaultWagePercent: '8.5' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'product-full-coin',
|
||||
title: 'سکه تمام بهار آزادی',
|
||||
description: 'سکه طلای تمام بهار آزادی، عیار استاندارد.',
|
||||
categoryId: coinsCategory.id,
|
||||
brandId: brandHenza.id,
|
||||
variants: [
|
||||
{ id: 'variant-coin-full', title: 'تمام سکه', sku: 'COIN-FULL', rashWeightGrams: '8.133', defaultWagePercent: '2.0' },
|
||||
{ id: 'variant-coin-half', title: 'نیم سکه', sku: 'COIN-HALF', rashWeightGrams: '4.066', defaultWagePercent: '2.5' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const createdVariantIds: Record<string, string> = {};
|
||||
let imageCount = 0;
|
||||
for (const p of productDefs) {
|
||||
const product = await prisma.product.upsert({
|
||||
where: { id: p.id },
|
||||
update: {},
|
||||
create: {
|
||||
id: p.id,
|
||||
title: p.title,
|
||||
description: p.description,
|
||||
categoryId: p.categoryId,
|
||||
brandId: p.brandId,
|
||||
},
|
||||
});
|
||||
for (const v of p.variants) {
|
||||
await prisma.productVariant.upsert({
|
||||
where: { id: v.id },
|
||||
update: {},
|
||||
create: {
|
||||
id: v.id,
|
||||
productId: product.id,
|
||||
title: v.title,
|
||||
sku: v.sku,
|
||||
rashWeightGrams: v.rashWeightGrams,
|
||||
defaultWagePercent: v.defaultWagePercent,
|
||||
},
|
||||
});
|
||||
createdVariantIds[v.id] = v.id;
|
||||
}
|
||||
|
||||
// Product photography does not exist in this project: legacy served it from
|
||||
// the old Laravel host (Product.thumb.full), so there is nothing to migrate.
|
||||
// Seed the placeholder the legacy app itself used for missing imagery so
|
||||
// every product card renders an image instead of collapsing to nothing.
|
||||
await prisma.productImage.upsert({
|
||||
where: { id: `${p.id}-img-1` },
|
||||
update: { url: PLACEHOLDER_PRODUCT_IMAGE },
|
||||
create: {
|
||||
id: `${p.id}-img-1`,
|
||||
productId: product.id,
|
||||
url: PLACEHOLDER_PRODUCT_IMAGE,
|
||||
sortOrder: 0,
|
||||
},
|
||||
});
|
||||
imageCount += 1;
|
||||
}
|
||||
|
||||
// ── Featured collection ──────────────────────────────────────────────
|
||||
const featured = await prisma.collection.upsert({
|
||||
where: { id: 'collection-featured' },
|
||||
update: {},
|
||||
create: { id: 'collection-featured', title: 'پرفروشهای این هفته', description: 'منتخبی از محبوبترین محصولات فروشگاه.' },
|
||||
});
|
||||
await prisma.collectionProduct.upsert({
|
||||
where: { collectionId_productId: { collectionId: featured.id, productId: 'product-classic-ring' } },
|
||||
update: {},
|
||||
create: { collectionId: featured.id, productId: 'product-classic-ring' },
|
||||
});
|
||||
await prisma.collectionProduct.upsert({
|
||||
where: { collectionId_productId: { collectionId: featured.id, productId: 'product-pendant-necklace' } },
|
||||
update: {},
|
||||
create: { collectionId: featured.id, productId: 'product-pendant-necklace' },
|
||||
});
|
||||
await prisma.collectionProduct.upsert({
|
||||
where: { collectionId_productId: { collectionId: featured.id, productId: 'product-full-coin' } },
|
||||
update: {},
|
||||
create: { collectionId: featured.id, productId: 'product-full-coin' },
|
||||
});
|
||||
|
||||
// ── Gold price quote (illustrative, not live-market-accurate) ───────
|
||||
await prisma.goldPriceQuote.create({
|
||||
data: { karatPerMille: 750, pricePerGramRial: '61500000', source: 'seed-illustrative' },
|
||||
});
|
||||
|
||||
// ── A delivered order + posted debt, so the dashboard shows real numbers ──
|
||||
const existingDemoOrder = await prisma.order.findUnique({ where: { orderNumber: 'ORD-SEED-0001' } });
|
||||
if (!existingDemoOrder) {
|
||||
const rashWeight = '3.100'; // variant-ring-size-7
|
||||
const wagePercent = 9.0;
|
||||
const discountPercent = 0;
|
||||
const computedWeight = (parseFloat(rashWeight) * (1 + (wagePercent - discountPercent) / 100)).toFixed(4);
|
||||
const quantity = 1;
|
||||
const flatRialChargePerUnit = '500000'; // e.g. a small stone-setting fee
|
||||
|
||||
const order = await prisma.order.create({
|
||||
data: {
|
||||
orderNumber: 'ORD-SEED-0001',
|
||||
customerId: customer1.id,
|
||||
branchId: branchTehran.id,
|
||||
status: 'DELIVERED_TO_CUSTOMER',
|
||||
totalGoldWeightGrams: computedWeight,
|
||||
wageWeightGrams: (parseFloat(computedWeight) - parseFloat(rashWeight)).toFixed(4),
|
||||
flatRialCharge: flatRialChargePerUnit,
|
||||
deliveredAt: new Date(),
|
||||
items: {
|
||||
create: [
|
||||
{
|
||||
productVariantId: 'variant-ring-size-7',
|
||||
quantity,
|
||||
rashWeightGramsSnapshot: rashWeight,
|
||||
wagePercentSnapshot: wagePercent.toString(),
|
||||
discountPercentSnapshot: discountPercent.toString(),
|
||||
computedWeightGrams: computedWeight,
|
||||
flatRialCharge: flatRialChargePerUnit,
|
||||
},
|
||||
],
|
||||
},
|
||||
statusEvents: {
|
||||
create: [{ toStatus: 'PLACED' }, { toStatus: 'ADMIN_APPROVED' }, { toStatus: 'DELIVERED_TO_CUSTOMER' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.ledgerEntry.create({
|
||||
data: {
|
||||
customerId: customer1.id,
|
||||
currency: 'GOLD_GRAM',
|
||||
direction: 'DEBIT',
|
||||
sourceType: 'ORDER_STOCK',
|
||||
amount: computedWeight,
|
||||
orderId: order.id,
|
||||
confirmed: true,
|
||||
confirmedAt: new Date(),
|
||||
note: 'بدهی وزنی طلا هنگام تحویل (داده آزمایشی)',
|
||||
},
|
||||
});
|
||||
await prisma.ledgerEntry.create({
|
||||
data: {
|
||||
customerId: customer1.id,
|
||||
currency: 'RIAL',
|
||||
direction: 'DEBIT',
|
||||
sourceType: 'ORDER_STOCK',
|
||||
amount: flatRialChargePerUnit,
|
||||
orderId: order.id,
|
||||
confirmed: true,
|
||||
confirmedAt: new Date(),
|
||||
note: 'هزینه ثابت ریالی هنگام تحویل (داده آزمایشی)',
|
||||
},
|
||||
});
|
||||
await prisma.customer.update({
|
||||
where: { id: customer1.id },
|
||||
data: { goldDebtGrams: { increment: computedWeight }, fiatDebtRial: { increment: flatRialChargePerUnit } },
|
||||
});
|
||||
|
||||
// A pending repayment awaiting admin confirmation, to demo the confirm flow.
|
||||
await prisma.ledgerEntry.create({
|
||||
data: {
|
||||
customerId: customer1.id,
|
||||
currency: 'GOLD_GRAM',
|
||||
direction: 'CREDIT',
|
||||
sourceType: 'REPAYMENT_MELTED_GOLD',
|
||||
amount: '1.5000',
|
||||
karatPerMille: 750,
|
||||
confirmed: false,
|
||||
note: 'تسویه با طلای آبشده، در انتظار تایید (داده آزمایشی)',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// customer2 and customer5 get existing debt/credit variety for the dashboard.
|
||||
await prisma.customer.update({ where: { id: customer2.id }, data: { goldDebtGrams: '8.2500', fiatDebtRial: '2000000' } });
|
||||
await prisma.customer.update({ where: { id: customer5.id }, data: { goldDebtGrams: '0', fiatDebtRial: '0' } });
|
||||
|
||||
console.log('Seed complete:', {
|
||||
cities: 4,
|
||||
branches: 2,
|
||||
adminUsers: 5,
|
||||
customers: customers.length,
|
||||
products: productDefs.length,
|
||||
variants: Object.keys(createdVariantIds).length,
|
||||
productImages: imageCount,
|
||||
});
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { CatalogModule } from './modules/catalog/catalog.module';
|
||||
import { CustomersModule } from './modules/customers/customers.module';
|
||||
import { OrdersModule } from './modules/orders/orders.module';
|
||||
import { AccountingModule } from './modules/accounting/accounting.module';
|
||||
import { PricingModule } from './modules/pricing/pricing.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, CatalogModule, CustomersModule, OrdersModule, AccountingModule, PricingModule],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,15 @@
|
||||
import 'reflect-metadata';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { AppModule } from './app.module';
|
||||
import { PrismaExceptionFilter } from './prisma/prisma-exception.filter';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
app.setGlobalPrefix('api');
|
||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
|
||||
app.useGlobalFilters(new PrismaExceptionFilter());
|
||||
app.enableCors();
|
||||
await app.listen(process.env.PORT ?? 3000);
|
||||
}
|
||||
bootstrap();
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
|
||||
import { LedgerService } from './ledger.service';
|
||||
import { PostGoldRepaymentDto } from './dto/post-gold-repayment.dto';
|
||||
import { PostRialRepaymentDto } from './dto/post-rial-repayment.dto';
|
||||
|
||||
@Controller('accounting')
|
||||
export class AccountingController {
|
||||
constructor(private readonly ledger: LedgerService) {}
|
||||
|
||||
@Get('customers/:customerId/balance')
|
||||
getBalance(@Param('customerId') customerId: string) {
|
||||
return this.ledger.getBalance(customerId);
|
||||
}
|
||||
|
||||
@Post('customers/:customerId/reconcile')
|
||||
reconcile(@Param('customerId') customerId: string) {
|
||||
return this.ledger.reconcileBalance(customerId);
|
||||
}
|
||||
|
||||
@Post('repayments/gold')
|
||||
postGoldRepayment(@Body() dto: PostGoldRepaymentDto) {
|
||||
return this.ledger.postGoldRepayment(dto);
|
||||
}
|
||||
|
||||
@Post('repayments/rial')
|
||||
postRialRepayment(@Body() dto: PostRialRepaymentDto) {
|
||||
return this.ledger.postRialRepayment(dto);
|
||||
}
|
||||
|
||||
@Post('entries/:entryId/confirm')
|
||||
confirmEntry(@Param('entryId') entryId: string) {
|
||||
return this.ledger.confirmEntry(entryId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { LedgerService } from './ledger.service';
|
||||
import { AccountingController } from './accounting.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [AccountingController],
|
||||
providers: [LedgerService],
|
||||
exports: [LedgerService],
|
||||
})
|
||||
export class AccountingModule {}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { IsIn, IsNumber, IsOptional, IsPositive, IsString } from 'class-validator';
|
||||
|
||||
export class PostGoldRepaymentDto {
|
||||
@IsString()
|
||||
customerId!: string;
|
||||
|
||||
@IsIn(['MELTED_GOLD', 'TRANSFER', 'CHEQUE', 'CASH'])
|
||||
instrument!: 'MELTED_GOLD' | 'TRANSFER' | 'CHEQUE' | 'CASH';
|
||||
|
||||
@IsNumber()
|
||||
@IsPositive()
|
||||
rawValue!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
karatPerMille?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@IsPositive()
|
||||
pricePerGramRial?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
note?: string;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { IsNumber, IsOptional, IsPositive, IsString } from 'class-validator';
|
||||
|
||||
export class PostRialRepaymentDto {
|
||||
@IsString()
|
||||
customerId!: string;
|
||||
|
||||
@IsNumber()
|
||||
@IsPositive()
|
||||
amount!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
note?: string;
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { LedgerCurrency, LedgerDirection, LedgerSourceType, Prisma, PrismaClient } from '@prisma/client';
|
||||
import { GRAMS_PER_MESGHAL, STANDARD_KARAT_PER_MILLE } from '@gold-bank/shared-types';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
type Tx = Prisma.TransactionClient | PrismaClient;
|
||||
|
||||
export interface PostEntryInput {
|
||||
customerId: string;
|
||||
currency: LedgerCurrency;
|
||||
direction: LedgerDirection;
|
||||
sourceType: LedgerSourceType;
|
||||
amount: Prisma.Decimal.Value;
|
||||
orderId?: string;
|
||||
karatPerMille?: number;
|
||||
pricePerGramRialAtEntry?: Prisma.Decimal.Value;
|
||||
note?: string;
|
||||
/** Order-delivery debt entries are self-confirming; repayments start pending. */
|
||||
confirmed?: boolean;
|
||||
postedByAdminUserId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The dual-currency accounting engine. LedgerEntry is append-only and is the
|
||||
* sole source of truth for customer debt; Customer.goldDebtGrams/fiatDebtRial
|
||||
* are denormalized caches kept in sync here and self-healed by reconcileBalance.
|
||||
*/
|
||||
@Injectable()
|
||||
export class LedgerService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/** Posts a ledger entry and, if confirmed, applies it to the cached customer balance. */
|
||||
async postEntry(input: PostEntryInput, tx: Tx = this.prisma) {
|
||||
const amount = new Prisma.Decimal(input.amount);
|
||||
if (amount.lte(0)) {
|
||||
throw new BadRequestException('Ledger entry amount must be positive');
|
||||
}
|
||||
const confirmed = input.confirmed ?? false;
|
||||
|
||||
const entry = await tx.ledgerEntry.create({
|
||||
data: {
|
||||
customerId: input.customerId,
|
||||
currency: input.currency,
|
||||
direction: input.direction,
|
||||
sourceType: input.sourceType,
|
||||
amount,
|
||||
orderId: input.orderId,
|
||||
karatPerMille: input.karatPerMille,
|
||||
pricePerGramRialAtEntry:
|
||||
input.pricePerGramRialAtEntry !== undefined ? new Prisma.Decimal(input.pricePerGramRialAtEntry) : undefined,
|
||||
note: input.note,
|
||||
confirmed,
|
||||
confirmedAt: confirmed ? new Date() : null,
|
||||
postedByAdminUserId: input.postedByAdminUserId,
|
||||
},
|
||||
});
|
||||
|
||||
if (confirmed) {
|
||||
await this.applyToBalance(input.customerId, input.currency, input.direction, amount, tx);
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a pending repayment entry confirmed. Legacy's confirmed toggle
|
||||
* (Adm\CashController::toggleConfiremCash) is bidirectional and used for
|
||||
* post-hoc correction/rejection — legacy Cash rows are auto-confirmed
|
||||
* (confirmed=1) at creation, not created pending. This system intentionally
|
||||
* requires an explicit confirmation step before a repayment counts (see
|
||||
* postGoldRepayment for the full rationale) — that is a deliberate design
|
||||
* change from legacy, not a faithful mirror of it.
|
||||
*/
|
||||
async confirmEntry(entryId: string, adminUserId?: string, tx: Tx = this.prisma) {
|
||||
const entry = await tx.ledgerEntry.findUnique({ where: { id: entryId } });
|
||||
if (!entry) throw new NotFoundException('Ledger entry not found');
|
||||
if (entry.confirmed) return entry;
|
||||
|
||||
const updated = await tx.ledgerEntry.update({
|
||||
where: { id: entryId },
|
||||
data: { confirmed: true, confirmedAt: new Date(), postedByAdminUserId: adminUserId ?? entry.postedByAdminUserId },
|
||||
});
|
||||
|
||||
await this.applyToBalance(entry.customerId, entry.currency, entry.direction, entry.amount, tx);
|
||||
return updated;
|
||||
}
|
||||
|
||||
private async applyToBalance(
|
||||
customerId: string,
|
||||
currency: LedgerCurrency,
|
||||
direction: LedgerDirection,
|
||||
amount: Prisma.Decimal,
|
||||
tx: Tx,
|
||||
) {
|
||||
const signedDelta = direction === LedgerDirection.DEBIT ? amount : amount.neg();
|
||||
await tx.customer.update({
|
||||
where: { id: customerId },
|
||||
data:
|
||||
currency === LedgerCurrency.GOLD_GRAM
|
||||
? { goldDebtGrams: { increment: signedDelta } }
|
||||
: { fiatDebtRial: { increment: signedDelta } },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts the dual-currency debt for a stock order once it reaches
|
||||
* DELIVERED_TO_AGENT: GOLD_GRAM debt = order.totalGoldWeightGrams, RIAL debt
|
||||
* = order.flatRialCharge. Both entries are self-confirming — the status
|
||||
* transition itself is the confirming event (mirrors legacy sts>4, which
|
||||
* first becomes true at sts=5 "تحویل مسئول فروش" / delivered to agent, not
|
||||
* sts=6 delivered to customer).
|
||||
*/
|
||||
async postOrderDebt(orderId: string, tx: Tx = this.prisma) {
|
||||
const order = await tx.order.findUnique({ where: { id: orderId } });
|
||||
if (!order) throw new NotFoundException('Order not found');
|
||||
|
||||
await this.postEntry(
|
||||
{
|
||||
customerId: order.customerId,
|
||||
currency: LedgerCurrency.GOLD_GRAM,
|
||||
direction: LedgerDirection.DEBIT,
|
||||
sourceType: LedgerSourceType.ORDER_STOCK,
|
||||
amount: order.totalGoldWeightGrams,
|
||||
orderId: order.id,
|
||||
confirmed: true,
|
||||
note: 'Gold-weight debt on delivery',
|
||||
},
|
||||
tx,
|
||||
);
|
||||
|
||||
if (new Prisma.Decimal(order.flatRialCharge).gt(0)) {
|
||||
await this.postEntry(
|
||||
{
|
||||
customerId: order.customerId,
|
||||
currency: LedgerCurrency.RIAL,
|
||||
direction: LedgerDirection.DEBIT,
|
||||
sourceType: LedgerSourceType.ORDER_STOCK,
|
||||
amount: order.flatRialCharge,
|
||||
orderId: order.id,
|
||||
confirmed: true,
|
||||
note: 'Flat Rial charge (e.g. stones) on delivery',
|
||||
},
|
||||
tx,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a physical repayment instrument into gold grams and posts a
|
||||
* pending (unconfirmed) CREDIT entry against the customer's gold debt.
|
||||
* Formulas preserved verbatim from legacy CashController::store. Note:
|
||||
* legacy auto-confirms (confirmed=1) every Cash row at creation — posting
|
||||
* this as pending instead is an intentional design change, not a mirror of
|
||||
* legacy's default.
|
||||
*/
|
||||
async postGoldRepayment(
|
||||
params: {
|
||||
customerId: string;
|
||||
instrument: 'MELTED_GOLD' | 'TRANSFER' | 'CHEQUE' | 'CASH';
|
||||
rawValue: Prisma.Decimal.Value;
|
||||
karatPerMille?: number;
|
||||
pricePerGramRial?: Prisma.Decimal.Value;
|
||||
note?: string;
|
||||
postedByAdminUserId?: string;
|
||||
},
|
||||
tx: Tx = this.prisma,
|
||||
) {
|
||||
const raw = new Prisma.Decimal(params.rawValue);
|
||||
let goldGramsValue: Prisma.Decimal;
|
||||
let sourceType: LedgerSourceType;
|
||||
|
||||
switch (params.instrument) {
|
||||
case 'MELTED_GOLD': {
|
||||
if (!params.karatPerMille) {
|
||||
throw new BadRequestException('karatPerMille is required for a melted-gold repayment');
|
||||
}
|
||||
// legacy: value = original_value * ayar / 750
|
||||
goldGramsValue = raw.mul(params.karatPerMille).div(STANDARD_KARAT_PER_MILLE);
|
||||
sourceType = LedgerSourceType.REPAYMENT_MELTED_GOLD;
|
||||
break;
|
||||
}
|
||||
case 'CASH': {
|
||||
if (!params.pricePerGramRial) {
|
||||
throw new BadRequestException('pricePerGramRial is required for a cash repayment');
|
||||
}
|
||||
// legacy: value = price / (price_per_gram / 4.331802) — preserved verbatim;
|
||||
// flagged in migration analysis as a formula worth stakeholder review.
|
||||
const pricePerGram = new Prisma.Decimal(params.pricePerGramRial);
|
||||
goldGramsValue = raw.div(pricePerGram.div(GRAMS_PER_MESGHAL));
|
||||
sourceType = LedgerSourceType.REPAYMENT_CASH;
|
||||
break;
|
||||
}
|
||||
case 'TRANSFER':
|
||||
goldGramsValue = raw;
|
||||
sourceType = LedgerSourceType.REPAYMENT_TRANSFER;
|
||||
break;
|
||||
case 'CHEQUE':
|
||||
goldGramsValue = raw;
|
||||
sourceType = LedgerSourceType.REPAYMENT_CHEQUE;
|
||||
break;
|
||||
}
|
||||
|
||||
return this.postEntry(
|
||||
{
|
||||
customerId: params.customerId,
|
||||
currency: LedgerCurrency.GOLD_GRAM,
|
||||
direction: LedgerDirection.CREDIT,
|
||||
sourceType,
|
||||
amount: goldGramsValue,
|
||||
karatPerMille: params.karatPerMille,
|
||||
pricePerGramRialAtEntry: params.pricePerGramRial,
|
||||
note: params.note,
|
||||
confirmed: false, // pending until an admin confirms — intentional deviation from legacy, which auto-confirms on creation
|
||||
postedByAdminUserId: params.postedByAdminUserId,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct Rial-debt repayment. Not present in the legacy system (its
|
||||
* rial_remain had no repayment offset at all — a gap flagged during
|
||||
* migration analysis) but supported here since the new ledger is symmetric.
|
||||
*/
|
||||
async postRialRepayment(
|
||||
params: { customerId: string; amount: Prisma.Decimal.Value; note?: string; postedByAdminUserId?: string },
|
||||
tx: Tx = this.prisma,
|
||||
) {
|
||||
return this.postEntry(
|
||||
{
|
||||
customerId: params.customerId,
|
||||
currency: LedgerCurrency.RIAL,
|
||||
direction: LedgerDirection.CREDIT,
|
||||
sourceType: LedgerSourceType.REPAYMENT_CASH,
|
||||
amount: params.amount,
|
||||
note: params.note,
|
||||
confirmed: false,
|
||||
postedByAdminUserId: params.postedByAdminUserId,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
}
|
||||
|
||||
async postAdjustment(
|
||||
params: {
|
||||
customerId: string;
|
||||
currency: LedgerCurrency;
|
||||
direction: LedgerDirection;
|
||||
amount: Prisma.Decimal.Value;
|
||||
note: string;
|
||||
postedByAdminUserId: string;
|
||||
},
|
||||
tx: Tx = this.prisma,
|
||||
) {
|
||||
return this.postEntry(
|
||||
{
|
||||
customerId: params.customerId,
|
||||
currency: params.currency,
|
||||
direction: params.direction,
|
||||
sourceType: LedgerSourceType.ADJUSTMENT,
|
||||
amount: params.amount,
|
||||
note: params.note,
|
||||
confirmed: true,
|
||||
postedByAdminUserId: params.postedByAdminUserId,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
}
|
||||
|
||||
async getBalance(customerId: string) {
|
||||
const customer = await this.prisma.customer.findUnique({
|
||||
where: { id: customerId },
|
||||
select: { id: true, goldDebtGrams: true, fiatDebtRial: true, goldCreditLimitGrams: true },
|
||||
});
|
||||
if (!customer) throw new NotFoundException('Customer not found');
|
||||
return {
|
||||
customerId: customer.id,
|
||||
goldDebtGrams: customer.goldDebtGrams,
|
||||
fiatDebtRial: customer.fiatDebtRial,
|
||||
goldCreditLimitGrams: customer.goldCreditLimitGrams,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Recomputes cached balances from confirmed LedgerEntry rows and overwrites
|
||||
* the cache — a self-healing reconciliation, run periodically or on demand,
|
||||
* unlike legacy's recompute-on-every-write (Customer::updateRemain).
|
||||
*/
|
||||
async reconcileBalance(customerId: string) {
|
||||
const [goldDebits, goldCredits, rialDebits, rialCredits] = await Promise.all([
|
||||
this.sumConfirmed(customerId, LedgerCurrency.GOLD_GRAM, LedgerDirection.DEBIT),
|
||||
this.sumConfirmed(customerId, LedgerCurrency.GOLD_GRAM, LedgerDirection.CREDIT),
|
||||
this.sumConfirmed(customerId, LedgerCurrency.RIAL, LedgerDirection.DEBIT),
|
||||
this.sumConfirmed(customerId, LedgerCurrency.RIAL, LedgerDirection.CREDIT),
|
||||
]);
|
||||
|
||||
return this.prisma.customer.update({
|
||||
where: { id: customerId },
|
||||
data: {
|
||||
goldDebtGrams: goldDebits.sub(goldCredits),
|
||||
fiatDebtRial: rialDebits.sub(rialCredits),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async sumConfirmed(customerId: string, currency: LedgerCurrency, direction: LedgerDirection) {
|
||||
const result = await this.prisma.ledgerEntry.aggregate({
|
||||
where: { customerId, currency, direction, confirmed: true },
|
||||
_sum: { amount: true },
|
||||
});
|
||||
return result._sum.amount ?? new Prisma.Decimal(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { CatalogService } from './catalog.service';
|
||||
|
||||
@Controller('catalog/products')
|
||||
export class CatalogController {
|
||||
constructor(private readonly catalog: CatalogService) {}
|
||||
|
||||
@Get()
|
||||
list() {
|
||||
return this.catalog.listProducts();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CatalogService } from './catalog.service';
|
||||
import { CatalogController } from './catalog.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [CatalogController],
|
||||
providers: [CatalogService],
|
||||
exports: [CatalogService],
|
||||
})
|
||||
export class CatalogModule {}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class CatalogService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
listProducts() {
|
||||
return this.prisma.product.findMany({
|
||||
where: { isActive: true },
|
||||
include: {
|
||||
variants: true,
|
||||
images: true,
|
||||
category: true,
|
||||
brand: true,
|
||||
collections: { include: { collection: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getVariantOrThrow(variantId: string) {
|
||||
const variant = await this.prisma.productVariant.findUnique({ where: { id: variantId } });
|
||||
if (!variant || !variant.isActive) {
|
||||
throw new NotFoundException(`Product variant ${variantId} not found or inactive`);
|
||||
}
|
||||
return variant;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Controller, Get, Param } from '@nestjs/common';
|
||||
import { CustomersService } from './customers.service';
|
||||
|
||||
@Controller('customers')
|
||||
export class CustomersController {
|
||||
constructor(private readonly customers: CustomersService) {}
|
||||
|
||||
@Get()
|
||||
list() {
|
||||
return this.customers.list();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
get(@Param('id') id: string) {
|
||||
return this.customers.getOrThrow(id);
|
||||
}
|
||||
|
||||
@Get(':id/orders')
|
||||
getOrders(@Param('id') id: string) {
|
||||
return this.customers.getOrders(id);
|
||||
}
|
||||
|
||||
@Get(':id/ledger-entries')
|
||||
getLedgerEntries(@Param('id') id: string) {
|
||||
return this.customers.getLedgerEntries(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CustomersService } from './customers.service';
|
||||
import { CustomersController } from './customers.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [CustomersController],
|
||||
providers: [CustomersService],
|
||||
exports: [CustomersService],
|
||||
})
|
||||
export class CustomersModule {}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
// passwordHash must never leave the API — every customer read goes through this.
|
||||
const PUBLIC_CUSTOMER_SELECT = {
|
||||
id: true,
|
||||
fullName: true,
|
||||
mobile: true,
|
||||
nationalCode: true,
|
||||
cityId: true,
|
||||
referredByAgentId: true,
|
||||
goldCreditLimitGrams: true,
|
||||
goldDebtGrams: true,
|
||||
fiatDebtRial: true,
|
||||
verificationStep: true,
|
||||
nationalIdImageUrl: true,
|
||||
businessLicenseUrl: true,
|
||||
shopImageUrl: true,
|
||||
isActive: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
} satisfies Prisma.CustomerSelect;
|
||||
|
||||
@Injectable()
|
||||
export class CustomersService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async getOrThrow(customerId: string) {
|
||||
const customer = await this.prisma.customer.findUnique({
|
||||
where: { id: customerId },
|
||||
select: PUBLIC_CUSTOMER_SELECT,
|
||||
});
|
||||
if (!customer) throw new NotFoundException(`Customer ${customerId} not found`);
|
||||
return customer;
|
||||
}
|
||||
|
||||
list() {
|
||||
return this.prisma.customer.findMany({
|
||||
select: PUBLIC_CUSTOMER_SELECT,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
getOrders(customerId: string) {
|
||||
return this.prisma.order.findMany({
|
||||
where: { customerId },
|
||||
include: { items: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
getLedgerEntries(customerId: string) {
|
||||
return this.prisma.ledgerEntry.findMany({
|
||||
where: { customerId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { ArrayMinSize, IsArray, IsInt, IsNumber, IsOptional, IsPositive, IsString, Min, ValidateNested } from 'class-validator';
|
||||
|
||||
export class CreateOrderItemInputDto {
|
||||
@IsString()
|
||||
productVariantId!: string;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
quantity!: number;
|
||||
|
||||
/** Making-charge percentage negotiated for this order (may differ from the variant default). */
|
||||
@IsNumber()
|
||||
wagePercent!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
discountPercent?: number;
|
||||
|
||||
/** Flat Rial charge per unit, independent of gold weight (e.g. stones). */
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
flatRialCharge?: number;
|
||||
}
|
||||
|
||||
export class CreateOrderDto {
|
||||
@IsString()
|
||||
customerId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
branchId?: string;
|
||||
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CreateOrderItemInputDto)
|
||||
items!: CreateOrderItemInputDto[];
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { IsEnum, IsOptional, IsString } from 'class-validator';
|
||||
import { OrderStatus } from '@prisma/client';
|
||||
|
||||
export class UpdateOrderStatusDto {
|
||||
@IsEnum(OrderStatus)
|
||||
status!: OrderStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
note?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
actorAdminUserId?: string;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
|
||||
import { OrdersService } from './orders.service';
|
||||
import { CreateOrderDto } from './dto/create-order.dto';
|
||||
import { UpdateOrderStatusDto } from './dto/update-order-status.dto';
|
||||
|
||||
@Controller('orders')
|
||||
export class OrdersController {
|
||||
constructor(private readonly orders: OrdersService) {}
|
||||
|
||||
@Post()
|
||||
create(@Body() dto: CreateOrderDto) {
|
||||
return this.orders.createOrder(dto);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
get(@Param('id') id: string) {
|
||||
return this.orders.getById(id);
|
||||
}
|
||||
|
||||
@Patch(':id/status')
|
||||
updateStatus(@Param('id') id: string, @Body() dto: UpdateOrderStatusDto) {
|
||||
return this.orders.updateStatus(id, dto.status, dto.actorAdminUserId, dto.note);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { OrdersService } from './orders.service';
|
||||
import { OrdersController } from './orders.controller';
|
||||
import { CatalogModule } from '../catalog/catalog.module';
|
||||
import { AccountingModule } from '../accounting/accounting.module';
|
||||
|
||||
@Module({
|
||||
imports: [CatalogModule, AccountingModule],
|
||||
controllers: [OrdersController],
|
||||
providers: [OrdersService],
|
||||
exports: [OrdersService],
|
||||
})
|
||||
export class OrdersModule {}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { OrderStatus, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { CatalogService } from '../catalog/catalog.service';
|
||||
import { LedgerService } from '../accounting/ledger.service';
|
||||
import { CreateOrderDto } from './dto/create-order.dto';
|
||||
|
||||
interface ComputedItem {
|
||||
productVariantId: string;
|
||||
quantity: number;
|
||||
rashWeightGramsSnapshot: Prisma.Decimal;
|
||||
wagePercentSnapshot: Prisma.Decimal;
|
||||
discountPercentSnapshot: Prisma.Decimal;
|
||||
computedWeightGrams: Prisma.Decimal;
|
||||
flatRialCharge: Prisma.Decimal;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OrdersService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly catalog: CatalogService,
|
||||
private readonly ledger: LedgerService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Places a stock order. Per line item:
|
||||
* w = rashWeightGrams * (1 + (wagePercent - discountPercent) / 100)
|
||||
* mirrors legacy ApiMobileController::setOrder. Debt is NOT posted here —
|
||||
* only once the order reaches DELIVERED_TO_AGENT (see updateStatus).
|
||||
*/
|
||||
async createOrder(dto: CreateOrderDto) {
|
||||
const customer = await this.prisma.customer.findUnique({ where: { id: dto.customerId } });
|
||||
if (!customer) throw new NotFoundException(`Customer ${dto.customerId} not found`);
|
||||
|
||||
const computedItems: ComputedItem[] = await Promise.all(
|
||||
dto.items.map(async (item) => {
|
||||
const variant = await this.catalog.getVariantOrThrow(item.productVariantId);
|
||||
const rashWeight = new Prisma.Decimal(variant.rashWeightGrams);
|
||||
const wagePercent = new Prisma.Decimal(item.wagePercent);
|
||||
const discountPercent = new Prisma.Decimal(item.discountPercent ?? 0);
|
||||
const netPercent = wagePercent.sub(discountPercent);
|
||||
const computedWeightGrams = rashWeight.mul(netPercent.div(100).add(1));
|
||||
|
||||
return {
|
||||
productVariantId: variant.id,
|
||||
quantity: item.quantity,
|
||||
rashWeightGramsSnapshot: rashWeight,
|
||||
wagePercentSnapshot: wagePercent,
|
||||
discountPercentSnapshot: discountPercent,
|
||||
computedWeightGrams,
|
||||
flatRialCharge: new Prisma.Decimal(item.flatRialCharge ?? 0),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
const totalGoldWeightGrams = computedItems.reduce(
|
||||
(sum, item) => sum.add(item.computedWeightGrams.mul(item.quantity)),
|
||||
new Prisma.Decimal(0),
|
||||
);
|
||||
const totalRashWeightGrams = computedItems.reduce(
|
||||
(sum, item) => sum.add(item.rashWeightGramsSnapshot.mul(item.quantity)),
|
||||
new Prisma.Decimal(0),
|
||||
);
|
||||
const wageWeightGrams = totalGoldWeightGrams.sub(totalRashWeightGrams);
|
||||
const flatRialCharge = computedItems.reduce(
|
||||
(sum, item) => sum.add(item.flatRialCharge.mul(item.quantity)),
|
||||
new Prisma.Decimal(0),
|
||||
);
|
||||
|
||||
// legacy: inValidOrder = (allGram + customer.remain) > customer.gram_level
|
||||
const isSuspendedOverCreditLimit = totalGoldWeightGrams
|
||||
.add(customer.goldDebtGrams)
|
||||
.gt(customer.goldCreditLimitGrams);
|
||||
const initialStatus = isSuspendedOverCreditLimit ? OrderStatus.SUSPENDED : OrderStatus.PLACED;
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const order = await tx.order.create({
|
||||
data: {
|
||||
orderNumber: this.generateOrderNumber(),
|
||||
customerId: dto.customerId,
|
||||
branchId: dto.branchId,
|
||||
status: initialStatus,
|
||||
totalGoldWeightGrams,
|
||||
wageWeightGrams,
|
||||
flatRialCharge,
|
||||
isSuspendedOverCreditLimit,
|
||||
items: {
|
||||
create: computedItems.map((item) => ({
|
||||
productVariantId: item.productVariantId,
|
||||
quantity: item.quantity,
|
||||
rashWeightGramsSnapshot: item.rashWeightGramsSnapshot,
|
||||
wagePercentSnapshot: item.wagePercentSnapshot,
|
||||
discountPercentSnapshot: item.discountPercentSnapshot,
|
||||
computedWeightGrams: item.computedWeightGrams,
|
||||
flatRialCharge: item.flatRialCharge,
|
||||
})),
|
||||
},
|
||||
statusEvents: {
|
||||
create: { toStatus: initialStatus, note: isSuspendedOverCreditLimit ? 'Credit limit exceeded' : undefined },
|
||||
},
|
||||
},
|
||||
include: { items: true, statusEvents: true },
|
||||
});
|
||||
|
||||
return order;
|
||||
});
|
||||
}
|
||||
|
||||
async updateStatus(orderId: string, newStatus: OrderStatus, actorAdminUserId?: string, note?: string) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const order = await tx.order.findUnique({ where: { id: orderId } });
|
||||
if (!order) throw new NotFoundException(`Order ${orderId} not found`);
|
||||
|
||||
const updated = await tx.order.update({
|
||||
where: { id: orderId },
|
||||
data: {
|
||||
status: newStatus,
|
||||
deliveredAt: newStatus === OrderStatus.DELIVERED_TO_CUSTOMER ? new Date() : order.deliveredAt,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.orderStatusEvent.create({
|
||||
data: { orderId, fromStatus: order.status, toStatus: newStatus, note, actorAdminUserId },
|
||||
});
|
||||
|
||||
// Debt recognition point — mirrors legacy sts>4, which first becomes true
|
||||
// at sts=5 ("تحویل مسئول فروش" / delivered to agent), not sts=6 (delivered
|
||||
// to customer). Only fires once: a second transition into this status
|
||||
// (e.g. a data-fix re-save) would double-post debt, so guard on the order
|
||||
// not already having deliveredAt.
|
||||
if (newStatus === OrderStatus.DELIVERED_TO_AGENT && !order.deliveredAt) {
|
||||
await this.ledger.postOrderDebt(orderId, tx);
|
||||
}
|
||||
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
getById(orderId: string) {
|
||||
return this.prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
include: { items: true, statusEvents: true, ledgerEntries: true },
|
||||
});
|
||||
}
|
||||
|
||||
private generateOrderNumber(): string {
|
||||
const random = Math.random().toString(36).slice(2, 8).toUpperCase();
|
||||
return `ORD-${Date.now()}-${random}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { PricingService } from './pricing.service';
|
||||
|
||||
@Controller('pricing')
|
||||
export class PricingController {
|
||||
constructor(private readonly pricing: PricingService) {}
|
||||
|
||||
@Get('latest')
|
||||
getLatest(@Query('karatPerMille') karatPerMille: string) {
|
||||
return this.pricing.getLatestQuote(Number(karatPerMille) || 750);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PricingService } from './pricing.service';
|
||||
import { PricingController } from './pricing.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [PricingController],
|
||||
providers: [PricingService],
|
||||
exports: [PricingService],
|
||||
})
|
||||
export class PricingModule {}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class PricingService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async recordQuote(karatPerMille: number, pricePerGramRial: Prisma.Decimal.Value, source: string) {
|
||||
return this.prisma.goldPriceQuote.create({
|
||||
data: { karatPerMille, pricePerGramRial, source },
|
||||
});
|
||||
}
|
||||
|
||||
async getLatestQuote(karatPerMille: number) {
|
||||
const quote = await this.prisma.goldPriceQuote.findFirst({
|
||||
where: { karatPerMille },
|
||||
orderBy: { quotedAt: 'desc' },
|
||||
});
|
||||
if (!quote) throw new NotFoundException(`No gold price quote recorded for ${karatPerMille}‰`);
|
||||
return quote;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { ArgumentsHost, Catch, ExceptionFilter, HttpStatus, Logger } from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* Without this, an unmigrated table (P2021), a bad foreign key (P2003), etc.
|
||||
* all surfaced as an opaque "Internal Server Error" with no indication of
|
||||
* the actual cause — this maps the common Prisma error codes to sensible
|
||||
* HTTP statuses and echoes the real reason back (safe in dev; consider
|
||||
* stripping detail in production).
|
||||
*/
|
||||
@Catch(Prisma.PrismaClientKnownRequestError, Prisma.PrismaClientValidationError)
|
||||
export class PrismaExceptionFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger('PrismaExceptionFilter');
|
||||
|
||||
catch(exception: Prisma.PrismaClientKnownRequestError | Prisma.PrismaClientValidationError, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
|
||||
this.logger.error(exception.message);
|
||||
|
||||
if (exception instanceof Prisma.PrismaClientValidationError) {
|
||||
return response.status(HttpStatus.BAD_REQUEST).json({
|
||||
statusCode: HttpStatus.BAD_REQUEST,
|
||||
error: 'Invalid request shape for the database query',
|
||||
message: exception.message.split('\n').pop()?.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
switch (exception.code) {
|
||||
case 'P2021': // table does not exist — schema not pushed/migrated
|
||||
return response.status(HttpStatus.SERVICE_UNAVAILABLE).json({
|
||||
statusCode: HttpStatus.SERVICE_UNAVAILABLE,
|
||||
error: 'Database schema not initialized',
|
||||
message: `${exception.meta?.table ?? 'A required table'} does not exist. Run "pnpm prisma:push" (or "pnpm prisma:migrate") against this DATABASE_URL.`,
|
||||
});
|
||||
case 'P2025': // record not found for update/delete
|
||||
return response.status(HttpStatus.NOT_FOUND).json({
|
||||
statusCode: HttpStatus.NOT_FOUND,
|
||||
error: 'Not found',
|
||||
message: exception.meta?.cause ?? 'Record not found',
|
||||
});
|
||||
case 'P2002': // unique constraint violation
|
||||
return response.status(HttpStatus.CONFLICT).json({
|
||||
statusCode: HttpStatus.CONFLICT,
|
||||
error: 'Conflict',
|
||||
message: `Unique constraint failed on: ${(exception.meta?.target as string[] | undefined)?.join(', ')}`,
|
||||
});
|
||||
case 'P2003': // foreign key constraint violation
|
||||
return response.status(HttpStatus.BAD_REQUEST).json({
|
||||
statusCode: HttpStatus.BAD_REQUEST,
|
||||
error: 'Invalid reference',
|
||||
message: `Foreign key constraint failed on: ${exception.meta?.field_name}`,
|
||||
});
|
||||
default:
|
||||
return response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
|
||||
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
error: 'Database error',
|
||||
message: `[${exception.code}] ${exception.message.split('\n').pop()?.trim()}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"strictNullChecks": true,
|
||||
"sourceMap": true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user