Legacy: final % = variant % + customer % − the largest active promotion on the product, its category or its brand; rial charge 0. The apps sent the variant's own % only, so the new app undercharged by the customer's percent (usually 3) and ignored promotions, and the server trusted whatever the app sent. - pricing/wage-formula.ts: one formula, legacy by default, with coefficients, promotion pick, constant, floor/cap and rounding so the admin screen can change it; validated - an app order is priced by it; the app's wage, discount and rial charge are ignored; an operator's manual numbers are kept - POST customer/me/wage-quote; both apps show that figure - admin/pricing/wage-formula (config.wage): read, preview, approve with the sample it was checked on, last 20 versions kept; the setting is kept off the generic const routes Owner approval: 2026-10-08. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
212 lines
8.2 KiB
TypeScript
212 lines
8.2 KiB
TypeScript
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<string, unknown>;
|
||
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<string, unknown>;
|
||
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),
|
||
};
|
||
}
|