Files
tehran_taj/apps/api/src/modules/pricing/wage-formula.ts
T
s.esmaeiliandClaude Opus 5.5 699657e483 feat(pricing): the server prices app orders with legacy's making-charge formula (MON-08)
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>
2026-10-08 11:26:55 +03:30

212 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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),
};
}