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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
4b7b2473fc
commit
699657e483
@@ -0,0 +1,9 @@
|
||||
-- «اجرت» admin screen (owner ruling 2026-10-08): its permission for the
|
||||
-- seeded super-admin user type. A fresh seed grants every rid in ALL_RIDS;
|
||||
-- an existing database gets the new one here. Data only, idempotent.
|
||||
-- The formula itself needs no row: until an admin approves one, the server
|
||||
-- uses legacy's formula (pricing/wage-formula.ts LEGACY_WAGE_FORMULA).
|
||||
INSERT INTO "permission" ("rid", "userTypeId")
|
||||
SELECT 'config.wage', 85
|
||||
WHERE EXISTS (SELECT 1 FROM "user_type" WHERE "id" = 85)
|
||||
ON CONFLICT DO NOTHING;
|
||||
@@ -14,6 +14,7 @@ import { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
import { CurrentAdmin, RequirePermission } from '../auth/guards';
|
||||
import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
|
||||
import { isAccountingSettingKey } from '../accounting-sync/accounting-config';
|
||||
import { PRICING_SETTING_PREFIX } from '../pricing/wage.service';
|
||||
|
||||
/**
|
||||
* Cities, SMS, notifications, newsletter, activity log and settings — the
|
||||
@@ -228,7 +229,7 @@ export class AdminMiscController {
|
||||
// `custom.config.statics.slider`) simply by calling `const/mobile_main_slider`
|
||||
// directly, since the underlying Setting row is keyed the same either way.
|
||||
private assertNotSliderKey(key: string) {
|
||||
if (key === 'mobile_main_slider' || isAccountingSettingKey(key)) {
|
||||
if (key === 'mobile_main_slider' || isAccountingSettingKey(key) || key.trim().toLowerCase().startsWith(PRICING_SETTING_PREFIX)) {
|
||||
throw new ForbiddenException('این تنظیم فقط از مسیر اختصاصی خود قابل دسترسی است');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { TableService } from '../admin-table/table.service';
|
||||
import type { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
import { ACCOUNTING_SETTING_PREFIX } from '../accounting-sync/accounting-config';
|
||||
import { PRICING_SETTING_PREFIX } from '../pricing/wage.service';
|
||||
|
||||
/**
|
||||
* The remaining admin screens — legacy views/city, views/sms, views/notify,
|
||||
@@ -482,7 +483,7 @@ export class AdminMiscService {
|
||||
// lease) have their own `config.accounting` screen and never appear here
|
||||
// (D27); the single-key routes refuse them in the controller.
|
||||
const rows = await this.prisma.setting.findMany({
|
||||
where: { NOT: { key: { startsWith: ACCOUNTING_SETTING_PREFIX } } },
|
||||
where: { AND: [{ NOT: { key: { startsWith: ACCOUNTING_SETTING_PREFIX } } }, { NOT: { key: { startsWith: PRICING_SETTING_PREFIX } } }] },
|
||||
orderBy: { key: 'asc' },
|
||||
});
|
||||
return rows.map((r) => ({ key: r.key, value: r.value, updated_at: r.updatedAt }));
|
||||
|
||||
@@ -34,6 +34,7 @@ export const ALL_RIDS = [
|
||||
'comment.view.edit',
|
||||
// D27 — «اتصال حسابداری» (Tahesab / Zarrin). Not a legacy rid.
|
||||
'config.accounting',
|
||||
'config.wage',
|
||||
'config.report',
|
||||
'config.statics',
|
||||
'config.statics.brand',
|
||||
|
||||
@@ -3,9 +3,10 @@ import { OrdersService } from './orders.service';
|
||||
import { OrdersController } from './orders.controller';
|
||||
import { CatalogModule } from '../catalog/catalog.module';
|
||||
import { AccountingModule } from '../accounting/accounting.module';
|
||||
import { PricingModule } from '../pricing/pricing.module';
|
||||
|
||||
@Module({
|
||||
imports: [CatalogModule, AccountingModule],
|
||||
imports: [CatalogModule, AccountingModule, PricingModule],
|
||||
controllers: [OrdersController],
|
||||
providers: [OrdersService],
|
||||
exports: [OrdersService],
|
||||
|
||||
@@ -30,7 +30,8 @@ describe('OrdersService.createOrder — sales expert credit', () => {
|
||||
};
|
||||
const catalog = { getVariantOrThrow: jest.fn(async () => ({ id: 'v1', rashWeightGrams: '10' })) };
|
||||
const accounting = { orderStatusReached: jest.fn() };
|
||||
const service = new OrdersService(prisma as never, catalog as never, {} as never, accounting as never);
|
||||
const wage = { quote: jest.fn() };
|
||||
const service = new OrdersService(prisma as never, catalog as never, {} as never, accounting as never, wage as never);
|
||||
return { service, created };
|
||||
}
|
||||
|
||||
@@ -49,6 +50,11 @@ describe('OrdersService.createOrder — sales expert credit', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/** A server quote of 10% for every variant. */
|
||||
const wageStub = {
|
||||
quote: jest.fn(async (_c: string, ids: string[]) => new Map(ids.map((id) => [id, { wagePercentSnapshot: '10', discountPercentSnapshot: '0' }]))),
|
||||
};
|
||||
|
||||
/** MON-05 (owner ruling 2026-10-08): «عدم سفارش» stops app orders only. */
|
||||
describe('OrdersService.createOrder — order ban', () => {
|
||||
function build(canPlaceOrders: boolean) {
|
||||
@@ -66,7 +72,7 @@ describe('OrdersService.createOrder — order ban', () => {
|
||||
$transaction: jest.fn(async (fn: (t: typeof tx) => unknown) => fn(tx)),
|
||||
};
|
||||
const catalog = { getVariantOrThrow: jest.fn(async () => ({ id: 'v1', rashWeightGrams: '10' })) };
|
||||
return new OrdersService(prisma as never, catalog as never, {} as never, { orderStatusReached: jest.fn() } as never);
|
||||
return new OrdersService(prisma as never, catalog as never, {} as never, { orderStatusReached: jest.fn() } as never, wageStub as never);
|
||||
}
|
||||
const dto = { items: [{ productVariantId: 'v1', quantity: 1, wagePercent: 3 }] } as never;
|
||||
|
||||
@@ -82,3 +88,34 @@ describe('OrdersService.createOrder — order ban', () => {
|
||||
await expect(build(true).createOrder(dto, 'c1', { placedBy: 'customer' })).resolves.toMatchObject({ id: 'o1' });
|
||||
});
|
||||
});
|
||||
|
||||
/** MON-08 (owner ruling 2026-10-08): an app order is priced by the server, never by the app. */
|
||||
describe('OrdersService.createOrder — server-side making charge', () => {
|
||||
function build() {
|
||||
const created: Record<string, any>[] = [];
|
||||
const tx = { order: { create: jest.fn(async ({ data }: { data: Record<string, any> }) => { created.push(data); return { id: 'o1', ...data }; }) } };
|
||||
const prisma = {
|
||||
customer: { findUnique: jest.fn(async () => ({ id: 'c1', canPlaceOrders: true, referredByAgentId: null, goldDebtGrams: new Prisma.Decimal(0), goldCreditLimitGrams: new Prisma.Decimal(1000) })) },
|
||||
$transaction: jest.fn(async (fn: (t: typeof tx) => unknown) => fn(tx)),
|
||||
};
|
||||
const catalog = { getVariantOrThrow: jest.fn(async () => ({ id: 'v1', rashWeightGrams: '10' })) };
|
||||
const service = new OrdersService(prisma as never, catalog as never, {} as never, { orderStatusReached: jest.fn() } as never, wageStub as never);
|
||||
return { service, created };
|
||||
}
|
||||
// A tampered request: zero making charge, a 50% discount and a rial charge.
|
||||
const tampered = { items: [{ productVariantId: 'v1', quantity: 1, wagePercent: 0, discountPercent: 50, flatRialCharge: 999 }] } as never;
|
||||
|
||||
it('ignores what the app sends and uses the server quote', async () => {
|
||||
const { service, created } = build();
|
||||
await service.createOrder(tampered, 'c1', { placedBy: 'customer' });
|
||||
expect(created[0].totalGoldWeightGrams.toString()).toBe('11');
|
||||
expect(created[0].flatRialCharge.toString()).toBe('0');
|
||||
});
|
||||
|
||||
it('keeps the numbers an operator entered in the panel', async () => {
|
||||
const { service, created } = build();
|
||||
await service.createOrder(tampered, 'c1', { placedBy: 'operator' });
|
||||
expect(created[0].totalGoldWeightGrams.toString()).toBe('5');
|
||||
expect(created[0].flatRialCharge.toString()).toBe('999');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { CatalogService } from '../catalog/catalog.service';
|
||||
import { LedgerService } from '../accounting/ledger.service';
|
||||
import { AccountingHooksService } from '../accounting-sync/accounting-hooks.service';
|
||||
import { CreateOrderDto } from './dto/create-order.dto';
|
||||
import { WageService } from '../pricing/wage.service';
|
||||
|
||||
interface ComputedItem {
|
||||
productVariantId: string;
|
||||
@@ -25,6 +26,7 @@ export class OrdersService {
|
||||
private readonly catalog: CatalogService,
|
||||
private readonly ledger: LedgerService,
|
||||
private readonly accounting: AccountingHooksService,
|
||||
private readonly wage: WageService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -65,12 +67,25 @@ export class OrdersService {
|
||||
);
|
||||
}
|
||||
|
||||
// MON-08 (owner ruling 2026-10-08): an app order is priced by the server's
|
||||
// making-charge formula (pricing/wage-formula.ts — legacy's by default), the
|
||||
// same figure the app showed. Whatever the app sends for wage, discount or
|
||||
// rial charge is ignored, so a tampered request cannot set its own price.
|
||||
// An operator ordering from the panel keeps the numbers they entered, as
|
||||
// legacy's order builder did.
|
||||
const quotes =
|
||||
opts.placedBy === 'customer'
|
||||
? await this.wage.quote(customerId, dto.items.map((i) => i.productVariantId))
|
||||
: null;
|
||||
|
||||
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 quote = quotes?.get(variant.id);
|
||||
if (quotes && !quote) throw new NotFoundException(`Variant ${variant.id} not found`);
|
||||
const wagePercent = new Prisma.Decimal(quote ? quote.wagePercentSnapshot : item.wagePercent);
|
||||
const discountPercent = new Prisma.Decimal(quote ? quote.discountPercentSnapshot : (item.discountPercent ?? 0));
|
||||
const netPercent = wagePercent.sub(discountPercent);
|
||||
const computedWeightGrams = rashWeight.mul(netPercent.div(100).add(1));
|
||||
|
||||
@@ -81,7 +96,8 @@ export class OrdersService {
|
||||
wagePercentSnapshot: wagePercent,
|
||||
discountPercentSnapshot: discountPercent,
|
||||
computedWeightGrams,
|
||||
flatRialCharge: new Prisma.Decimal(item.flatRialCharge ?? 0),
|
||||
// Legacy app: rial_pay is always 0 (cart.vue); only an operator sets one.
|
||||
flatRialCharge: new Prisma.Decimal(quotes ? 0 : (item.flatRialCharge ?? 0)),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PricingService } from './pricing.service';
|
||||
import { PricingController } from './pricing.controller';
|
||||
import { WageService } from './wage.service';
|
||||
import { WageAdminController, WageCustomerController } from './wage.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [PricingController],
|
||||
providers: [PricingService],
|
||||
exports: [PricingService],
|
||||
controllers: [PricingController, WageAdminController, WageCustomerController],
|
||||
providers: [PricingService, WageService],
|
||||
exports: [PricingService, WageService],
|
||||
})
|
||||
export class PricingModule {}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { computeSample, computeWage, LEGACY_WAGE_FORMULA, parseWageFormula, type WageFormula } from './wage-formula';
|
||||
|
||||
/**
|
||||
* The making-charge formula. The default must reproduce legacy exactly:
|
||||
* final % = variant % + customer % − largest active promotion
|
||||
* weight = rash × (1 + final/100) × qty
|
||||
*/
|
||||
describe('wage formula', () => {
|
||||
const legacy = LEGACY_WAGE_FORMULA;
|
||||
|
||||
it('legacy: variant + customer, no promotion', () => {
|
||||
const w = computeWage(legacy, { variantPercent: '7', customerPercent: '3', promotionPercents: [] });
|
||||
expect(w.finalPercent).toBe('10');
|
||||
expect(w.wagePercentSnapshot).toBe('10');
|
||||
expect(w.discountPercentSnapshot).toBe('0');
|
||||
});
|
||||
|
||||
it('legacy: the LARGEST promotion is subtracted, not the sum', () => {
|
||||
const w = computeWage(legacy, { variantPercent: '7', customerPercent: '3', promotionPercents: ['1', '2.5', '2'] });
|
||||
expect(w.promotionPercent).toBe('2.5');
|
||||
expect(w.finalPercent).toBe('7.5');
|
||||
// Stored so that wage − discount = final, which is what createOrder multiplies by.
|
||||
expect(w.wagePercentSnapshot).toBe('10');
|
||||
expect(w.discountPercentSnapshot).toBe('2.5');
|
||||
});
|
||||
|
||||
it('legacy sample weight matches setOrder: 10 g × (1 + 10%) = 11 g', () => {
|
||||
const r = computeSample(legacy, { rashWeightGrams: '10', quantity: 1, variantPercent: '7', customerPercent: '3', promotionPercent: '0' });
|
||||
expect(r.totalGrams).toBe('11');
|
||||
expect(r.wageGrams).toBe('1');
|
||||
});
|
||||
|
||||
it('a changed structure: no customer term, sum of promotions, +0.5, floor 5, 2 decimals', () => {
|
||||
const f: WageFormula = { ...legacy, customerCoef: '0', promotionPick: 'sum', constant: '0.5', min: '5', decimals: 2 };
|
||||
expect(computeWage(f, { variantPercent: '6', customerPercent: '3', promotionPercents: ['1', '1'] }).finalPercent).toBe('5');
|
||||
const w = computeWage(f, { variantPercent: '7.333', customerPercent: '3', promotionPercents: [] });
|
||||
expect(w.finalPercent).toBe('7.83');
|
||||
expect(w.limitedBy).toBeNull();
|
||||
});
|
||||
|
||||
it('the cap is reported when it bites', () => {
|
||||
const w = computeWage({ ...legacy, max: '8' }, { variantPercent: '7', customerPercent: '3', promotionPercents: [] });
|
||||
expect(w).toMatchObject({ finalPercent: '8', limitedBy: 'max', beforeLimits: '10' });
|
||||
});
|
||||
|
||||
it('rejects nonsense from the admin form', () => {
|
||||
expect(() => parseWageFormula({ ...legacy, variantCoef: 'abc' })).toThrow(BadRequestException);
|
||||
expect(() => parseWageFormula({ ...legacy, min: '9', max: '8' })).toThrow(BadRequestException);
|
||||
expect(() => parseWageFormula({ ...legacy, promotionPick: 'avg' })).toThrow(BadRequestException);
|
||||
expect(() => parseWageFormula({ ...legacy, decimals: 9 })).toThrow(BadRequestException);
|
||||
expect(parseWageFormula({ ...legacy })).toEqual(legacy);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
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),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Body, Controller, Get, HttpCode, Post, Put } from '@nestjs/common';
|
||||
import { ArrayMaxSize, IsArray, IsString } from 'class-validator';
|
||||
import { CurrentAdmin, RequirePermission } from '../auth/guards';
|
||||
import type { AuthenticatedAdmin } from '../auth/jwt.strategy';
|
||||
import { CurrentCustomer, CustomerAuth } from '../customer-auth/customer.guards';
|
||||
import type { AuthenticatedCustomer } from '../customer-auth/customer-jwt.strategy';
|
||||
import { WageService } from './wage.service';
|
||||
|
||||
/**
|
||||
* «اجرت» admin screen — the making-charge formula, a worked sample, and the
|
||||
* approval that puts a new formula in force. Behind its own rid.
|
||||
*/
|
||||
@Controller('admin/pricing/wage-formula')
|
||||
@RequirePermission('config.wage')
|
||||
export class WageAdminController {
|
||||
constructor(private readonly wage: WageService) {}
|
||||
|
||||
@Get()
|
||||
get() {
|
||||
return this.wage.getConfig();
|
||||
}
|
||||
|
||||
/** Nothing is saved: the draft and the formula in force on the same sample. */
|
||||
@Post('preview')
|
||||
@HttpCode(200)
|
||||
preview(@Body() body: { formula?: unknown; sample?: unknown }) {
|
||||
return this.wage.preview(body?.formula, body?.sample);
|
||||
}
|
||||
|
||||
/** Puts the formula in force, recorded with the sample it was checked on. */
|
||||
@Put()
|
||||
approve(@Body() body: { formula?: unknown; sample?: unknown }, @CurrentAdmin() admin: AuthenticatedAdmin) {
|
||||
return this.wage.approve(body?.formula, body?.sample, admin);
|
||||
}
|
||||
}
|
||||
|
||||
class WageQuoteDto {
|
||||
@IsArray()
|
||||
@ArrayMaxSize(200)
|
||||
@IsString({ each: true })
|
||||
variantIds!: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The making charge the app shows on a product and in the cart — computed by
|
||||
* the same code that prices the order, for the signed-in customer.
|
||||
*/
|
||||
@Controller('customer/me/wage-quote')
|
||||
export class WageCustomerController {
|
||||
constructor(private readonly wage: WageService) {}
|
||||
|
||||
@CustomerAuth()
|
||||
@Post()
|
||||
@HttpCode(200)
|
||||
async quote(@Body() dto: WageQuoteDto, @CurrentCustomer() me: AuthenticatedCustomer) {
|
||||
return [...(await this.wage.quote(me.id, dto.variantIds)).values()];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma, PromotionScope } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import {
|
||||
computeSample,
|
||||
computeWage,
|
||||
DEFAULT_WAGE_SAMPLE,
|
||||
LEGACY_WAGE_FORMULA,
|
||||
parseWageFormula,
|
||||
parseWageSample,
|
||||
type WageBreakdown,
|
||||
type WageFormula,
|
||||
type WageSample,
|
||||
} from './wage-formula';
|
||||
|
||||
/** Setting row holding the formula; kept off the generic `const` routes (see admin-misc). */
|
||||
export const WAGE_FORMULA_SETTING_KEY = 'pricing.wageFormula';
|
||||
export const PRICING_SETTING_PREFIX = 'pricing.';
|
||||
|
||||
interface Approval {
|
||||
formula: WageFormula;
|
||||
sample: WageSample;
|
||||
approvedAt: string;
|
||||
approvedBy: { id: string; name: string };
|
||||
}
|
||||
|
||||
interface StoredWageFormula extends Approval {
|
||||
/** Earlier approved versions, newest first (last 20). */
|
||||
history: Approval[];
|
||||
}
|
||||
|
||||
type Tx = Prisma.TransactionClient | PrismaService;
|
||||
|
||||
/** One variant's making charge for one customer, as charged and as shown. */
|
||||
export interface WageQuote extends WageBreakdown {
|
||||
variantId: string;
|
||||
variantPercent: string;
|
||||
customerPercent: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WageService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
private async stored(tx: Tx = this.prisma): Promise<StoredWageFormula | null> {
|
||||
const row = await tx.setting.findUnique({ where: { key: WAGE_FORMULA_SETTING_KEY } });
|
||||
return row ? (row.value as unknown as StoredWageFormula) : null;
|
||||
}
|
||||
|
||||
/** The formula in force; legacy's exactly until an admin approves another. */
|
||||
async formula(tx: Tx = this.prisma): Promise<WageFormula> {
|
||||
const s = await this.stored(tx);
|
||||
return s ? parseWageFormula(s.formula) : { ...LEGACY_WAGE_FORMULA };
|
||||
}
|
||||
|
||||
/* ───────────── admin screen ───────────── */
|
||||
|
||||
async getConfig() {
|
||||
const s = await this.stored();
|
||||
const formula = s ? parseWageFormula(s.formula) : { ...LEGACY_WAGE_FORMULA };
|
||||
const sample = s ? parseWageSample(s.sample) : { ...DEFAULT_WAGE_SAMPLE };
|
||||
return {
|
||||
formula,
|
||||
sample,
|
||||
result: computeSample(formula, sample),
|
||||
approvedAt: s?.approvedAt ?? null,
|
||||
approvedBy: s?.approvedBy ?? null,
|
||||
isLegacyDefault: !s,
|
||||
legacy: LEGACY_WAGE_FORMULA,
|
||||
history: (s?.history ?? []).map((h) => ({ ...h, result: computeSample(parseWageFormula(h.formula), parseWageSample(h.sample)) })),
|
||||
};
|
||||
}
|
||||
|
||||
/** The draft and the formula in force, side by side, on the same sample. */
|
||||
async preview(rawFormula: unknown, rawSample: unknown) {
|
||||
const draft = parseWageFormula(rawFormula);
|
||||
const sample = parseWageSample(rawSample);
|
||||
return {
|
||||
draft: computeSample(draft, sample),
|
||||
current: computeSample(await this.formula(), sample),
|
||||
};
|
||||
}
|
||||
|
||||
/** Approves a formula together with the sample it was checked on. */
|
||||
async approve(rawFormula: unknown, rawSample: unknown, admin: { id: string; fullName: string }) {
|
||||
const formula = parseWageFormula(rawFormula);
|
||||
const sample = parseWageSample(rawSample);
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const prev = await this.stored(tx);
|
||||
const next: StoredWageFormula = {
|
||||
formula,
|
||||
sample,
|
||||
approvedAt: new Date().toISOString(),
|
||||
approvedBy: { id: admin.id, name: admin.fullName },
|
||||
history: prev
|
||||
? [{ formula: prev.formula, sample: prev.sample, approvedAt: prev.approvedAt, approvedBy: prev.approvedBy }, ...prev.history].slice(0, 20)
|
||||
: [],
|
||||
};
|
||||
const value = next as unknown as Prisma.InputJsonValue;
|
||||
await tx.setting.upsert({
|
||||
where: { key: WAGE_FORMULA_SETTING_KEY },
|
||||
update: { value },
|
||||
create: { key: WAGE_FORMULA_SETTING_KEY, value },
|
||||
});
|
||||
});
|
||||
return this.getConfig();
|
||||
}
|
||||
|
||||
/* ───────────── pricing a customer's lines ───────────── */
|
||||
|
||||
/**
|
||||
* The making charge of each variant for this customer — what the app shows
|
||||
* and what an app order is charged. The promotions considered are legacy's:
|
||||
* active, inside their dates, scoped to the product, its category or its
|
||||
* brand (customer-scoped promotions were switched off in the legacy app).
|
||||
*/
|
||||
async quote(customerId: string, variantIds: string[], tx: Tx = this.prisma): Promise<Map<string, WageQuote>> {
|
||||
const customer = await tx.customer.findUnique({
|
||||
where: { id: customerId },
|
||||
select: { makingChargeOffsetPercent: true },
|
||||
});
|
||||
if (!customer) throw new NotFoundException('مشتری یافت نشد');
|
||||
|
||||
const ids = [...new Set(variantIds)];
|
||||
const variants = await tx.productVariant.findMany({
|
||||
where: { id: { in: ids } },
|
||||
select: { id: true, defaultWagePercent: true, product: { select: { id: true, categoryId: true, brandId: true } } },
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
const promotions = await tx.promotion.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
scope: { in: [PromotionScope.PRODUCT, PromotionScope.CATEGORY, PromotionScope.BRAND] },
|
||||
AND: [
|
||||
{ OR: [{ startsAt: null }, { startsAt: { lte: now } }] },
|
||||
{ OR: [{ endsAt: null }, { endsAt: { gte: now } }] },
|
||||
],
|
||||
OR: [
|
||||
{ productId: { in: variants.map((v) => v.product.id) } },
|
||||
{ categoryId: { in: variants.map((v) => v.product.categoryId).filter((x): x is string => !!x) } },
|
||||
{ brandId: { in: variants.map((v) => v.product.brandId).filter((x): x is string => !!x) } },
|
||||
],
|
||||
},
|
||||
select: { scope: true, productId: true, categoryId: true, brandId: true, discountPercent: true },
|
||||
});
|
||||
|
||||
const formula = await this.formula(tx);
|
||||
const out = new Map<string, WageQuote>();
|
||||
for (const v of variants) {
|
||||
const applying = promotions.filter(
|
||||
(p) =>
|
||||
(p.scope === PromotionScope.PRODUCT && p.productId === v.product.id) ||
|
||||
(p.scope === PromotionScope.CATEGORY && !!v.product.categoryId && p.categoryId === v.product.categoryId) ||
|
||||
(p.scope === PromotionScope.BRAND && !!v.product.brandId && p.brandId === v.product.brandId),
|
||||
);
|
||||
const inputs = {
|
||||
variantPercent: v.defaultWagePercent,
|
||||
customerPercent: customer.makingChargeOffsetPercent,
|
||||
promotionPercents: applying.map((p) => p.discountPercent),
|
||||
};
|
||||
out.set(v.id, {
|
||||
variantId: v.id,
|
||||
variantPercent: new Prisma.Decimal(v.defaultWagePercent).toString(),
|
||||
customerPercent: new Prisma.Decimal(customer.makingChargeOffsetPercent).toString(),
|
||||
...computeWage(formula, inputs),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -288,6 +288,15 @@ export interface AccountingBalance {
|
||||
provider: string | null
|
||||
}
|
||||
|
||||
/** POST /customer/me/wage-quote — one variant's making charge for the signed-in customer. */
|
||||
export interface WageQuote {
|
||||
variantId: string
|
||||
variantPercent: string
|
||||
customerPercent: string
|
||||
promotionPercent: string
|
||||
finalPercent: string
|
||||
}
|
||||
|
||||
export const api = {
|
||||
listProducts: () => request<ProductSummary[]>('/catalog/products'),
|
||||
/**
|
||||
@@ -320,6 +329,9 @@ export const api = {
|
||||
getAccountingBalance: () => request<AccountingBalance>('/customer/me/accounting-balance'),
|
||||
getMyOrders: () => request<OrderDetail[]>('/customer/me/orders'),
|
||||
getMyLedgerEntries: () => request<LedgerEntrySummary[]>('/customer/me/ledger-entries'),
|
||||
/** The making charge the server will charge for these variants (MON-08). */
|
||||
getWageQuotes: (variantIds: string[]) =>
|
||||
request<WageQuote[]>('/customer/me/wage-quote', { method: 'POST', body: JSON.stringify({ variantIds }) }),
|
||||
/** Favourite product ids, newest first — legacy get-favorites (FAV-01). */
|
||||
getFavorites: () => request<{ ids: string[] }>('/customer/me/favorites'),
|
||||
/** Legacy add-to-fav: adds or removes; answers the new state. */
|
||||
|
||||
@@ -12,14 +12,14 @@
|
||||
// data exists in the new API, so `hasDiscountWage` is always null here —
|
||||
// the discount badge and "before/after" price never render, which is
|
||||
// correct given there is genuinely nothing to show, not a bug.
|
||||
// - `wage`: legacy added the logged-in customer's own `percent` (a
|
||||
// commission/markup field on their account) on top of the variant's
|
||||
// making-charge %. No login exists in this port yet, so wage is just the
|
||||
// selected variant's defaultWagePercent on its own.
|
||||
// - `wage` now comes from the server (wageQuoteStore), which adds the
|
||||
// customer's own percent and the promotion exactly as legacy did.
|
||||
// - `is_exist` (stock) and `has_warranty`: no such fields exist on our
|
||||
// ProductVariant model yet — always true / false respectively.
|
||||
import { computed, ref, unref, type Ref } from 'vue'
|
||||
import type { ProductSummary, ProductVariantSummary } from '../api/client'
|
||||
import { useSessionStore } from '../stores/session'
|
||||
import { useWageQuoteStore } from '../stores/wageQuoteStore'
|
||||
|
||||
/** A variant can be ordered when it has stock, or when it is flagged pre-order. */
|
||||
export function isVariantOrderable(v: ProductVariantSummary): boolean {
|
||||
@@ -47,7 +47,20 @@ export function useProduct(productItem: ProductSummary, initialVariant?: Product
|
||||
const variants = computed(() => product.value.variants)
|
||||
const selectedVariant: Ref<ProductVariantSummary | null> = ref(initialVariant ?? getCheapestVariant(unref(variants)))
|
||||
const min_gram = computed(() => selectedVariant.value ? parseFloat(selectedVariant.value.rashWeightGrams) : 0)
|
||||
const wage = computed(() => (selectedVariant.value ? parseFloat(selectedVariant.value.defaultWagePercent) : null))
|
||||
// The making charge the server will charge (MON-08): variant + the
|
||||
// customer's own percent − the best active promotion, by the formula the
|
||||
// admin «اجرت» screen controls. Signed in: the server's figure, hidden for
|
||||
// the moment it loads rather than flashing a different number. Signed out
|
||||
// (no customer, so no customer percent): the variant's own figure.
|
||||
const session = useSessionStore()
|
||||
const wageQuotes = useWageQuoteStore()
|
||||
const wage = computed(() => {
|
||||
const v = selectedVariant.value
|
||||
if (!v) return null
|
||||
if (!session.isLoggedIn) return parseFloat(v.defaultWagePercent)
|
||||
wageQuotes.request(v.id)
|
||||
return wageQuotes.finalPercent(v.id)
|
||||
})
|
||||
const hasDiscountWage = ref<number | null>(null) // no promotions backend yet — see file header
|
||||
// REAL stock: ProductVariant.qty / isPreOrder exist and are returned by
|
||||
// GET /catalog/products. A variant is orderable when it has stock, or when
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// The making charge («اجرت») the server will charge this customer for each
|
||||
// variant — fetched from /customer/me/wage-quote, which runs the same formula
|
||||
// that prices the order (MON-08, owner ruling 2026-10-08). The app shows this
|
||||
// figure, never one it computes itself, so what is shown is what is charged.
|
||||
//
|
||||
// Requests are batched: many cards asking at once become one call.
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, watch } from 'vue'
|
||||
import { api, type WageQuote } from '../api/client'
|
||||
import { useSessionStore } from './session'
|
||||
|
||||
export const useWageQuoteStore = defineStore('wageQuotes', () => {
|
||||
const session = useSessionStore()
|
||||
const quotes = ref<Record<string, WageQuote>>({})
|
||||
const pending = new Set<string>()
|
||||
const inFlight = new Set<string>()
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// A different customer has a different percent: never reuse another's quotes.
|
||||
watch(
|
||||
() => session.token,
|
||||
() => {
|
||||
quotes.value = {}
|
||||
pending.clear()
|
||||
inFlight.clear()
|
||||
},
|
||||
)
|
||||
|
||||
async function flush() {
|
||||
timer = null
|
||||
const ids = [...pending].slice(0, 200)
|
||||
ids.forEach((id) => {
|
||||
pending.delete(id)
|
||||
inFlight.add(id)
|
||||
})
|
||||
if (pending.size) timer = setTimeout(flush, 0)
|
||||
if (!ids.length) return
|
||||
try {
|
||||
const rows = await api.getWageQuotes(ids)
|
||||
const next = { ...quotes.value }
|
||||
for (const r of rows) next[r.variantId] = r
|
||||
quotes.value = next
|
||||
} catch (e) {
|
||||
console.warn('wage quote failed', e)
|
||||
} finally {
|
||||
ids.forEach((id) => inFlight.delete(id))
|
||||
}
|
||||
}
|
||||
|
||||
/** Asks for a variant's quote (idempotent; safe to call from a computed). */
|
||||
function request(variantId: string) {
|
||||
if (!session.token || quotes.value[variantId] || pending.has(variantId) || inFlight.has(variantId)) return
|
||||
pending.add(variantId)
|
||||
if (!timer) timer = setTimeout(flush, 30)
|
||||
}
|
||||
|
||||
/** The charged percent, or null while it is still loading. */
|
||||
function finalPercent(variantId: string): number | null {
|
||||
const q = quotes.value[variantId]
|
||||
return q ? parseFloat(q.finalPercent) : null
|
||||
}
|
||||
|
||||
return { quotes, request, finalPercent }
|
||||
})
|
||||
@@ -222,6 +222,8 @@ const submitOrder = async () => {
|
||||
return {
|
||||
productVariantId: item.vid,
|
||||
quantity: item.qty,
|
||||
// Sent for older API builds only: the server prices app orders itself
|
||||
// (MON-08) and ignores this figure.
|
||||
wagePercent: variant ? parseFloat(variant.defaultWagePercent) : 0,
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -303,6 +303,15 @@ export interface AccountingBalance {
|
||||
provider: string | null
|
||||
}
|
||||
|
||||
/** POST /customer/me/wage-quote — one variant's making charge for the signed-in customer. */
|
||||
export interface WageQuote {
|
||||
variantId: string
|
||||
variantPercent: string
|
||||
customerPercent: string
|
||||
promotionPercent: string
|
||||
finalPercent: string
|
||||
}
|
||||
|
||||
export const api = {
|
||||
listProducts: () => request<ProductSummary[]>('/catalog/products'),
|
||||
|
||||
@@ -336,6 +345,9 @@ export const api = {
|
||||
getAccountingBalance: () => request<AccountingBalance>('/customer/me/accounting-balance'),
|
||||
getMyOrders: () => request<OrderDetail[]>('/customer/me/orders'),
|
||||
getMyLedgerEntries: () => request<LedgerEntrySummary[]>('/customer/me/ledger-entries'),
|
||||
/** The making charge the server will charge for these variants (MON-08). */
|
||||
getWageQuotes: (variantIds: string[]) =>
|
||||
request<WageQuote[]>('/customer/me/wage-quote', { method: 'POST', body: JSON.stringify({ variantIds }) }),
|
||||
/** Favourite product ids, newest first — legacy get-favorites (FAV-01). */
|
||||
getFavorites: () => request<{ ids: string[] }>('/customer/me/favorites'),
|
||||
/** Legacy add-to-fav: adds or removes; answers the new state. */
|
||||
|
||||
@@ -12,14 +12,14 @@
|
||||
// data exists in the new API, so `hasDiscountWage` is always null here —
|
||||
// the discount badge and "before/after" price never render, which is
|
||||
// correct given there is genuinely nothing to show, not a bug.
|
||||
// - `wage`: legacy added the logged-in customer's own `percent` (a
|
||||
// commission/markup field on their account) on top of the variant's
|
||||
// making-charge %. No login exists in this port yet, so wage is just the
|
||||
// selected variant's defaultWagePercent on its own.
|
||||
// - `wage` now comes from the server (wageQuoteStore), which adds the
|
||||
// customer's own percent and the promotion exactly as legacy did.
|
||||
// - `is_exist` (stock) and `has_warranty`: no such fields exist on our
|
||||
// ProductVariant model yet — always true / false respectively.
|
||||
import { computed, ref, unref, type Ref } from 'vue'
|
||||
import type { ProductSummary, ProductVariantSummary } from '../api/client'
|
||||
import { useSessionStore } from '../stores/session'
|
||||
import { useWageQuoteStore } from '../stores/wageQuoteStore'
|
||||
|
||||
/** A variant can be ordered when it has stock, or when it is flagged pre-order. */
|
||||
export function isVariantOrderable(v: ProductVariantSummary): boolean {
|
||||
@@ -52,7 +52,20 @@ export function useProduct(productItem: ProductSummary, initialVariant?: Product
|
||||
() => selectedVariant.value?.image?.url ?? product.value.images?.[0]?.url,
|
||||
)
|
||||
const min_gram = computed(() => selectedVariant.value ? parseFloat(selectedVariant.value.rashWeightGrams) : 0)
|
||||
const wage = computed(() => (selectedVariant.value ? parseFloat(selectedVariant.value.defaultWagePercent) : null))
|
||||
// The making charge the server will charge (MON-08): variant + the
|
||||
// customer's own percent − the best active promotion, by the formula the
|
||||
// admin «اجرت» screen controls. Signed in: the server's figure, hidden for
|
||||
// the moment it loads rather than flashing a different number. Signed out
|
||||
// (no customer, so no customer percent): the variant's own figure.
|
||||
const session = useSessionStore()
|
||||
const wageQuotes = useWageQuoteStore()
|
||||
const wage = computed(() => {
|
||||
const v = selectedVariant.value
|
||||
if (!v) return null
|
||||
if (!session.isLoggedIn) return parseFloat(v.defaultWagePercent)
|
||||
wageQuotes.request(v.id)
|
||||
return wageQuotes.finalPercent(v.id)
|
||||
})
|
||||
const hasDiscountWage = ref<number | null>(null) // no promotions backend yet — see file header
|
||||
// REAL stock: ProductVariant.qty / isPreOrder exist and are returned by
|
||||
// GET /catalog/products. A variant is orderable when it has stock, or when
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// The making charge («اجرت») the server will charge this customer for each
|
||||
// variant — fetched from /customer/me/wage-quote, which runs the same formula
|
||||
// that prices the order (MON-08, owner ruling 2026-10-08). The app shows this
|
||||
// figure, never one it computes itself, so what is shown is what is charged.
|
||||
//
|
||||
// Requests are batched: many cards asking at once become one call.
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, watch } from 'vue'
|
||||
import { api, type WageQuote } from '../api/client'
|
||||
import { useSessionStore } from './session'
|
||||
|
||||
export const useWageQuoteStore = defineStore('wageQuotes', () => {
|
||||
const session = useSessionStore()
|
||||
const quotes = ref<Record<string, WageQuote>>({})
|
||||
const pending = new Set<string>()
|
||||
const inFlight = new Set<string>()
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// A different customer has a different percent: never reuse another's quotes.
|
||||
watch(
|
||||
() => session.token,
|
||||
() => {
|
||||
quotes.value = {}
|
||||
pending.clear()
|
||||
inFlight.clear()
|
||||
},
|
||||
)
|
||||
|
||||
async function flush() {
|
||||
timer = null
|
||||
const ids = [...pending].slice(0, 200)
|
||||
ids.forEach((id) => {
|
||||
pending.delete(id)
|
||||
inFlight.add(id)
|
||||
})
|
||||
if (pending.size) timer = setTimeout(flush, 0)
|
||||
if (!ids.length) return
|
||||
try {
|
||||
const rows = await api.getWageQuotes(ids)
|
||||
const next = { ...quotes.value }
|
||||
for (const r of rows) next[r.variantId] = r
|
||||
quotes.value = next
|
||||
} catch (e) {
|
||||
console.warn('wage quote failed', e)
|
||||
} finally {
|
||||
ids.forEach((id) => inFlight.delete(id))
|
||||
}
|
||||
}
|
||||
|
||||
/** Asks for a variant's quote (idempotent; safe to call from a computed). */
|
||||
function request(variantId: string) {
|
||||
if (!session.token || quotes.value[variantId] || pending.has(variantId) || inFlight.has(variantId)) return
|
||||
pending.add(variantId)
|
||||
if (!timer) timer = setTimeout(flush, 30)
|
||||
}
|
||||
|
||||
/** The charged percent, or null while it is still loading. */
|
||||
function finalPercent(variantId: string): number | null {
|
||||
const q = quotes.value[variantId]
|
||||
return q ? parseFloat(q.finalPercent) : null
|
||||
}
|
||||
|
||||
return { quotes, request, finalPercent }
|
||||
})
|
||||
@@ -213,6 +213,8 @@ const submitOrder = async () => {
|
||||
return {
|
||||
productVariantId: item.vid,
|
||||
quantity: item.qty,
|
||||
// Sent for older API builds only: the server prices app orders itself
|
||||
// (MON-08) and ignores this figure.
|
||||
wagePercent: variant ? parseFloat(variant.defaultWagePercent) : 0,
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -96,7 +96,7 @@ Severity: `Critical` (money loss / security / data loss) · `High` · `Medium`
|
||||
| ID | Severity | Where | Description | Evidence | Status |
|
||||
|---|---|---|---|---|---|
|
||||
| SEC-11 | High | TGT `apps/api/src/modules/customer-auth/customer-auth.service.ts` (deliver) | The OTP message text, **code included**, was persisted to `SmsMessage.body`, which the admin SMS grid lists and full-text searches (`admin-misc.service.ts:135-136`). Any operator holding the `sms` rid could read live codes — defeating the bcrypt hashing of `codeHash`. | Read both files; grid `allowed` includes `body`. | ✅ Fixed in stage 3: the persisted row is a redacted audit line. |
|
||||
| MON-08 | Medium | TGT `apps/api/src/modules/orders/orders.service.ts:40-52` | `wagePercent`, `discountPercent` and `flatRialCharge` are taken from the **client** on order creation, so a caller can set their own making charge. `rashWeightGrams` is correctly read from the variant. **The reference is worse** — `ApiMobileController.php:538-539,619` takes `rash_weight` from the client too — so this is a partial improvement already. Reported per the user's instruction to flag client-supplied pricing. | REF `:536-545,615-632` vs TGT `:40-52` | Log only (D4). Fix in Phase 2 alongside the price-formula work. |
|
||||
| MON-08 | ~~Medium~~ **FIXED 2026-10-08 (owner-approved)** — app orders are priced by the server (`pricing/wage-formula.ts`, legacy by default: variant % + customer % − largest active product/category/brand promotion; rial charge 0) and the app's wage/discount/rial are ignored; the apps show the same figure from `POST customer/me/wage-quote`. Operators keep their manual numbers. Found on the way: the apps never added the customer's own percent (usually 3) nor any promotion, so the new app undercharged vs legacy. The formula is editable on the new «اجرت» admin screen (`config.wage`) with a live server-computed sample and approve-then-save. Verified end to end: 9+3=12; promotions 2,1 + expired 5 → 10; a tampered order (wage 0, discount 50, rial 999) was charged 10.5% and 0 rial under an approved +0.5 formula. | TGT `apps/api/src/modules/orders/orders.service.ts:40-52` | `wagePercent`, `discountPercent` and `flatRialCharge` are taken from the **client** on order creation, so a caller can set their own making charge. `rashWeightGrams` is correctly read from the variant. **The reference is worse** — `ApiMobileController.php:538-539,619` takes `rash_weight` from the client too — so this is a partial improvement already. Reported per the user's instruction to flag client-supplied pricing. | REF `:536-545,615-632` vs TGT `:40-52` | Log only (D4). Fix in Phase 2 alongside the price-formula work. |
|
||||
|
||||
## Found while classifying scope for D13
|
||||
|
||||
|
||||
Reference in New Issue
Block a user