fix(money,orders): wage/gram-debt parity, atomic document confirm, expert edit, made-to-order, QA fixes
- Rial wage and gram-debt arithmetic matched to the reference; order corrections keep debt in sync - Document confirm/un-confirm is a compare-and-swap (no double-applied cache under concurrency) - Settlement document and expert inputs validated (400 instead of 500) - Expert: edit / activate-deactivate own customers, adjust raw weights only - Made-to-order flow in the app, PreOrder.quantity migration - Order grids: customer column, sort keys; panel select search; pre-order quantity save - Rebuilt release artifacts Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
959a272227
commit
6514b05d18
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Get, HttpCode, Param, Post } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, HttpCode, Param, Post } from '@nestjs/common';
|
||||
import { AdminOrdersService } from './admin-orders.service';
|
||||
import { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
import { UpdateOrderStatusDto } from './update-status.dto';
|
||||
@@ -73,7 +73,7 @@ export class AdminOrdersController {
|
||||
@Body()
|
||||
body: {
|
||||
customer_id: string;
|
||||
order_items: { vid: string; qty: number; percent?: number; discount?: number }[];
|
||||
order_items: { vid: string; qty: number; percent?: number; discount?: number; rial_input?: number }[];
|
||||
sts?: number | string;
|
||||
note?: string;
|
||||
},
|
||||
@@ -82,6 +82,21 @@ export class AdminOrdersController {
|
||||
return this.orders.createFromAdmin(body, admin.id);
|
||||
}
|
||||
|
||||
/** Legacy POST order-item/edit — correct one line of a wholesale order. */
|
||||
@Post('item/edit')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('order.omde.view.edit')
|
||||
editItem(@Body() body: { id?: string; rash_weight?: unknown; qty?: unknown; percent?: unknown }) {
|
||||
return this.orders.editItem(body);
|
||||
}
|
||||
|
||||
/** Legacy DELETE major/order_item/{id}. */
|
||||
@Delete('item/:id')
|
||||
@RequirePermission('order.omde.view.edit')
|
||||
removeItem(@Param('id') id: string) {
|
||||
return this.orders.removeItem(id);
|
||||
}
|
||||
|
||||
/** Legacy GET order/type. */
|
||||
@Get('type-options')
|
||||
@SuperAdminOnly()
|
||||
|
||||
@@ -2,11 +2,12 @@ import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
import { OrdersModule } from '../orders/orders.module';
|
||||
import { AccountingModule } from '../accounting/accounting.module';
|
||||
import { PricingModule } from '../pricing/pricing.module';
|
||||
import { AdminOrdersController } from './admin-orders.controller';
|
||||
import { AdminOrdersService } from './admin-orders.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, OrdersModule, AccountingModule],
|
||||
imports: [PrismaModule, OrdersModule, AccountingModule, PricingModule],
|
||||
controllers: [AdminOrdersController],
|
||||
providers: [AdminOrdersService],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import { OrderChannel, OrderStatus, Prisma } from '@prisma/client';
|
||||
import { AdminOrdersService } from './admin-orders.service';
|
||||
|
||||
/**
|
||||
* The wholesale and sourcing grids bind the reference's order columns
|
||||
* (`input`, `wage`, `rial_input`, `discount`) and the modal binds per-line
|
||||
* figures. The row the API returns has to carry them, with the line figures
|
||||
* adding up to the order's.
|
||||
*/
|
||||
describe('AdminOrdersService.findOne — the row the grids bind', () => {
|
||||
const D = (v: Prisma.Decimal.Value) => new Prisma.Decimal(v);
|
||||
|
||||
const order = {
|
||||
id: 'o1',
|
||||
orderNumber: '100',
|
||||
status: OrderStatus.DELIVERED_TO_AGENT,
|
||||
channel: OrderChannel.WHOLESALE,
|
||||
createdAt: new Date('2026-10-01'),
|
||||
updatedAt: new Date('2026-10-02'),
|
||||
branchId: null,
|
||||
branch: null,
|
||||
deliveredAt: null,
|
||||
isSuspendedOverCreditLimit: false,
|
||||
// setOrder: allGram, allGram − Σ rash×qty, Σ rial_pay×qty
|
||||
totalGoldWeightGrams: D('59.9'),
|
||||
wageWeightGrams: D('5.5'),
|
||||
flatRialCharge: D('1250000'),
|
||||
customer: { id: 'c1', firstName: 'علی', lastName: 'رضایی', fullName: 'علی رضایی', mobile: '0912' },
|
||||
statusEvents: [],
|
||||
items: [
|
||||
{
|
||||
id: 'i1',
|
||||
quantity: 3,
|
||||
computedWeightGrams: D('6.3'),
|
||||
rashWeightGramsSnapshot: D('5.7856'),
|
||||
wagePercentSnapshot: D('10'),
|
||||
discountPercentSnapshot: D('2'),
|
||||
flatRialCharge: D('150000'),
|
||||
productVariant: { title: '5 گرم', product: { title: 'النگو' } },
|
||||
},
|
||||
{
|
||||
id: 'i2',
|
||||
quantity: 10,
|
||||
computedWeightGrams: D('4.1'),
|
||||
rashWeightGramsSnapshot: D('2.5'),
|
||||
wagePercentSnapshot: D('12'),
|
||||
discountPercentSnapshot: D('3'),
|
||||
flatRialCharge: D('80000'),
|
||||
productVariant: { title: '2.5 گرم', product: { title: 'گوشواره' } },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const service = new AdminOrdersService(
|
||||
{ order: { findUnique: jest.fn(async () => order) } } as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
it('carries the legacy order columns the wholesale and sourcing grids bind', async () => {
|
||||
const row = (await service.findOne('o1')) as Record<string, any>;
|
||||
expect(row.input.toString()).toBe('59.9'); // جمع کل سفارش
|
||||
expect(row.wage.toString()).toBe('5.5'); // اجرت (گرم)
|
||||
expect(row.rial_input.toString()).toBe('1250000'); // اجرت ریالی
|
||||
expect(row.discount.toString()).toBe('5'); // Σ the lines' discount percents (array_sum in setOrder)
|
||||
});
|
||||
|
||||
it('names the customer under the key the «مشتری» column binds (customer.name)', async () => {
|
||||
const row = (await service.findOne('o1')) as Record<string, any>;
|
||||
expect(row.customer.name).toBe('علی رضایی');
|
||||
});
|
||||
|
||||
it('gives each line its per-unit figures and what the line owes', async () => {
|
||||
const row = (await service.findOne('o1')) as Record<string, any>;
|
||||
const [a, b] = row.items;
|
||||
expect(a.grams.toString()).toBe('6.3');
|
||||
expect(a.total_grams.toString()).toBe('18.9'); // 6.3 × 3
|
||||
expect(a.percent.toString()).toBe('8'); // 10 − 2: the percent actually charged
|
||||
expect(a.rial_pay.toString()).toBe('150000');
|
||||
expect(a.total_rial.toString()).toBe('450000'); // × qty
|
||||
expect(b.total_rial.toString()).toBe('800000');
|
||||
});
|
||||
|
||||
it("the lines' rial totals add up to the order's rial making charge", async () => {
|
||||
const row = (await service.findOne('o1')) as Record<string, any>;
|
||||
const sum = row.items.reduce((s: Prisma.Decimal, it: any) => s.add(it.total_rial), D(0));
|
||||
expect(sum.toString()).toBe(row.rial_input.toString());
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The panel's order builder: a line the operator gave no percent for is priced
|
||||
* at what the app would charge this customer; a percent typed on purpose wins.
|
||||
*/
|
||||
describe('AdminOrdersService.createFromAdmin — the percent a line is placed at', () => {
|
||||
const D = (v: Prisma.Decimal.Value) => new Prisma.Decimal(v);
|
||||
|
||||
function build() {
|
||||
const created: any[] = [];
|
||||
const prisma = {
|
||||
productVariant: { findUnique: jest.fn(async ({ where }: any) => ({ id: where.id, defaultWagePercent: D(7) })) },
|
||||
order: { findUnique: jest.fn(async () => ({ ...baseOrder })) },
|
||||
};
|
||||
const orders = {
|
||||
createOrder: jest.fn(async (dto: any, customerId: string) => {
|
||||
created.push({ dto, customerId });
|
||||
return { id: 'o1' };
|
||||
}),
|
||||
updateStatus: jest.fn(),
|
||||
};
|
||||
// 7 (variant) + 3 (customer) − 2 (largest promotion) = 8 net, stored as 10 charge / 2 discount.
|
||||
const wage = {
|
||||
quote: jest.fn(
|
||||
async (_c: string, ids: string[]) =>
|
||||
new Map(ids.map((id) => [id, { wagePercentSnapshot: '10', discountPercentSnapshot: '2', finalPercent: '8' }])),
|
||||
),
|
||||
};
|
||||
const service = new AdminOrdersService(prisma as never, {} as never, orders as never, {} as never, {} as never, wage as never);
|
||||
return { service, created, wage };
|
||||
}
|
||||
|
||||
// A minimal order for the findOne that closes createFromAdmin.
|
||||
const baseOrder = {
|
||||
id: 'o1',
|
||||
orderNumber: '1',
|
||||
status: OrderStatus.PLACED,
|
||||
channel: OrderChannel.WHOLESALE,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
branchId: null,
|
||||
branch: null,
|
||||
deliveredAt: null,
|
||||
isSuspendedOverCreditLimit: false,
|
||||
totalGoldWeightGrams: D(0),
|
||||
wageWeightGrams: D(0),
|
||||
flatRialCharge: D(0),
|
||||
customer: { id: 'c1', firstName: 'a', lastName: 'b', fullName: 'a b', mobile: '0' },
|
||||
statusEvents: [],
|
||||
items: [],
|
||||
};
|
||||
|
||||
it("places a line with no percent at the customer's quoted figure (net 8%), like the app", async () => {
|
||||
const { service, created, wage } = build();
|
||||
await service.createFromAdmin({ customer_id: 'c1', order_items: [{ vid: 'v1', qty: 2 }] }, 'admin1');
|
||||
expect(wage.quote).toHaveBeenCalledWith('c1', ['v1']);
|
||||
const line = created[0].dto.items[0];
|
||||
expect(line.wagePercent - line.discountPercent).toBe(8);
|
||||
expect(created[0].customerId).toBe('c1');
|
||||
});
|
||||
|
||||
it('a percent the operator typed is used as typed', async () => {
|
||||
const { service, created } = build();
|
||||
await service.createFromAdmin({ customer_id: 'c1', order_items: [{ vid: 'v1', qty: 1, percent: 4.5 }] }, 'admin1');
|
||||
const line = created[0].dto.items[0];
|
||||
expect(line.wagePercent).toBe(4.5);
|
||||
expect(line.discountPercent).toBe(0);
|
||||
});
|
||||
|
||||
it('a rial charge the operator typed (even 0) is passed on; an omitted one is left for the variant', async () => {
|
||||
const { service, created } = build();
|
||||
await service.createFromAdmin(
|
||||
{ customer_id: 'c1', order_items: [{ vid: 'v1', qty: 1, rial_input: 0 }, { vid: 'v2', qty: 1 }] },
|
||||
'admin1',
|
||||
);
|
||||
const [a, b] = created[0].dto.items;
|
||||
expect(a.flatRialCharge).toBe(0);
|
||||
expect('flatRialCharge' in b).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The grid sends the COLUMN key as the sort field (`order: '-input'`), which is
|
||||
* not always a column of the table: «جمع کل سفارش» (`input`), «اجرت ریالی»
|
||||
* (`rial_input`) and «مشتری» (`customer.name`) answered a click on their header
|
||||
* with a 400 "Field not filterable".
|
||||
*/
|
||||
describe('AdminOrdersService — sorting by the grid column keys', () => {
|
||||
const D = (v: Prisma.Decimal.Value) => new Prisma.Decimal(v);
|
||||
const { TableService } = jest.requireActual('../admin-table/table.service') as typeof import('../admin-table/table.service');
|
||||
|
||||
function build() {
|
||||
const findMany = jest.fn(async () => []);
|
||||
const prisma = { order: { findMany, count: jest.fn(async () => 0) } };
|
||||
const service = new AdminOrdersService(prisma as never, new TableService() as never, {} as never, {} as never, {} as never, {} as never);
|
||||
return { service, findMany };
|
||||
}
|
||||
|
||||
const cases: [string, string, unknown][] = [
|
||||
['-input', 'wholesale', { totalGoldWeightGrams: 'desc' }],
|
||||
['+rial_input', 'wholesale', { flatRialCharge: 'asc' }],
|
||||
['-customer.name', 'wholesale', { customer: { fullName: 'desc' } }],
|
||||
['-input', 'tamin', { totalGoldWeightGrams: 'desc' }],
|
||||
['+rial_input', 'tamin', { flatRialCharge: 'asc' }],
|
||||
['+customer.name', 'tamin', { customer: { fullName: 'asc' } }],
|
||||
];
|
||||
|
||||
it.each(cases)('order %s (%s grid) sorts by the real field', async (order, grid, expected) => {
|
||||
const { service, findMany } = build();
|
||||
const run = grid === 'tamin' ? service.listTamin.bind(service) : service.listWholesale.bind(service);
|
||||
await run({ page: 1, limit: 10, order } as never);
|
||||
expect((findMany.mock.calls[0] as any)[0].orderBy).toEqual(expected);
|
||||
});
|
||||
|
||||
it('a field that is really not sortable keeps the default order (no error, nothing extra exposed)', async () => {
|
||||
const { service, findMany } = build();
|
||||
await service.listWholesale({ page: 1, limit: 10, order: '-passwordHash' } as never);
|
||||
expect((findMany.mock.calls[0] as any)[0].orderBy).toEqual({ createdAt: 'desc' });
|
||||
});
|
||||
|
||||
void D;
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import type { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
import { AccountingHooksService } from '../accounting-sync/accounting-hooks.service';
|
||||
import { OrdersService } from '../orders/orders.service';
|
||||
import { LedgerService } from '../accounting/ledger.service';
|
||||
import { WageService } from '../pricing/wage.service';
|
||||
import type { UpdateOrderStatusDto } from './update-status.dto';
|
||||
|
||||
/**
|
||||
@@ -44,6 +45,7 @@ export class AdminOrdersService {
|
||||
private readonly orders: OrdersService,
|
||||
private readonly accounting: AccountingHooksService,
|
||||
private readonly ledger: LedgerService,
|
||||
private readonly wage: WageService,
|
||||
) {}
|
||||
|
||||
/** Fields the XTable filter bar and sorter may reference. */
|
||||
@@ -54,6 +56,7 @@ export class AdminOrdersService {
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'totalGoldWeightGrams',
|
||||
'wageWeightGrams',
|
||||
'flatRialCharge',
|
||||
'isSuspendedOverCreditLimit',
|
||||
'customer.fullName',
|
||||
@@ -61,6 +64,25 @@ export class AdminOrdersService {
|
||||
'branch.name',
|
||||
];
|
||||
|
||||
/**
|
||||
* The grids send the column KEY as the sort field. Three of those keys are the
|
||||
* reference's names, not columns of this table, and used to answer a click on
|
||||
* their header with a 400 («جمع کل سفارش», «اجرت (گرم)/ریالی», «مشتری»).
|
||||
*/
|
||||
private static readonly SORT_ALIASES: Record<string, string> = {
|
||||
input: 'totalGoldWeightGrams',
|
||||
wage: 'wageWeightGrams',
|
||||
rial_input: 'flatRialCharge',
|
||||
'customer.name': 'customer.fullName',
|
||||
};
|
||||
|
||||
private withSortAlias(query: TableQueryDto): TableQueryDto {
|
||||
if (!query.order) return query;
|
||||
const field = query.order.replace(/^[+-]/, '');
|
||||
const real = AdminOrdersService.SORT_ALIASES[field];
|
||||
return real ? { ...query, order: `${query.order.startsWith('-') ? '-' : '+'}${real}` } : query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `POST single-order` — the order list grid.
|
||||
*
|
||||
@@ -70,7 +92,7 @@ export class AdminOrdersService {
|
||||
async list(query: TableQueryDto, branchId?: string, channel: OrderChannel = OrderChannel.RETAIL) {
|
||||
this.table.assertNotExport(query);
|
||||
|
||||
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
|
||||
const { where, orderBy, skip, take, page, limit } = this.table.build(this.withSortAlias(query), {
|
||||
searchable: ['orderNumber', 'customer.fullName', 'customer.mobile'],
|
||||
allowed: AdminOrdersService.ALLOWED,
|
||||
defaultOrderBy: { createdAt: 'desc' },
|
||||
@@ -125,12 +147,24 @@ export class AdminOrdersService {
|
||||
total_price: o.flatRialCharge,
|
||||
totalGoldWeightGrams: o.totalGoldWeightGrams,
|
||||
wageWeightGrams: o.wageWeightGrams,
|
||||
// The wholesale / sourcing grids bind the reference's order columns
|
||||
// (`input`, `wage`, `rial_input`, `discount`); with only the target's own
|
||||
// names in the row those cells rendered empty, so the order's total grams,
|
||||
// making-charge grams and rial making charge were not shown at all.
|
||||
input: o.totalGoldWeightGrams,
|
||||
w: o.totalGoldWeightGrams,
|
||||
wage: o.wageWeightGrams,
|
||||
rial_input: o.flatRialCharge,
|
||||
// Reference: array_sum of the lines' discount percents (setOrder).
|
||||
discount: o.items.reduce((s, it) => s.add(it.discountPercentSnapshot), new Prisma.Decimal(0)),
|
||||
isSuspendedOverCreditLimit: o.isSuspendedOverCreditLimit,
|
||||
deliveredAt: o.deliveredAt,
|
||||
branch_id: o.branchId,
|
||||
branch: o.branch ? { id: o.branch.id, name: o.branch.name } : null,
|
||||
customer: {
|
||||
id: o.customer.id,
|
||||
// The «مشتری» column of the order grids binds `customer.name`.
|
||||
name: o.customer.fullName,
|
||||
// Legacy split names into fname/lname; the new schema has one fullName.
|
||||
// Both are exposed so ported templates binding either keep working.
|
||||
fname: o.customer.firstName ?? o.customer.fullName,
|
||||
@@ -143,9 +177,16 @@ export class AdminOrdersService {
|
||||
title: it.productVariant.product.title,
|
||||
variant_title: it.productVariant.title,
|
||||
qty: it.quantity,
|
||||
// Per unit — `qty` sits beside it.
|
||||
grams: it.computedWeightGrams,
|
||||
// What the line owes: unit × qty (the figures that sum to the order's).
|
||||
total_grams: it.computedWeightGrams.mul(it.quantity),
|
||||
rash: it.rashWeightGramsSnapshot,
|
||||
percent: it.wagePercentSnapshot.sub(it.discountPercentSnapshot),
|
||||
// Per unit, as the variant's fixed rial making charge (legacy rial_pay).
|
||||
flatRialCharge: it.flatRialCharge,
|
||||
rial_pay: it.flatRialCharge,
|
||||
total_rial: it.flatRialCharge.mul(it.quantity),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -176,7 +217,7 @@ export class AdminOrdersService {
|
||||
async listTamin(query: TableQueryDto, branchId?: string) {
|
||||
this.table.assertNotExport(query);
|
||||
|
||||
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
|
||||
const { where, orderBy, skip, take, page, limit } = this.table.build(this.withSortAlias(query), {
|
||||
searchable: ['orderNumber', 'customer.fullName', 'customer.mobile'],
|
||||
allowed: AdminOrdersService.ALLOWED,
|
||||
defaultOrderBy: { createdAt: 'desc' },
|
||||
@@ -236,6 +277,31 @@ export class AdminOrdersService {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `POST order-item/edit` — correct one line of an order: raw weight,
|
||||
* quantity and the net percent charged. The rules, and the totals and debt
|
||||
* that follow, live in OrdersService.editLine.
|
||||
*/
|
||||
async editItem(body: { id?: string; rash_weight?: unknown; qty?: unknown; percent?: unknown }) {
|
||||
if (!body?.id) throw new BadRequestException('ردیف سفارش مشخص نشده است');
|
||||
const line = await this.prisma.orderItem.findUnique({ where: { id: String(body.id) }, select: { orderId: true } });
|
||||
if (!line) throw new NotFoundException('ردیف سفارش یافت نشد');
|
||||
await this.orders.editLine(line.orderId, String(body.id), {
|
||||
rashWeightGrams: body.rash_weight,
|
||||
quantity: body.qty,
|
||||
netPercent: body.percent,
|
||||
});
|
||||
return this.findOne(line.orderId);
|
||||
}
|
||||
|
||||
/** Legacy `DELETE major/order_item/{id}`. */
|
||||
async removeItem(lineId: string) {
|
||||
const line = await this.prisma.orderItem.findUnique({ where: { id: lineId }, select: { orderId: true } });
|
||||
if (!line) throw new NotFoundException('ردیف سفارش یافت نشد');
|
||||
await this.orders.removeLine(line.orderId, lineId);
|
||||
return this.findOne(line.orderId);
|
||||
}
|
||||
|
||||
/** Legacy `GET order/:id` detail, including the status timeline. */
|
||||
async findOne(id: string) {
|
||||
const order = await this.prisma.order.findUnique({
|
||||
@@ -312,17 +378,32 @@ export class AdminOrdersService {
|
||||
throw new BadRequestException('سبد سفارش خالی است');
|
||||
}
|
||||
|
||||
// What the app would charge this customer for each variant: their own
|
||||
// percent plus the variant's, less the largest promotion. A line the
|
||||
// operator did not give a percent for is priced at that figure, so the same
|
||||
// order costs the same from the panel as from the app (the reference only
|
||||
// ever priced wholesale orders this way, in the cart). A percent typed
|
||||
// explicitly still wins — that is the operator's negotiated figure.
|
||||
const quotes = await this.wage.quote(
|
||||
payload.customer_id,
|
||||
payload.order_items.map((it) => it.vid),
|
||||
);
|
||||
|
||||
const items = await Promise.all(
|
||||
payload.order_items.map(async (it) => {
|
||||
const variant = await this.prisma.productVariant.findUnique({ where: { id: it.vid } });
|
||||
if (!variant) throw new NotFoundException(`تنوع محصول یافت نشد: ${it.vid}`);
|
||||
const quote = quotes.get(it.vid);
|
||||
const typed = it.percent !== undefined && it.percent !== null;
|
||||
return {
|
||||
productVariantId: it.vid,
|
||||
quantity: Number(it.qty) || 1,
|
||||
// Fall back to the variant's own default wage when the builder did not
|
||||
// negotiate a different one.
|
||||
wagePercent: Number(it.percent ?? variant.defaultWagePercent),
|
||||
discountPercent: Number(it.discount ?? 0),
|
||||
wagePercent: typed
|
||||
? Number(it.percent)
|
||||
: quote
|
||||
? Number(quote.wagePercentSnapshot)
|
||||
: Number(variant.defaultWagePercent),
|
||||
discountPercent: typed || !quote ? Number(it.discount ?? 0) : Number(it.discount ?? quote.discountPercentSnapshot),
|
||||
// Omitted rial_input is left unset so createOrder copies variant.rialWage.
|
||||
// An explicit 0 stays 0: the operator cleared the charge.
|
||||
...(it.rial_input != null ? { flatRialCharge: Number(it.rial_input) } : {}),
|
||||
|
||||
Reference in New Issue
Block a user