feat: initial commit
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user