import { BadRequestException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; /** * The making-charge («اجرت») formula — one place, used to price every order * line placed from the app and to show the same figure in the app beforehand * (owner ruling 2026-10-08: the number shown must be the number charged, and * nothing the app sends is trusted). * * Legacy, reconstructed from henza_pwa + ApiMobileController::setOrder: * percent_pay = customer.percent + variant.percentage_pay (Mixins/product getWage) * discount = the LARGEST active promotion for the product, * its category or its brand (getPromotion; customer-type off) * final % = percent_pay − discount (setOrder :558) * weight = rash × (1 + final/100) × qty * rial charge = 0 (cart.vue: rial_pay: 0) * * The admin «اجرت» screen may change the numbers AND the structure: each term * has a coefficient (0 switches it off), the promotion can be the largest or * the sum, a constant can be added, and the result can be floored, capped and * rounded. LEGACY_WAGE_FORMULA reproduces legacy exactly. */ export interface WageFormula { version: 1; /** × the variant's own making charge (legacy percentage_pay). */ variantCoef: string; /** × the customer's own percent (legacy customers.percent). */ customerCoef: string; /** × the promotion discount, SUBTRACTED. */ promotionCoef: string; /** Which promotion counts when several apply: legacy takes the largest. */ promotionPick: 'max' | 'sum'; /** Added as is, in percent. */ constant: string; /** Floor / cap on the final percent; null = none (legacy has neither). */ min: string | null; max: string | null; /** Round the final percent to this many decimals; null = none (legacy). */ decimals: number | null; } export const LEGACY_WAGE_FORMULA: WageFormula = Object.freeze({ version: 1, variantCoef: '1', customerCoef: '1', promotionCoef: '1', promotionPick: 'max', constant: '0', min: null, max: null, decimals: null, }); export interface WageInputs { variantPercent: Prisma.Decimal.Value; customerPercent: Prisma.Decimal.Value; /** Every active promotion that applies, in percent. */ promotionPercents: Prisma.Decimal.Value[]; } export interface WageBreakdown { variantTerm: string; customerTerm: string; /** The promotion percent picked (largest or sum) before its coefficient. */ promotionPercent: string; promotionTerm: string; constant: string; beforeLimits: string; /** Which limit changed the result, if any. */ limitedBy: 'min' | 'max' | null; finalPercent: string; /** * What the order line stores. OrdersService computes * weight = rash × (1 + (wage − discount)/100), so wage = final + discount term * and discount = discount term keeps that product equal to `finalPercent`. */ wagePercentSnapshot: string; discountPercentSnapshot: string; } const D = (v: Prisma.Decimal.Value | null | undefined) => new Prisma.Decimal(v ?? 0); export function computeWage(f: WageFormula, i: WageInputs): WageBreakdown { const promos = i.promotionPercents.map(D); const promo = promos.length === 0 ? D(0) : f.promotionPick === 'sum' ? promos.reduce((s, p) => s.add(p), D(0)) : promos.reduce((m, p) => (p.gt(m) ? p : m), promos[0]); const variantTerm = D(f.variantCoef).mul(D(i.variantPercent)); const customerTerm = D(f.customerCoef).mul(D(i.customerPercent)); const promotionTerm = D(f.promotionCoef).mul(promo); const constant = D(f.constant); const before = variantTerm.add(customerTerm).sub(promotionTerm).add(constant); let final = before; let limitedBy: WageBreakdown['limitedBy'] = null; if (f.min !== null && final.lt(D(f.min))) { final = D(f.min); limitedBy = 'min'; } if (f.max !== null && final.gt(D(f.max))) { final = D(f.max); limitedBy = 'max'; } if (f.decimals !== null) final = final.toDecimalPlaces(f.decimals, Prisma.Decimal.ROUND_HALF_UP); return { variantTerm: variantTerm.toString(), customerTerm: customerTerm.toString(), promotionPercent: promo.toString(), promotionTerm: promotionTerm.toString(), constant: constant.toString(), beforeLimits: before.toString(), limitedBy, finalPercent: final.toString(), wagePercentSnapshot: final.add(promotionTerm).toString(), discountPercentSnapshot: promotionTerm.toString(), }; } /** A worked example for the admin screen: the formula applied to one order line. */ export interface WageSample { rashWeightGrams: string; quantity: number; variantPercent: string; customerPercent: string; promotionPercent: string; } export const DEFAULT_WAGE_SAMPLE: WageSample = Object.freeze({ rashWeightGrams: '10', quantity: 1, variantPercent: '7', customerPercent: '3', promotionPercent: '0', }); export function computeSample(f: WageFormula, s: WageSample) { const wage = computeWage(f, { variantPercent: s.variantPercent, customerPercent: s.customerPercent, promotionPercents: D(s.promotionPercent).isZero() ? [] : [s.promotionPercent], }); const rash = D(s.rashWeightGrams).mul(s.quantity); // Same formula as OrdersService.createOrder. const total = rash.mul(D(wage.finalPercent).div(100).add(1)); return { ...wage, rashTotalGrams: rash.toString(), wageGrams: total.sub(rash).toDecimalPlaces(4).toString(), totalGrams: total.toDecimalPlaces(4).toString(), }; } /* ───────────── validation (admin input) ───────────── */ function num(v: unknown, field: string, lo: number, hi: number): string { const s = String(v ?? '').trim(); let d: Prisma.Decimal; try { d = new Prisma.Decimal(s); } catch { throw new BadRequestException(`${field}: عدد معتبر نیست`); } if (!d.isFinite() || d.lt(lo) || d.gt(hi)) throw new BadRequestException(`${field}: باید بین ${lo} و ${hi} باشد`); return d.toString(); } function optNum(v: unknown, field: string, lo: number, hi: number): string | null { return v === null || v === undefined || String(v).trim() === '' ? null : num(v, field, lo, hi); } export function parseWageFormula(raw: unknown): WageFormula { const r = (raw ?? {}) as Record; const pick = r.promotionPick === 'sum' ? 'sum' : r.promotionPick === 'max' ? 'max' : null; if (!pick) throw new BadRequestException('نحوه‌ی انتخاب تخفیف باید «بیشترین» یا «جمع» باشد'); const decimals = r.decimals === null || r.decimals === undefined || r.decimals === '' ? null : Number(num(r.decimals, 'تعداد رقم اعشار', 0, 4)); if (decimals !== null && !Number.isInteger(decimals)) throw new BadRequestException('تعداد رقم اعشار باید عدد صحیح باشد'); const f: WageFormula = { version: 1, variantCoef: num(r.variantCoef, 'ضریب اجرت مدل', -10, 10), customerCoef: num(r.customerCoef, 'ضریب درصد مشتری', -10, 10), promotionCoef: num(r.promotionCoef, 'ضریب تخفیف', -10, 10), promotionPick: pick, constant: num(r.constant, 'مقدار ثابت', -100, 100), min: optNum(r.min, 'حداقل اجرت', -100, 1000), max: optNum(r.max, 'حداکثر اجرت', -100, 1000), decimals, }; if (f.min !== null && f.max !== null && D(f.min).gt(D(f.max))) { throw new BadRequestException('حداقل اجرت نمی‌تواند از حداکثر بیشتر باشد'); } return f; } export function parseWageSample(raw: unknown): WageSample { const r = (raw ?? {}) as Record; const qty = Number(r.quantity); if (!Number.isInteger(qty) || qty < 1 || qty > 1000) throw new BadRequestException('تعداد نمونه باید عدد صحیح بین ۱ و ۱۰۰۰ باشد'); return { rashWeightGrams: num(r.rashWeightGrams, 'وزن خام نمونه', 0, 100000), quantity: qty, variantPercent: num(r.variantPercent, 'اجرت مدل نمونه', -100, 1000), customerPercent: num(r.customerPercent, 'درصد مشتری نمونه', -100, 1000), promotionPercent: num(r.promotionPercent, 'تخفیف نمونه', 0, 100), }; }