feat: port QA/money fixes from tehran_taj (atomic document confirm, validated inputs, expert customer edit/toggle/weights, made-to-order, grid sort/customer column)

Order-revision workflow kept as is: the direct admin line-edit routes were not ported.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
saeed
2026-10-29 18:43:22 +03:30
co-authored by Claude Sonnet 5.5
parent 756ecef6db
commit 3eaecde876
65 changed files with 3810 additions and 216 deletions
@@ -303,9 +303,13 @@ const filterableOptions = computed(() =>
);
/** legacy `order` computed: '+field' / '-field' */
const order = computed(() =>
sort.value ? `${sortDirection.value === 'asc' ? '+' : '-'}${sort.value}` : undefined,
);
const order = computed(() => {
if (!sort.value) return undefined;
// A column may be keyed by the reference's name for it; its filter key, when it
// has one, is the real field it stands for, so that is what is sorted on.
const col = cols.value.find((c) => c.key === sort.value);
return `${sortDirection.value === 'asc' ? '+' : '-'}${col?.__fkey ?? sort.value}`;
});
const antColumns = computed(() =>
visibleCols.value.map((c) => ({
+14
View File
@@ -0,0 +1,14 @@
/**
* The text of a slot-style `<a-select-option>`, for a custom `filter-option`.
*
* ant-design-vue 4 hands `filterOption` the option's default slot as a
* function, not as an array of vnodes (the shape the legacy filters indexed
* into), so `option.children[0].children` is always undefined and a typed
* search matched nothing. This calls the slot first.
*/
export function optionText(option: any): string {
const children = typeof option?.children === 'function' ? option.children() : option?.children;
const first = Array.isArray(children) ? children[0] : children;
if (typeof first === 'string') return first;
return String(first?.children ?? '');
}
@@ -136,6 +136,7 @@
</template>
<script setup lang="ts">
import { optionText } from '../../../utils/select';
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
@@ -197,7 +198,7 @@ watch(
);
function filterOption(input: string, option: any) {
return String(option?.children?.[0]?.children ?? '').includes(input);
return optionText(option).includes(input);
}
function validate(): boolean {
@@ -299,6 +299,7 @@
</template>
<script setup lang="ts">
import { optionText } from '../../../utils/select';
import { computed, onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
@@ -380,7 +381,7 @@ const userTypeOptions = computed(() =>
);
function filterUserType(input: string, option: any) {
return String(option?.children?.[0]?.children ?? '').includes(input);
return optionText(option).includes(input);
}
const err = reactive<Record<string, string>>({});
@@ -502,6 +502,7 @@
</template>
<script setup lang="ts">
import { optionText } from '../../../utils/select';
import { onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { Empty, message } from 'ant-design-vue';
@@ -672,7 +673,7 @@ function saveVariant() {
}
function filterSelectOption(input: string, option: any) {
return String(option?.children?.[0]?.children ?? option?.title ?? '').toLowerCase().includes(input.toLowerCase());
return (optionText(option) || String(option?.title ?? '')).toLowerCase().includes(input.toLowerCase());
}
function handleBack() {
@@ -61,6 +61,7 @@
</template>
<script setup lang="ts">
import { optionText } from '../../../utils/select';
import { onMounted, reactive, ref } from 'vue';
import { message, Modal } from 'ant-design-vue';
import api from '../../../api/client';
@@ -77,7 +78,7 @@ const form = reactive<Record<string, any>>({
});
function filterOption(input: string, option: any) {
return String(option?.children?.[0]?.children ?? '')
return optionText(option)
.toLowerCase()
.indexOf(input.toLowerCase()) >= 0;
}
@@ -88,6 +88,7 @@
</template>
<script setup lang="ts">
import { optionText } from '../../utils/select';
import { onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
@@ -124,7 +125,7 @@ const form = reactive<Record<string, any>>({
const err = reactive<Record<string, string>>({});
function filterOption(input: string, option: any) {
return String(option?.children?.[0]?.children ?? '').includes(input);
return optionText(option).includes(input);
}
async function handleSubmit() {
@@ -118,6 +118,7 @@
</template>
<script setup lang="ts">
import { optionText } from '../../../utils/select';
import { computed, onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
@@ -194,7 +195,7 @@ const totalWeight = computed(() => {
});
function filterOption(input: string, option: any) {
return String(option?.children?.[0]?.children ?? '')
return optionText(option)
.toLowerCase()
.includes(input.toLowerCase());
}
@@ -207,10 +208,8 @@ async function submitOrder() {
}
try {
loading.value = true;
const order_items = items.value.map((x) => {
const v = getVariant(x);
return { vid: x.vid, qty: x.qty, percent: v.percentage_pay };
});
// No `percent`: the server prices each line like the app does for this customer.
const order_items = items.value.map((x) => ({ vid: x.vid, qty: x.qty }));
await api.post('order', {
customer_id: form.customer_id,
@@ -220,6 +220,7 @@
</template>
<script setup lang="ts">
import { optionText } from '../../utils/select';
import { computed, onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
@@ -286,7 +287,7 @@ const cashFinalWeight = computed(() => {
});
function filterOption(input: string, option: any) {
return String(option?.children?.[0]?.children ?? '')
return optionText(option)
.toLowerCase()
.includes(input.toLowerCase());
}
@@ -161,6 +161,7 @@
</template>
<script setup lang="ts">
import { optionText } from '../../utils/select';
import { computed, onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
@@ -215,7 +216,7 @@ function addToCart(record: Record<string, any>) {
}
function filterOption(input: string, option: any) {
return String(option?.children?.[0]?.children ?? '').includes(input);
return optionText(option).includes(input);
}
async function handleSubmit() {
@@ -195,6 +195,8 @@ async function editW() {
color: tempRow.value?.color,
color_mina: tempRow.value?.color_mina,
color_stone: tempRow.value?.color_stone,
// The quantity field sits in the same edit mode; without it the edit was shown but never kept.
sum: tempRow.value?.sum,
});
editMode.value = false;
message.info('ویرایش موفق آمیز');
@@ -333,4 +333,20 @@ function deleteOrder(record: Record<string, any>) {
.mr-3 {
margin-left: 0.75rem;
}
.line-edit {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 12px;
padding: 10px 12px;
margin-bottom: 8px;
border: 1px dashed var(--ant-color-border, #d9d9d9);
border-radius: 8px;
}
.line-edit label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
}
</style>
@@ -22,7 +22,9 @@ export function buildOmdeListModel(): XTbl {
new Xtc('customer.mobile', 'موبایل').filterText().hidden().noSort(),
);
tbl.add(new Xtc('input', 'جمع کل سفارش').filterKey('totalGoldWeightGrams').filterText());
tbl.add(new Xtc('discount', 'جمع کل تخفیف (درصد)').filterText());
tbl.add(new Xtc('wage', 'اجرت (گرم)').filterKey('wageWeightGrams').filterText().noSort());
tbl.add(new Xtc('rial_input', 'اجرت ریالی (ریال)').filterKey('flatRialCharge').filterText());
tbl.add(new Xtc('discount', 'جمع کل تخفیف (درصد)').noSort());
// legacy kept the .filterSelect(cityFilters) call commented out — preserved.
tbl.add(new Xtc('customer.city_id', 'شهر').hidden().renderSlot('city'));
tbl.add(new Xtc('items', 'تعداد آیتم ها').renderSlot('items_len').noSort());
@@ -13,7 +13,8 @@ export function buildTaminListModel(): XTbl {
tbl.add(new Xtc('customer.name', 'مشتری').filterKey('customer.fullName').filterText());
tbl.add(new Xtc('customer.mobile', 'موبایل').filterText().hidden().noSort());
tbl.add(new Xtc('input', 'ورودی').filterKey('totalGoldWeightGrams').filterText());
tbl.add(new Xtc('wage', 'اجرت').filterKey('wageWeightGrams').filterText().noSort());
tbl.add(new Xtc('wage', 'اجرت (گرم)').filterKey('wageWeightGrams').filterText().noSort());
tbl.add(new Xtc('rial_input', 'اجرت ریالی (ریال)').filterKey('flatRialCharge').filterText());
tbl.add(new Xtc('items', 'تعداد آیتم ها').renderSlot('items_len').noSort());
tbl.add(new Xtc('caj', 'تاریخ ثبت').filterKey('createdAt').filterDate().noSort());
tbl.add(new Xtc('uaj', 'تاریخ ویرایش').hidden().filterKey('updatedAt').filterDate().noSort());
+4 -10
View File
@@ -191,6 +191,7 @@
</template>
<script setup lang="ts">
import { optionText } from '../../utils/select';
import { computed, onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
@@ -350,7 +351,7 @@ async function editGram() {
}
function filterOption(input: string, option: any) {
return String(option?.children?.[0]?.children ?? '')
return optionText(option)
.toLowerCase()
.includes(input.toLowerCase());
}
@@ -367,15 +368,8 @@ async function submitOrder() {
if (!validate()) return;
try {
loading.value = true;
const order_items = items.value.map((x) => {
const v = getVariant(x);
return {
vid: x.vid,
qty: x.qty,
percent: v.percentage_pay,
promotion_id: x.promotion_id,
};
});
// No `percent`: the server prices each line at what the app would charge this customer.
const order_items = items.value.map((x) => ({ vid: x.vid, qty: x.qty, promotion_id: x.promotion_id }));
// Legacy also sent browser-computed total_price / total_weight /
// gold_price_original. The server recomputes those from the variant
@@ -249,6 +249,7 @@
</template>
<script setup lang="ts">
import { optionText } from '../../utils/select';
import { onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { message } from 'ant-design-vue';
@@ -313,7 +314,7 @@ function changeType() {
}
function filterOption(input: string, option: any) {
return String(option?.children?.[0]?.children ?? '')
return optionText(option)
.toLowerCase()
.includes(input.toLowerCase());
}
+2 -1
View File
@@ -42,6 +42,7 @@
</template>
<script setup lang="ts">
import { optionText } from '../../utils/select';
import { onMounted, ref } from 'vue';
import { jdateTime } from '../../utils/filters';
import api from '../../api/client';
@@ -58,7 +59,7 @@ const columns = [
];
function filterOption(input: string, option: any) {
return String(option?.children?.[0]?.children ?? '').includes(input);
return optionText(option).includes(input);
}
async function load() {
@@ -0,0 +1,3 @@
-- Pieces asked for on a made-to-order request (legacy custom_orders.sum). Existing
-- rows were all treated as one piece by the admin grid, so they keep 1.
ALTER TABLE "PreOrder" ADD COLUMN "quantity" INTEGER NOT NULL DEFAULT 1;
+3
View File
@@ -947,6 +947,9 @@ model PreOrder {
sizeSpec String?
stoneColor String?
minaColor String?
/// Legacy custom_orders.sum — how many pieces the customer asked for. The first
/// schema had no field for it and the admin grid showed a hard-coded 1.
quantity Int @default(1)
estimatedWeightGrams Decimal? @db.Decimal(14, 4)
estimatedPriceRial Decimal? @db.Decimal(20, 2)
description String?
+2
View File
@@ -20,6 +20,7 @@ import { AdminTableModule } from './modules/admin-table/admin-table.module';
import { AdminOrdersModule } from './modules/admin-orders/admin-orders.module';
import { AdminCitiesModule } from './modules/admin-cities/admin-cities.module';
import { AdminPreOrdersModule } from './modules/admin-preorders/admin-preorders.module';
import { CustomerPreOrdersModule } from './modules/customer-preorders/customer-preorders.module';
import { AdminWorkshopsModule } from './modules/admin-workshops/admin-workshops.module';
import { AdminCustomersModule } from './modules/admin-customers/admin-customers.module';
import { AdminCommentsModule } from './modules/admin-comments/admin-comments.module';
@@ -48,6 +49,7 @@ import { SalesAgentModule } from './modules/sales-agent/sales-agent.module';
AdminOrdersModule,
AdminCitiesModule,
AdminPreOrdersModule,
CustomerPreOrdersModule,
AdminWorkshopsModule,
AdminCustomersModule,
AdminCommentsModule,
+32
View File
@@ -0,0 +1,32 @@
import { BadRequestException } from '@nestjs/common';
import { faToEn } from '../modules/customer-auth/digits';
/**
* PERS-03: the birth date both customer forms collect (legacy bulkCustomer
* `birth_date`, singleCustomer `birth_day`). DateInput sends the Gregorian
* 'YYYY-MM-DD' of the Jalali date picked; stored at UTC midnight, as the
* import stores legacy's. undefined = not sent (leave as is), '' = cleared.
*
* Shared by the admin customer forms and the sales expert's «ویرایش مشتری» in
* the app, so both read a date the same way.
*/
export function birthDateOrNull(raw: unknown): Date | null | undefined {
if (raw === undefined) return undefined;
if (raw === null || String(raw).trim() === '') return null;
const m = /^(\d{4})-(\d{1,2})-(\d{1,2})/.exec(String(raw).trim());
const d = m ? new Date(Date.UTC(+m[1], +m[2] - 1, +m[3])) : null;
if (!d || d.getUTCMonth() !== +m![2] - 1 || d.getUTCFullYear() < 1900 || d > new Date()) {
throw new BadRequestException('تاریخ تولد معتبر نیست');
}
return d;
}
/**
* A national code as the operator typed it, digits normalised; empty -> null.
* The column is optional and unique (2026-10-07): an absent code is NULL,
* never the mobile or '' — two customers without one must not collide, and
* the column must only ever hold a national code.
*/
export function nationalCodeOrNull(raw: unknown): string | null {
return faToEn(String(raw ?? '').trim()) || null;
}
@@ -104,3 +104,58 @@ describe('document rule (پیش‌فاکتور at a chosen status)', () => {
expect(rows).toHaveLength(0);
});
});
describe('a corrected order and its document (lines or weights edited after placing)', () => {
const row = (status: string, kind = 'ORDER_DOCUMENT', provider = 'tahesab'): Row => ({ id: String(Math.random()), provider, kind, entityId: 'o1', status });
it('a document still waiting is left alone: it is built from the order when sent', async () => {
const rows = [row('PENDING')];
await hooks('PLACED').orderEdited(fakeTx(rows) as any, 'o1');
expect(rows).toHaveLength(1);
expect(rows[0].status).toBe('PENDING');
});
it('a document already sent is withdrawn and a corrected one queued behind the delete', async () => {
const rows = [row('SENT')];
await hooks('PLACED').orderEdited(fakeTx(rows) as any, 'o1');
expect(docs(rows, 'ORDER_DOCUMENT_DELETE')).toHaveLength(1);
expect(docs(rows).filter((r) => r.status === 'PENDING')).toHaveLength(1);
});
it('works for Zarrin too (its delete runs first, then the corrected document posts)', async () => {
const rows = [row('SENT', 'ORDER_DOCUMENT', 'zarrin')];
await hooks('PLACED', 'zarrin').orderEdited(fakeTx(rows) as any, 'o1');
expect(docs(rows, 'ORDER_DOCUMENT_DELETE')).toHaveLength(1);
expect(docs(rows).filter((r) => r.status === 'PENDING')).toHaveLength(1);
});
it('a second correction before the first has gone out queues nothing more', async () => {
const rows = [row('SENT')];
const h = hooks('PLACED');
await h.orderEdited(fakeTx(rows) as any, 'o1');
await h.orderEdited(fakeTx(rows) as any, 'o1');
expect(docs(rows, 'ORDER_DOCUMENT_DELETE')).toHaveLength(1);
expect(docs(rows).filter((r) => r.status === 'PENDING')).toHaveLength(1);
});
it('an order with no document yet needs nothing', async () => {
const rows: Row[] = [];
await hooks('PLACED').orderEdited(fakeTx(rows) as any, 'o1');
expect(rows).toHaveLength(0);
});
it('does nothing while the document feature is off', async () => {
const rows = [row('SENT')];
const h = hooks('PLACED');
((await (h as any).config.get()) as AccountingConfig).features.documents = false;
await h.orderEdited(fakeTx(rows) as any, 'o1');
expect(rows).toHaveLength(1);
});
it('a document being sent, or waiting for review, blocks the correction', async () => {
expect(await hooks('PLACED').orderDocumentBlocksEdit(fakeTx([row('SENDING')]) as any, 'o1')).toContain('سند حسابداری');
expect(await hooks('PLACED').orderDocumentBlocksEdit(fakeTx([row('NEEDS_REVIEW')]) as any, 'o1')).toContain('سند حسابداری');
expect(await hooks('PLACED').orderDocumentBlocksEdit(fakeTx([row('SENT')]) as any, 'o1')).toBeNull();
expect(await hooks('PLACED').orderDocumentBlocksEdit(fakeTx([]) as any, 'o1')).toBeNull();
});
});
@@ -108,6 +108,52 @@ export class AccountingHooksService {
}
}
/**
* F3 — may this order's lines or weights be corrected now? A document that is
* being sent, or is waiting for a person to look at it, cannot be reconciled
* with a change automatically, so the correction is refused until it is
* settled. Returns the reason, or null when the correction may go ahead.
*/
async orderDocumentBlocksEdit(tx: Tx, orderId: string): Promise<string | null> {
const c = await this.config.get();
if (c.provider === 'none' || !c.features.documents) return null;
const stuck = await tx.accountingOutbox.findFirst({
where: { provider: c.provider, kind: 'ORDER_DOCUMENT', entityId: orderId, status: { in: ['SENDING', 'NEEDS_REVIEW'] } },
select: { id: true },
});
return stuck
? 'سند حسابداری این سفارش در حال ارسال است یا منتظر بررسی؛ پس از روشن شدن وضعیتش دوباره تلاش کنید.'
: null;
}
/**
* F3 — the order's lines or weights were corrected after it was placed.
*
* A document still waiting (or failed and due for a retry) is built from the
* order at the moment it is sent, so it picks the correction up by itself.
* One already in the books no longer matches: it is withdrawn and a corrected
* one is queued behind the delete (the worker runs the older job first).
*/
async orderEdited(tx: Tx, orderId: string) {
const c = await this.config.get();
if (c.provider === 'none' || !c.features.documents) return;
const sent = await tx.accountingOutbox.findFirst({
where: { provider: c.provider, kind: 'ORDER_DOCUMENT', entityId: orderId, status: { in: ['SENT'] } },
select: { id: true },
});
if (!sent) return;
// Already withdrawn and re-queued by an earlier correction: that new job
// will read the latest figures too.
const requeued = await tx.accountingOutbox.findFirst({
where: { provider: c.provider, kind: 'ORDER_DOCUMENT', entityId: orderId, status: { in: ['PENDING'] } },
select: { id: true },
});
if (requeued) return;
await this.orderCancelled(tx, orderId);
await tx.accountingOutbox.create({ data: { provider: c.provider, kind: 'ORDER_DOCUMENT', entityId: orderId } });
this.worker.kickSoon();
}
/** F1 — an admin marked the customer VERIFIED. */
async customerVerified(tx: Tx, customerId: string) {
await this.enqueue(tx, 'customers', 'CUSTOMER_PUSH', customerId);
@@ -5,6 +5,7 @@ import { OTP_ONLY_PASSWORD_HASH } from '../auth/password-hash';
import { ActiveProvider, AccountingConfigService } from './accounting-config.service';
import { AccountingConfig } from './accounting-config';
import { JalaliDate, parseJalali, toJalali } from './jalali';
import { documentWeightTolerance } from './document-check';
import {
AccountingProviderError,
CustomerPayload,
@@ -844,7 +845,7 @@ export class AccountingSyncService {
new Prisma.Decimal(0),
);
const mismatch = docGold.sub(order.totalGoldWeightGrams).abs();
if (mismatch.gt('0.01')) {
if (mismatch.gt(documentWeightTolerance(order.items.reduce((s, i) => s + i.quantity, 0)))) {
throw new NeedsReviewError(
`جمع وزن سند (${docGold.toFixed(3)}) با وزن سفارش (${new Prisma.Decimal(order.totalGoldWeightGrams).toFixed(3)}) نمی‌خواند`,
);
@@ -0,0 +1,43 @@
import { Prisma } from '@prisma/client';
import { documentWeightTolerance } from './document-check';
/**
* The document's gold is rebuilt from lines whose unit weight is stored to four
* decimals; the order's total is not. The allowance has to cover that rounding
* for every piece, or a large order is held for review for nothing.
*/
describe('documentWeightTolerance', () => {
const r4 = (x: number) => Math.round(x * 1e4) / 1e4;
it('is 0.01 g plus 0.0001 g per piece', () => {
expect(documentWeightTolerance(0).toString()).toBe('0.01');
expect(documentWeightTolerance(10).toString()).toBe('0.011');
expect(documentWeightTolerance(200).toString()).toBe('0.03');
});
it('never goes below the flat 0.01 g', () => {
expect(documentWeightTolerance(-5).toString()).toBe('0.01');
});
it('covers the worst rounding of a big order (the case a flat 0.01 g rejected)', () => {
// 5 lines × 200 pieces; 7.00003 × 1.085 = 7.5950326, stored as 7.5950, so every
// piece is short by the same 0.0000326 g and the errors add up instead of cancelling.
const lines = Array.from({ length: 5 }, () => ({ rash: 7.00003, pct: 8.5, qty: 200 }));
let total = 0;
let stored = 0;
for (const l of lines) {
const unit = l.rash * (1 + l.pct / 100);
total += unit * l.qty;
stored += r4(unit) * l.qty;
}
const drift = new Prisma.Decimal(Math.abs(r4(total) - stored));
const pieces = lines.reduce((s, l) => s + l.qty, 0);
expect(drift.gt('0.01')).toBe(true); // a flat 0.01 g would have held this order
expect(drift.lte(documentWeightTolerance(pieces))).toBe(true);
});
it('still catches a real disagreement', () => {
// 1 g missing from a 1000-piece order is far beyond rounding.
expect(new Prisma.Decimal(1).gt(documentWeightTolerance(1000))).toBe(true);
});
});
@@ -0,0 +1,17 @@
import { Prisma } from '@prisma/client';
/**
* How far an order document's gold may differ from the order's own total
* before the document is held for review (F3 acceptance rule).
*
* The order total comes from the unrounded sum of its lines, but each line
* stores its UNIT weight to four decimals, and the document is built from the
* stored lines. A unit can therefore be off by up to 0.00005 g, and a line of
* `qty` units by `qty` times that — a flat 0.01 g allowance is crossed by a
* big order (about 200 pieces in all) through rounding alone, with nothing
* wrong in the books. The allowance grows with the pieces: 0.01 g plus twice
* the worst per-unit rounding for every piece.
*/
export function documentWeightTolerance(totalQuantity: number): Prisma.Decimal {
return new Prisma.Decimal('0.01').add(new Prisma.Decimal('0.0001').mul(Math.max(0, totalQuantity)));
}
@@ -1,5 +1,35 @@
import { LedgerCurrency, LedgerDirection, OrderStatus, Prisma } from '@prisma/client';
import { LedgerService } from './ledger.service';
import { LedgerService, repaymentToGrams } from './ledger.service';
/**
* The repayment conversions, against the reference's CashController::store
* worked by hand:
* type 1 value = original_value × ayar / 750
* type 4 value = price / (price_per_gram / 4.331802)
*/
describe('repaymentToGrams', () => {
it('melted gold: 20 g at 900 is 24 g of 750', () => {
expect(repaymentToGrams('MELTED_GOLD', '20', 900).toString()).toBe('24');
expect(repaymentToGrams('MELTED_GOLD', '10', 750).toString()).toBe('10');
expect(repaymentToGrams('MELTED_GOLD', '12.5', 916).toDecimalPlaces(4).toString()).toBe('15.2667');
});
it('cash: 50,000,000 at 20,000,000 per mesghal is 10.8295 g', () => {
// 50e6 × 4.331802 / 20e6
expect(repaymentToGrams('CASH', '50000000', undefined, '20000000').toDecimalPlaces(6).toString()).toBe('10.829505');
});
it('transfer and cheque are the grams as typed', () => {
expect(repaymentToGrams('TRANSFER', '7.25').toString()).toBe('7.25');
expect(repaymentToGrams('CHEQUE', '3').toString()).toBe('3');
});
it('refuses a melted-gold figure without a karat and a cash figure without a price', () => {
expect(() => repaymentToGrams('MELTED_GOLD', '20')).toThrow('karatPerMille');
expect(() => repaymentToGrams('CASH', '100')).toThrow('pricePerGramRial');
expect(() => repaymentToGrams('CASH', '100', undefined, '0')).toThrow('pricePerGramRial');
});
});
/**
* ACC-03 / MON-04 — an order's debt follows its status, exactly as legacy's
@@ -92,3 +122,129 @@ describe('LedgerService.syncOrderDebt', () => {
expect(t.entries).toHaveLength(0);
});
});
/**
* `confirmed` on a repayment: an admin's own document counts at once (the
* reference's store sets confirmed = 1); the default stays pending.
*/
describe('LedgerService.postGoldRepayment — confirmed', () => {
function build() {
const balance = { gold: new Prisma.Decimal(10) };
const created: any[] = [];
const tx = {
ledgerEntry: {
create: jest.fn(async ({ data }: any) => {
created.push(data);
return { id: 'e1', ...data };
}),
},
customer: {
update: jest.fn(async ({ data }: any) => {
balance.gold = balance.gold.add(data.goldDebtGrams.increment);
}),
},
};
return { service: new LedgerService(tx as never), balance, created, tx };
}
it('confirmed: the debt drops at once by the converted grams', async () => {
const t = build();
await t.service.postGoldRepayment(
{ customerId: 'c1', instrument: 'MELTED_GOLD', rawValue: '20', karatPerMille: 900, confirmed: true },
t.tx as never,
);
expect(t.balance.gold.toString()).toBe('-14'); // 10 − 24
expect(t.created[0]).toMatchObject({ confirmed: true });
});
it('by default: pending, the debt does not move', async () => {
const t = build();
await t.service.postGoldRepayment({ customerId: 'c1', instrument: 'TRANSFER', rawValue: '3' }, t.tx as never);
expect(t.balance.gold.toString()).toBe('10');
expect(t.created[0]).toMatchObject({ confirmed: false });
});
});
/**
* Confirming / un-confirming is a flag flip plus a move of the cached balance.
* Check-then-update let N simultaneous requests (a double-click, two admins) all
* pass the check and all move the balance, leaving the cache N times off the book
* (found by firing 10 toggles at one document: book 8.04 g, cache 71.44 g).
* The flip is now a compare-and-swap and only its winner moves the balance.
*/
describe('LedgerService — simultaneous confirm / un-confirm', () => {
const D = (v: Prisma.Decimal.Value) => new Prisma.Decimal(v);
const tick = () => new Promise<void>((r) => setImmediate(r));
function book(confirmed: boolean) {
const row: Record<string, any> = {
id: 'e1',
customerId: 'c1',
currency: LedgerCurrency.GOLD_GRAM,
direction: LedgerDirection.CREDIT,
amount: D(7),
confirmed,
confirmedAt: null,
postedByAdminId: null,
};
const balance = { gold: D(confirmed ? -7 : 0) };
// Each call yields first, so concurrent callers interleave exactly as separate requests do.
// `updateMany` checks and sets with nothing awaited between them: the row lock of a real UPDATE ... WHERE.
const db: any = {
ledgerEntry: {
findUnique: jest.fn(async () => (await tick(), { ...row })),
update: jest.fn(async ({ data }: any) => (await tick(), Object.assign(row, data), { ...row })),
updateMany: jest.fn(async ({ where, data }: any) => {
await tick();
if (row.id !== where.id || row.confirmed !== where.confirmed) return { count: 0 };
Object.assign(row, data);
return { count: 1 };
}),
},
customer: {
update: jest.fn(async ({ data }: any) => {
await tick();
balance.gold = balance.gold.add(data.goldDebtGrams.increment);
return {};
}),
},
};
db.$transaction = jest.fn(async (fn: any) => fn({ ...db })); // a real transaction client is not the root client
return { service: new LedgerService(db as never), row, balance, db };
}
it('ten simultaneous confirms of a pending credit take 7 g off the debt once', async () => {
const { service, balance, row } = book(false);
await Promise.all(Array.from({ length: 10 }, () => service.confirmEntry('e1', 'admin1')));
expect(row.confirmed).toBe(true);
expect(balance.gold.toString()).toBe('-7');
});
it('ten simultaneous un-confirms of a confirmed credit put the 7 g back once', async () => {
const { service, balance, row } = book(true);
const results = await Promise.all(Array.from({ length: 10 }, () => service.setConfirmed('e1', false)));
expect(row.confirmed).toBe(false);
expect(balance.gold.toString()).toBe('0');
expect(results.filter((r) => r.changed)).toHaveLength(1);
});
it('confirming something already confirmed changes nothing', async () => {
const { service, balance } = book(true);
const r = await service.setConfirmed('e1', true);
expect(r.changed).toBe(false);
expect(balance.gold.toString()).toBe('-7');
});
it('a confirm then an un-confirm leaves the balance exactly where it started', async () => {
const { service, balance } = book(false);
await service.setConfirmed('e1', true);
await service.setConfirmed('e1', false);
expect(balance.gold.toString()).toBe('0');
});
it('an unknown entry is still a 404', async () => {
const { service, db } = book(false);
db.ledgerEntry.findUnique.mockResolvedValueOnce(null);
await expect(service.confirmEntry('nope')).rejects.toThrow('not found');
});
});
@@ -10,6 +10,43 @@ const ZERO = new Prisma.Decimal(0);
/** Legacy `sts > 4`: delivered to the expert (5) or to the customer (6). */
const OWING_STATUSES: OrderStatus[] = [OrderStatus.DELIVERED_TO_AGENT, OrderStatus.DELIVERED_TO_CUSTOMER];
export type RepaymentInstrument = 'MELTED_GOLD' | 'TRANSFER' | 'CHEQUE' | 'CASH';
/**
* The raw figure of a repayment document → the gold grams it takes off the
* debt. One place, used both when the document is filed and when an unconfirmed
* one is edited, so the two can never disagree (legacy CashController::store
* and ::update).
*
* MELTED_GOLD value = original_value × ayar / 750
* CASH value = price / (price_per_gram / 4.331802)
* TRANSFER / CHEQUE the grams as typed
*/
export function repaymentToGrams(
instrument: RepaymentInstrument,
rawValue: Prisma.Decimal.Value,
karatPerMille?: number | null,
pricePerGramRial?: Prisma.Decimal.Value | null,
): Prisma.Decimal {
const raw = new Prisma.Decimal(rawValue);
switch (instrument) {
case 'MELTED_GOLD':
if (!karatPerMille) throw new BadRequestException('karatPerMille is required for a melted-gold repayment');
return raw.mul(karatPerMille).div(STANDARD_KARAT_PER_MILLE);
case 'CASH': {
if (!pricePerGramRial || new Prisma.Decimal(pricePerGramRial).isZero()) {
throw new BadRequestException('pricePerGramRial is required for a cash repayment');
}
// legacy: value = price / (price_per_gram / 4.331802) — preserved verbatim;
// flagged in migration analysis as a formula worth stakeholder review.
return raw.div(new Prisma.Decimal(pricePerGramRial).div(GRAMS_PER_MESGHAL));
}
case 'TRANSFER':
case 'CHEQUE':
return raw;
}
}
export interface PostEntryInput {
customerId: string;
currency: LedgerCurrency;
@@ -77,17 +114,50 @@ export class LedgerService {
* change from legacy, not a faithful mirror of it.
*/
async confirmEntry(entryId: string, adminUserId?: string, tx: Tx = this.prisma) {
return (await this.setConfirmed(entryId, true, adminUserId, tx)).entry;
}
/**
* Sets an entry's confirmed flag and moves the cached balance by exactly that
* change, once. Un-confirming reverses the entry: the same amount, the other way.
*
* The flip is a compare-and-swap (`updateMany` while the flag still has its old
* value) and only the request that wins it touches the balance. Reading the flag
* and then updating let N simultaneous requests all pass the read and all move
* the balance, leaving the cache N times off the book. The flip and the balance
* move commit together.
*
* `changed` is false when someone else already put the entry in that state.
*/
async setConfirmed(
entryId: string,
confirmed: boolean,
adminUserId?: string,
tx: Tx = this.prisma,
): Promise<{ entry: Prisma.LedgerEntryGetPayload<object>; changed: boolean }> {
if (tx === this.prisma) {
return this.prisma.$transaction((inner) => this.setConfirmed(entryId, confirmed, adminUserId, inner));
}
const entry = await tx.ledgerEntry.findUnique({ where: { id: entryId } });
if (!entry) throw new NotFoundException('Ledger entry not found');
if (entry.confirmed) return entry;
const updated = await tx.ledgerEntry.update({
where: { id: entryId },
data: { confirmed: true, confirmedAt: new Date(), postedByAdminId: adminUserId ?? entry.postedByAdminId },
const claimed = await tx.ledgerEntry.updateMany({
where: { id: entryId, confirmed: !confirmed },
data: confirmed
? { confirmed: true, confirmedAt: new Date(), postedByAdminId: adminUserId ?? entry.postedByAdminId }
: { confirmed: false, confirmedAt: null },
});
const current = (await tx.ledgerEntry.findUnique({ where: { id: entryId } })) ?? entry;
if (claimed.count === 0) return { entry: current, changed: false };
await this.applyToBalance(entry.customerId, entry.currency, entry.direction, entry.amount, tx);
return updated;
const direction = confirmed
? entry.direction
: entry.direction === LedgerDirection.DEBIT
? LedgerDirection.CREDIT
: LedgerDirection.DEBIT;
await this.applyToBalance(entry.customerId, entry.currency, direction, entry.amount, tx);
return { entry: current, changed: true };
}
private async applyToBalance(
@@ -166,58 +236,40 @@ export class LedgerService {
/**
* Normalizes a physical repayment instrument into gold grams and posts a
* pending (unconfirmed) CREDIT entry against the customer's gold debt.
* Formulas preserved verbatim from legacy CashController::store. Note:
* legacy auto-confirms (confirmed=1) every Cash row at creation — posting
* this as pending instead is an intentional design change, not a mirror of
* legacy's default.
* CREDIT entry against the customer's gold debt. Formulas preserved verbatim
* from legacy CashController::store.
*
* `confirmed` follows the reference: a document an ADMIN files
* (CashController::store, `confirmed = 1`) counts at once; one a customer
* submits from the app (ApiMobileController::setFindoc, `confirmed = 0`) waits
* for an admin. The default stays pending — the safe state for a caller that
* does not say who is filing — and the admin documents screen passes true.
*/
async postGoldRepayment(
params: {
customerId: string;
instrument: 'MELTED_GOLD' | 'TRANSFER' | 'CHEQUE' | 'CASH';
instrument: RepaymentInstrument;
rawValue: Prisma.Decimal.Value;
karatPerMille?: number;
pricePerGramRial?: Prisma.Decimal.Value;
note?: string;
postedByAdminId?: string;
confirmed?: boolean;
},
tx: Tx = this.prisma,
) {
const raw = new Prisma.Decimal(params.rawValue);
let goldGramsValue: Prisma.Decimal;
let sourceType: LedgerSourceType;
switch (params.instrument) {
case 'MELTED_GOLD': {
if (!params.karatPerMille) {
throw new BadRequestException('karatPerMille is required for a melted-gold repayment');
}
// legacy: value = original_value * ayar / 750
goldGramsValue = raw.mul(params.karatPerMille).div(STANDARD_KARAT_PER_MILLE);
sourceType = LedgerSourceType.REPAYMENT_MELTED_GOLD;
break;
}
case 'CASH': {
if (!params.pricePerGramRial) {
throw new BadRequestException('pricePerGramRial is required for a cash repayment');
}
// legacy: value = price / (price_per_gram / 4.331802) — preserved verbatim;
// flagged in migration analysis as a formula worth stakeholder review.
const pricePerGram = new Prisma.Decimal(params.pricePerGramRial);
goldGramsValue = raw.div(pricePerGram.div(GRAMS_PER_MESGHAL));
sourceType = LedgerSourceType.REPAYMENT_CASH;
break;
}
case 'TRANSFER':
goldGramsValue = raw;
sourceType = LedgerSourceType.REPAYMENT_TRANSFER;
break;
case 'CHEQUE':
goldGramsValue = raw;
sourceType = LedgerSourceType.REPAYMENT_CHEQUE;
break;
}
const goldGramsValue = repaymentToGrams(
params.instrument,
params.rawValue,
params.karatPerMille,
params.pricePerGramRial,
);
const sourceType = {
MELTED_GOLD: LedgerSourceType.REPAYMENT_MELTED_GOLD,
CASH: LedgerSourceType.REPAYMENT_CASH,
TRANSFER: LedgerSourceType.REPAYMENT_TRANSFER,
CHEQUE: LedgerSourceType.REPAYMENT_CHEQUE,
}[params.instrument];
return this.postEntry(
{
@@ -229,7 +281,7 @@ export class LedgerService {
karatPerMille: params.karatPerMille,
pricePerGramRialAtEntry: params.pricePerGramRial,
note: params.note,
confirmed: false, // pending until an admin confirms — intentional deviation from legacy, which auto-confirms on creation
confirmed: params.confirmed ?? false,
postedByAdminId: params.postedByAdminId,
},
tx,
@@ -0,0 +1,277 @@
import { LedgerCurrency, LedgerDirection, OrderStatus, Prisma } from '@prisma/client';
import { LedgerService } from './ledger.service';
import { OrdersService } from '../orders/orders.service';
import { WageService } from '../pricing/wage.service';
/**
* End-to-end parity with the reference (henza_server / henza_pwa): the numbers
* an order, its debt and its repayments produce here must equal what the PHP
* computed. The reference arithmetic is re-typed below line for line from
*
* ApiMobileController::setOrder (:556-566, :615-632)
* CustomerController::updateRemain (:42-51)
* Adm\CashController::store (:55-73)
*
* in plain JS floats, exactly as PHP did, and the real WageService,
* OrdersService and LedgerService are run against an in-memory database.
*/
/* ───────────── the reference, typed out ───────────── */
interface RefItem {
rash_weight: number;
percent_pay: number; // PWA getWage(): customer.percent + variant.percentage_pay
discount: number; // PWA getPromotion(): the largest promotion
qty: number;
rial_pay: number;
}
function refSetOrder(items: RefItem[]) {
const allGram = items.reduce((s, i) => {
const percent = i.percent_pay - i.discount;
const gram = i.rash_weight + i.rash_weight * (percent / 100);
return s + gram * i.qty;
}, 0);
const wage = items.reduce((s, i) => s + i.rash_weight * i.qty, 0);
const rial_input = items.reduce((s, i) => s + i.rial_pay * i.qty, 0);
return { input: allGram, wage: allGram - wage, rial_input };
}
/** remain = Σ orders.input (sts>4) − Σ cashes.value (confirmed, type 1-4); rial_remain = Σ orders.rial_input (sts>4) */
function refUpdateRemain(
orders: { input: number; rial_input: number; sts: number }[],
cashes: { value: number; confirmed: boolean }[],
) {
const delivered = orders.filter((o) => o.sts > 4);
return {
remain: delivered.reduce((s, o) => s + o.input, 0) - cashes.filter((c) => c.confirmed).reduce((s, c) => s + c.value, 0),
rial_remain: delivered.reduce((s, o) => s + o.rial_input, 0),
};
}
const refMelted = (originalValue: number, ayar: number) => (originalValue * ayar) / 750;
const refCash = (price: number, pricePerGram: number) => price / (pricePerGram / 4.331802);
/* ───────────── the in-memory database ───────────── */
const D = (v: Prisma.Decimal.Value) => new Prisma.Decimal(v);
interface Variant {
id: string;
rashWeightGrams: string;
defaultWagePercent: string;
rialWage: string | null;
product: { id: string; categoryId: string | null; brandId: string | null };
}
interface Entry {
id: string;
customerId: string;
orderId?: string;
currency: LedgerCurrency;
direction: LedgerDirection;
sourceType: string;
amount: Prisma.Decimal;
confirmed: boolean;
}
function world(opts: { customerPercent: string; variants: Variant[]; promotions?: unknown[] }) {
const entries: Entry[] = [];
const customer = {
id: 'c1',
canPlaceOrders: true,
referredByAgentId: null,
makingChargeOffsetPercent: D(opts.customerPercent),
goldDebtGrams: D(0),
fiatDebtRial: D(0),
goldCreditLimitGrams: D(100000),
};
const orders: Record<string, unknown>[] = [];
let seq = 0;
// eslint-disable-next-line prefer-const
let db: Record<string, any>;
db = {
customer: {
findUnique: jest.fn(async () => customer),
update: jest.fn(async ({ data }: { data: { goldDebtGrams?: { increment: Prisma.Decimal }; fiatDebtRial?: { increment: Prisma.Decimal } } }) => {
if (data.goldDebtGrams) customer.goldDebtGrams = customer.goldDebtGrams.add(data.goldDebtGrams.increment);
if (data.fiatDebtRial) customer.fiatDebtRial = customer.fiatDebtRial.add(data.fiatDebtRial.increment);
return customer;
}),
},
productVariant: {
findMany: jest.fn(async ({ where }: { where: { id: { in: string[] } } }) => opts.variants.filter((v) => where.id.in.includes(v.id))),
},
promotion: { findMany: jest.fn(async () => opts.promotions ?? []) },
promotionExclusion: { findMany: jest.fn(async () => []) },
setting: { findUnique: jest.fn(async () => null) }, // → legacy formula
order: {
create: jest.fn(async ({ data }: { data: Record<string, unknown> }) => {
const row = { id: `o${++seq}`, customerId: 'c1', ...data };
orders.push(row);
return row;
}),
findUnique: jest.fn(async ({ where }: { where: { id: string } }) => orders.find((o) => o.id === where.id)),
},
ledgerEntry: {
create: jest.fn(async ({ data }: { data: Omit<Entry, 'id'> }) => {
const e = { id: `e${entries.length + 1}`, ...data } as Entry;
entries.push(e);
return e;
}),
findUnique: jest.fn(async ({ where }: { where: { id: string } }) => entries.find((e) => e.id === where.id) ?? null),
update: jest.fn(async ({ where, data }: { where: { id: string }; data: Partial<Entry> }) => {
const e = entries.find((x) => x.id === where.id)!;
Object.assign(e, data);
return e;
}),
updateMany: jest.fn(async ({ where, data }: { where: { id: string; confirmed: boolean }; data: Partial<Entry> }) => {
const e = entries.find((x) => x.id === where.id && x.confirmed === where.confirmed);
if (!e) return { count: 0 };
Object.assign(e, data);
return { count: 1 };
}),
groupBy: jest.fn(async ({ where }: { where: { orderId: string; currency: LedgerCurrency } }) => {
const out: { direction: LedgerDirection; _sum: { amount: Prisma.Decimal } }[] = [];
for (const d of [LedgerDirection.DEBIT, LedgerDirection.CREDIT]) {
const hit = entries.filter(
(e) => e.orderId === where.orderId && e.currency === where.currency && e.direction === d && e.confirmed && e.sourceType === 'ORDER_STOCK',
);
if (hit.length) out.push({ direction: d, _sum: { amount: hit.reduce((s, e) => s.add(e.amount), D(0)) } });
}
return out;
}),
},
$queryRaw: jest.fn(async () => [{ n: 100n }]),
$transaction: jest.fn(async (fn: (t: unknown) => unknown): Promise<unknown> => fn({ ...db })),
};
const catalog = {
getVariantOrThrow: jest.fn(async (id: string) => {
const v = opts.variants.find((x) => x.id === id)!;
return { id: v.id, rashWeightGrams: v.rashWeightGrams, rialWage: v.rialWage };
}),
};
const ledger = new LedgerService(db as never);
const wage = new WageService(db as never);
const orderService = new OrdersService(
db as never,
catalog as never,
ledger,
{ orderStatusReached: jest.fn(), orderCancelled: jest.fn() } as never,
wage,
{ send: jest.fn(async () => ({ ok: true })) } as never,
);
return { db, customer, entries, orders, ledger, orderService };
}
/* ───────────── the scenario ───────────── */
const variants: Variant[] = [
{ id: 'vA', rashWeightGrams: '5.7856', defaultWagePercent: '7', rialWage: '150000', product: { id: 'pA', categoryId: null, brandId: null } },
{ id: 'vB', rashWeightGrams: '12.3', defaultWagePercent: '5.5', rialWage: null, product: { id: 'pB', categoryId: null, brandId: null } },
{ id: 'vC', rashWeightGrams: '2.5', defaultWagePercent: '9', rialWage: '80000', product: { id: 'pC', categoryId: null, brandId: null } },
];
// Two active product promotions on A (2% and 1%: the reference takes the largest),
// one on C (3%), none on B. The customer's own percent is 3.
const promotions = [
{ scope: 'PRODUCT', productId: 'pA', categoryId: null, brandId: null, discountPercent: D(2) },
{ scope: 'PRODUCT', productId: 'pA', categoryId: null, brandId: null, discountPercent: D(1) },
{ scope: 'PRODUCT', productId: 'pC', categoryId: null, brandId: null, discountPercent: D(3) },
];
const refItems: RefItem[] = [
{ rash_weight: 5.7856, percent_pay: 3 + 7, discount: 2, qty: 3, rial_pay: 150000 },
{ rash_weight: 12.3, percent_pay: 3 + 5.5, discount: 0, qty: 1, rial_pay: 0 },
{ rash_weight: 2.5, percent_pay: 3 + 9, discount: 3, qty: 10, rial_pay: 80000 },
];
const near = (actual: Prisma.Decimal.Value, expected: number, eps = 1e-3) =>
expect(Math.abs(Number(actual) - expected)).toBeLessThan(eps);
describe('parity with the reference: order → debt → repayment', () => {
async function place() {
const w = world({ customerPercent: '3', variants, promotions });
const order = (await w.orderService.createOrder(
{
items: [
{ productVariantId: 'vA', quantity: 3, wagePercent: 0 },
{ productVariantId: 'vB', quantity: 1, wagePercent: 0 },
{ productVariantId: 'vC', quantity: 10, wagePercent: 0 },
],
} as never,
'c1',
{ placedBy: 'customer' },
)) as unknown as { id: string; totalGoldWeightGrams: Prisma.Decimal; wageWeightGrams: Prisma.Decimal; flatRialCharge: Prisma.Decimal };
return { w, order };
}
it('prices the order like setOrder: grams, making-charge grams and rial making charge', async () => {
const { order } = await place();
const ref = refSetOrder(refItems);
near(order.totalGoldWeightGrams, ref.input); // order.input / order.w
near(order.wageWeightGrams, ref.wage); // order.wage = allGram − Σ rash×qty
near(order.flatRialCharge, ref.rial_input, 0.5); // order.rial_input = Σ rial_pay×qty
expect(order.flatRialCharge.toString()).toBe('1250000'); // 150000×3 + 0 + 80000×10
});
it('owes nothing until the order is delivered, then exactly input and rial_input (updateRemain)', async () => {
const { w, order } = await place();
const ref = refSetOrder(refItems);
await w.ledger.syncOrderDebt(order.id, w.db as never);
expect(w.customer.goldDebtGrams.toString()).toBe('0'); // sts 1
expect(w.customer.fiatDebtRial.toString()).toBe('0');
(w.orders[0] as { status: OrderStatus }).status = OrderStatus.DELIVERED_TO_AGENT; // sts 5
await w.ledger.syncOrderDebt(order.id, w.db as never);
const legacy = refUpdateRemain([{ ...ref, sts: 5 }], []);
near(w.customer.goldDebtGrams, legacy.remain);
near(w.customer.fiatDebtRial, legacy.rial_remain, 0.5);
});
it('subtracts a melted-gold and a cash repayment the way CashController does, only once confirmed', async () => {
const { w, order } = await place();
(w.orders[0] as { status: OrderStatus }).status = OrderStatus.DELIVERED_TO_CUSTOMER;
await w.ledger.syncOrderDebt(order.id, w.db as never);
const ref = refSetOrder(refItems);
const melted = await w.ledger.postGoldRepayment({ customerId: 'c1', instrument: 'MELTED_GOLD', rawValue: '20', karatPerMille: 900 }, w.db as never);
const cash = await w.ledger.postGoldRepayment(
{ customerId: 'c1', instrument: 'CASH', rawValue: '50000000', pricePerGramRial: '20000000' },
w.db as never,
);
// Pending documents have not moved the balance.
near(w.customer.goldDebtGrams, refUpdateRemain([{ ...ref, sts: 6 }], []).remain);
await w.ledger.confirmEntry(melted.id, undefined, w.db as never);
await w.ledger.confirmEntry(cash.id, undefined, w.db as never);
const legacy = refUpdateRemain(
[{ ...ref, sts: 6 }],
[
{ value: refMelted(20, 900), confirmed: true }, // 24 g
{ value: refCash(50_000_000, 20_000_000), confirmed: true }, // 10.829505 g
],
);
near(melted.amount, 24);
near(cash.amount, 10.829505, 1e-6);
near(w.customer.goldDebtGrams, legacy.remain);
// A cash payment lowers the GOLD debt only — rial debt is untouched (reference quirk, kept).
near(w.customer.fiatDebtRial, legacy.rial_remain, 0.5);
});
it('puts the order back to zero debt when it is cancelled after delivery', async () => {
const { w, order } = await place();
(w.orders[0] as { status: OrderStatus }).status = OrderStatus.DELIVERED_TO_AGENT;
await w.ledger.syncOrderDebt(order.id, w.db as never);
(w.orders[0] as { status: OrderStatus }).status = OrderStatus.CANCELLED;
await w.ledger.syncOrderDebt(order.id, w.db as never);
expect(w.customer.goldDebtGrams.toString()).toBe('0');
expect(w.customer.fiatDebtRial.toString()).toBe('0');
});
});
@@ -5,6 +5,7 @@ import { PrismaService } from '../../prisma/prisma.service';
import { TableService } from '../admin-table/table.service';
import type { TableQueryDto } from '../admin-table/table-query.dto';
import { faToEn } from '../customer-auth/digits';
import { birthDateOrNull, nationalCodeOrNull } from '../../common/customer-input';
import { OTP_ONLY_PASSWORD_HASH } from '../auth/password-hash';
import { agentSalesWhere, customerSalesWhere } from '../sales-agent/agent-orders';
import { SmsService } from '../sms/sms.service';
@@ -72,33 +73,6 @@ export type OmdeScope = 'all' | 'banker' | 'shopkeeper' | 'non_verified' | 'arch
* `sts_verify_step_one !== 2`, i.e. only an ACCEPTED first step unlocks them.
* The new schema models one ordered enum, so these numbers are derived from it.
*/
/**
* PERS-03: the birth date both customer forms collect (legacy bulkCustomer
* `birth_date`, singleCustomer `birth_day`). DateInput sends the Gregorian
* 'YYYY-MM-DD' of the Jalali date picked; stored at UTC midnight, as the
* import stores legacy's. undefined = not sent (leave as is), '' = cleared.
*/
function birthDateOrNull(raw: unknown): Date | null | undefined {
if (raw === undefined) return undefined;
if (raw === null || String(raw).trim() === '') return null;
const m = /^(\d{4})-(\d{1,2})-(\d{1,2})/.exec(String(raw).trim());
const d = m ? new Date(Date.UTC(+m[1], +m[2] - 1, +m[3])) : null;
if (!d || d.getUTCMonth() !== +m![2] - 1 || d.getUTCFullYear() < 1900 || d > new Date()) {
throw new BadRequestException('تاریخ تولد معتبر نیست');
}
return d;
}
/**
* A national code as the operator typed it, digits normalised; empty -> null.
* The column is optional and unique (2026-10-07): an absent code is NULL,
* never the mobile or '' — two customers without one must not collide, and
* the column must only ever hold a national code.
*/
function nationalCodeOrNull(raw: unknown): string | null {
return faToEn(String(raw ?? '').trim()) || null;
}
function verifyStepOne(step: VerificationStep): number {
switch (step) {
case VerificationStep.UNVERIFIED:
@@ -0,0 +1,192 @@
import { BadRequestException } from '@nestjs/common';
import { LedgerCurrency, LedgerDirection, LedgerSourceType, Prisma } from '@prisma/client';
import { AdminDocumentsService } from './admin-documents.service';
/**
* Editing a repayment document (legacy CashController::update).
*
* The edit form posts the WHOLE form, `value` included — the value as it was
* loaded. These tests post exactly that and check that the gold taken off the
* debt follows the raw figures the operator corrected.
*/
describe('AdminDocumentsService.update', () => {
const D = (v: Prisma.Decimal.Value) => new Prisma.Decimal(v);
function build(existing: Record<string, unknown>) {
const row: Record<string, any> = {
id: 'd1',
customerId: 'c1',
currency: LedgerCurrency.GOLD_GRAM,
direction: LedgerDirection.CREDIT,
confirmed: false,
confirmedAt: null,
karatPerMille: null,
originalValue: null,
priceRial: null,
pricePerGramRialAtEntry: null,
customer: { id: 'c1', fullName: 'x', firstName: 'x', lastName: 'y', mobile: '0' },
postedByAdmin: null,
createdAt: new Date(),
...existing,
};
const prisma = {
ledgerEntry: {
findUnique: jest.fn(async () => row),
update: jest.fn(async ({ data }: { data: Record<string, any> }) => {
Object.assign(row, data);
return row;
}),
},
};
const service = new AdminDocumentsService(prisma as never, {} as never, {} as never, {} as never);
return { service, row, prisma };
}
const melted = { sourceType: LedgerSourceType.REPAYMENT_MELTED_GOLD, amount: D('15'), originalValue: D('16.6667'), karatPerMille: 675 };
it('recomputes the credited gold when the karat of an unconfirmed melted-gold document is corrected', async () => {
// 16.6667 g @ 675 = 15.00003 g was filed; the lab said 900.
const { service, row } = build({ ...melted, amount: D('15.0000'), originalValue: D('16.6667'), karatPerMille: 675 });
await service.update('d1', { type: 1, value: 15, original_value: 16.6667, ayar: 900 });
expect(row.karatPerMille).toBe(900);
expect(row.amount.toString()).toBe(D(16.6667).mul(900).div(750).toString()); // 20.00004
});
it('recomputes the credited gold when the raw weight is corrected', async () => {
const { service, row } = build({ ...melted, amount: D('15'), originalValue: D('16.6667'), karatPerMille: 900 });
await service.update('d1', { type: 1, value: 15, original_value: 20, ayar: 900 });
expect(row.originalValue.toString()).toBe('20');
expect(row.amount.toString()).toBe('24'); // 20 × 900 / 750
});
it('refuses to move the amount of a CONFIRMED document (the debt already counts it)', async () => {
const { service, row } = build({ ...melted, confirmed: true, amount: D('15'), originalValue: D('16.6667'), karatPerMille: 900 });
await expect(service.update('d1', { type: 1, value: 15, original_value: 20, ayar: 900 })).rejects.toThrow(BadRequestException);
expect(row.amount.toString()).toBe('15');
});
it('lets a confirmed document be re-saved untouched (the form posts the same figures back)', async () => {
// Stored to 4 places; the live conversion has more digits. Must not read as a change.
const { service, row } = build({ ...melted, confirmed: true, amount: D('20.0000'), originalValue: D('16.6667'), karatPerMille: 900 });
await expect(service.update('d1', { type: 1, value: 20, original_value: 16.6667, ayar: 900, note: 'checked' })).resolves.toBeDefined();
expect(row.note).toBe('checked');
expect(row.amount.toString()).toBe('20');
});
it('a confirmed imported document whose stored value is not original×ayar/750 can still have its note edited', async () => {
const { service } = build({ ...melted, confirmed: true, amount: D('48.34'), originalValue: D('50'), karatPerMille: 750 });
await expect(service.update('d1', { type: 1, value: 48.34, original_value: 50, ayar: 750, note: 'n' })).resolves.toBeDefined();
});
it('recomputes a cash document from price and price per gram, and stores the price per gram', async () => {
const { service, row } = build({
sourceType: LedgerSourceType.REPAYMENT_CASH,
amount: D('10.8295'),
priceRial: D('50000000'),
pricePerGramRialAtEntry: D('20000000'),
});
await service.update('d1', { type: 4, value: 10.8295, price: 50_000_000, price_per_gram: 25_000_000 });
expect(row.pricePerGramRialAtEntry.toString()).toBe('25000000');
expect(row.amount.toDecimalPlaces(4).toString()).toBe('8.6636'); // 5e7 / (2.5e7 / 4.331802)
});
it('takes the typed grams of a transfer as they are', async () => {
const { service, row } = build({ sourceType: LedgerSourceType.REPAYMENT_TRANSFER, amount: D('5') });
await service.update('d1', { type: 2, value: 7.5 });
expect(row.amount.toString()).toBe('7.5');
});
it('leaves a rial repayment alone: it has no conversion', async () => {
const { service, row } = build({
sourceType: LedgerSourceType.REPAYMENT_CASH,
currency: LedgerCurrency.RIAL,
amount: D('1000000'),
});
await service.update('d1', { type: 4, note: 'rial', price_per_gram: 99 });
expect(row.amount.toString()).toBe('1000000');
expect(row.note).toBe('rial');
});
});
/**
* Reference CashController::store sets `confirmed = 1`: a document an admin
* files counts at once and the gold debt drops then. Only a customer's own
* submission (set-findoc) waits for confirmation.
*/
describe('AdminDocumentsService.create — an admin-filed document counts at once', () => {
it('asks the ledger for a confirmed credit: melted gold 20 g at 900 takes 24 g off the debt at once', async () => {
const posted: any[] = [];
const ledger = {
postGoldRepayment: jest.fn(async (p: any) => {
posted.push(p);
return { id: 'e1' };
}),
};
const row = {
id: 'e1',
sourceType: LedgerSourceType.REPAYMENT_MELTED_GOLD,
currency: LedgerCurrency.GOLD_GRAM,
direction: LedgerDirection.CREDIT,
amount: new Prisma.Decimal(24),
confirmed: true,
customer: { id: 'c1', fullName: 'x', firstName: 'x', lastName: 'y', mobile: '0' },
postedByAdmin: null,
createdAt: new Date(),
};
const prisma = { ledgerEntry: { update: jest.fn(async () => row), findUnique: jest.fn(async () => row) } };
const service = new AdminDocumentsService(prisma as never, {} as never, ledger as never, {} as never);
await service.create({ type: 1, customer_id: 'c1', original_value: 20, ayar: 900 }, 'admin1');
expect(posted[0]).toMatchObject({ customerId: 'c1', instrument: 'MELTED_GOLD', rawValue: '20', karatPerMille: 900, confirmed: true });
});
});
/**
* The route takes a loose body (no DTO). Malformed or absurd figures used to
* reach `new Prisma.Decimal` / the database and come back as a 500; each is now
* a 400 and nothing is posted.
*/
describe('AdminDocumentsService.create — figures it must refuse', () => {
function build() {
const ledger = { postGoldRepayment: jest.fn(async () => ({ id: 'e1' })) };
const prisma = { ledgerEntry: { update: jest.fn(), findUnique: jest.fn() } };
const service = new AdminDocumentsService(prisma as never, {} as never, ledger as never, {} as never);
return { service, ledger };
}
const base = { customer_id: 'c1' };
const bad: [string, Record<string, unknown>][] = [
['melted weight "abc"', { type: 1, original_value: 'abc', ayar: 750 }],
['melted weight 1e30', { type: 1, original_value: 1e30, ayar: 750 }],
['melted weight beyond the column', { type: 1, original_value: '99999999999', ayar: 750 }],
['melted weight 0', { type: 1, original_value: 0, ayar: 750 }],
['melted weight negative', { type: 1, original_value: -5, ayar: 750 }],
['karat 5 000 000 000 (an Int column)', { type: 1, original_value: 5, ayar: 5000000000 }],
['karat 916.5 (an Int column)', { type: 1, original_value: 5, ayar: 916.5 }],
['karat 0', { type: 1, original_value: 5, ayar: 0 }],
['cash price 1e30', { type: 4, price: 1e30, price_per_gram: 61500000 }],
['cash price "abc"', { type: 4, price: 'abc', price_per_gram: 61500000 }],
['cash price per gram "abc"', { type: 4, price: 1000000, price_per_gram: 'abc' }],
['cash price per gram 0', { type: 4, price: 1000000, price_per_gram: 0 }],
['cash price so small it converts to 0.0000 g', { type: 4, price: 0.0001, price_per_gram: 61500000 }],
['cash price large enough to overflow the gram column once converted', { type: 4, price: '999999999999999', price_per_gram: '0.01' }],
['transfer weight an object', { type: 2, value: { a: 1 } }],
['transfer weight 0', { type: 2, value: 0 }],
];
it.each(bad)('%s → 400, nothing posted', async (_name, body) => {
const { service, ledger } = build();
await expect(service.create({ ...base, ...body }, 'admin1')).rejects.toThrow(BadRequestException);
expect(ledger.postGoldRepayment).not.toHaveBeenCalled();
});
it('still accepts the ordinary cases (melted 10 g @ 900, cash 100 000 000 @ 61 500 000)', async () => {
const a = build();
a.service.findOne = jest.fn(async () => ({}) as never);
await a.service.create({ ...base, type: 1, original_value: 10, ayar: 900 }, 'admin1');
expect(a.ledger.postGoldRepayment).toHaveBeenCalledTimes(1);
const b = build();
b.service.findOne = jest.fn(async () => ({}) as never);
await b.service.create({ ...base, type: 4, price: 100000000, price_per_gram: 61500000 }, 'admin1');
expect(b.ledger.postGoldRepayment).toHaveBeenCalledTimes(1);
});
});
@@ -2,7 +2,7 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm
import { LedgerCurrency, LedgerDirection, LedgerSourceType, Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { TableService } from '../admin-table/table.service';
import { LedgerService } from '../accounting/ledger.service';
import { LedgerService, repaymentToGrams } from '../accounting/ledger.service';
import { SmsService } from '../sms/sms.service';
import type { TableQueryDto } from '../admin-table/table-query.dto';
import { brandName } from '../../common/brand';
@@ -58,6 +58,52 @@ const TYPE_LABELS: Record<LedgerSourceType, string> = {
ADJUSTMENT: 'اصلاح',
};
/** LedgerEntry.originalValue is Decimal(14,4); the other figures have more room, so this is the tight bound. */
const MAX_WEIGHT_GRAMS = new Prisma.Decimal('9999999999.9999');
const MAX_RIAL = new Prisma.Decimal('999999999999999');
/**
* A request figure as a Decimal. This route takes a loose body (no DTO), so
* "abc", "1e400" or an object used to reach `new Prisma.Decimal` and come back as
* a 500; it is a 400 naming the field instead.
*/
function decimalOf(value: unknown, label: string): Prisma.Decimal {
const text = String(value ?? '').trim();
if (!/^-?\d+(\.\d+)?$/.test(text)) throw new BadRequestException(`${label} نامعتبر است`);
return new Prisma.Decimal(text);
}
/** A figure that is not negative and fits its column. */
function boundedFigure(value: unknown, label: string, max: Prisma.Decimal): Prisma.Decimal {
const d = decimalOf(value, label);
if (d.isNegative()) throw new BadRequestException(`${label} نمی‌تواند منفی باشد`);
if (d.gt(max)) throw new BadRequestException(`${label} بیش از حد مجاز است`);
return d;
}
/** A figure that is strictly positive and fits its column. */
function positiveFigure(value: unknown, label: string, max: Prisma.Decimal): Prisma.Decimal {
const d = boundedFigure(value, label, max);
if (d.isZero()) throw new BadRequestException(`${label} باید بزرگتر از صفر باشد`);
return d;
}
/** Karat in thousandths (750 = 18k): the column is an integer. */
function karatOf(value: unknown): number {
const n = Number(value);
if (!Number.isInteger(n) || n < 1 || n > 1000) throw new BadRequestException('عیار باید عدد صحیح بین ۱ تا ۱۰۰۰ باشد');
return n;
}
/** Grams a document carries must survive the four-decimal rounding as something, and fit the column. */
function checkedGrams(grams: Prisma.Decimal): Prisma.Decimal {
const rounded = grams.toDecimalPlaces(4);
if (rounded.lte(0) || rounded.gt(MAX_WEIGHT_GRAMS)) {
throw new BadRequestException('مقدار سند پس از تبدیل به گرم صفر یا بیش از حد مجاز است');
}
return grams;
}
@Injectable()
export class AdminDocumentsService {
constructor(
@@ -234,14 +280,26 @@ export class AdminDocumentsService {
? String(data.original_value ?? data.value ?? 0)
: String(data.value ?? 0);
// Every figure is checked here, before anything is posted: a bad one must
// not reach the database (a 500) and a document must not carry zero grams.
const isCash = instrument === 'CASH';
const raw = positiveFigure(rawValue, isCash ? 'مبلغ' : 'وزن', isCash ? MAX_RIAL : MAX_WEIGHT_GRAMS);
const karatPerMille = data.ayar ? karatOf(data.ayar) : undefined;
const pricePerGramRial = data.price_per_gram ? positiveFigure(data.price_per_gram, 'مظنه', MAX_RIAL) : undefined;
checkedGrams(repaymentToGrams(instrument, raw, karatPerMille, pricePerGramRial));
const entry = await this.ledger.postGoldRepayment({
customerId,
instrument,
rawValue,
karatPerMille: data.ayar ? Number(data.ayar) : undefined,
pricePerGramRial: data.price_per_gram ? String(data.price_per_gram) : undefined,
rawValue: raw.toString(),
karatPerMille,
pricePerGramRial: pricePerGramRial?.toString(),
note: (data.note as string) ?? undefined,
postedByAdminId: actorAdminUserId,
// Reference CashController::store sets `confirmed = 1`: a document an admin
// files counts at once. Only the customer's own submission (set-findoc,
// not built in this app) waits for confirmation. «تایید/رد» still toggles.
confirmed: true,
});
// Attach the instrument metadata the ledger service does not model.
@@ -261,10 +319,10 @@ export class AdminDocumentsService {
if (data.bank_name !== undefined) patch.bankName = (data.bank_name as string) || null;
if (data.to !== undefined) patch.transferTo = (data.to as string) || null;
if (data.original_value !== undefined && data.original_value !== null) {
patch.originalValue = new Prisma.Decimal(String(data.original_value));
patch.originalValue = boundedFigure(data.original_value, 'وزن', MAX_WEIGHT_GRAMS);
}
if (data.price !== undefined && data.price !== null) {
patch.priceRial = new Prisma.Decimal(String(data.price));
patch.priceRial = boundedFigure(data.price, 'مبلغ', MAX_RIAL);
}
if (data.recive_date) patch.receivedAt = new Date(String(data.recive_date));
return patch;
@@ -282,25 +340,81 @@ export class AdminDocumentsService {
const existing = await this.prisma.ledgerEntry.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('سند یافت نشد');
const amountChanged =
data.value !== undefined && !new Prisma.Decimal(String(data.value)).equals(existing.amount);
const patch = this.metadataPatch(data);
if (data.note !== undefined) patch.note = (data.note as string) || null;
const newAmount = this.amountAfterEdit(existing, data, patch);
const amountChanged = newAmount !== null && !newAmount.toDecimalPlaces(4).equals(existing.amount.toDecimalPlaces(4));
if (existing.confirmed && amountChanged) {
throw new BadRequestException(
'مبلغ سند تاییدشده قابل ویرایش نیست، چون بدهی مشتری بر اساس آن محاسبه شده است. برای اصلاح، سند جدید ثبت کنید.',
);
}
const patch = this.metadataPatch(data);
if (data.note !== undefined) patch.note = (data.note as string) || null;
if (!existing.confirmed && amountChanged) {
patch.amount = new Prisma.Decimal(String(data.value));
}
if (!existing.confirmed && amountChanged) patch.amount = checkedGrams(newAmount!);
await this.prisma.ledgerEntry.update({ where: { id }, data: patch });
return this.findOne(id);
}
/**
* The gram amount a document should carry after an edit, or null when the
* edit does not touch it.
*
* Legacy CashController::update recomputed `value = original_value × ayar / 750`
* on every edit of a melted-gold document. The edit form posts the whole form,
* including the OLD `value`, so trusting `data.value` (as this method used to)
* left the credited gold unchanged whenever the operator corrected the weight
* or the karat — and `ayar` was not even stored. The figure is therefore
* derived from the raw inputs whenever one of those changed; `data.value`
* only counts for the instruments that have no raw input (transfer, cheque).
*/
private amountAfterEdit(
existing: Prisma.LedgerEntryGetPayload<object>,
data: Record<string, unknown>,
patch: Prisma.LedgerEntryUpdateInput,
): Prisma.Decimal | null {
const instrument = INSTRUMENT_BY_TYPE[existing.sourceType];
// Rial repayments (postRialRepayment) share REPAYMENT_CASH but have no conversion.
if (!instrument || existing.currency !== LedgerCurrency.GOLD_GRAM) {
return data.value !== undefined && data.value !== null && data.value !== ''
? decimalOf(data.value, 'مقدار')
: null;
}
const present = (v: unknown) => v !== undefined && v !== null && v !== '';
const differs = (next: unknown, current: Prisma.Decimal.Value | null | undefined) =>
present(next) && (current === null || current === undefined || !decimalOf(next, 'مقدار').equals(current));
if (instrument === 'MELTED_GOLD') {
const karat = present(data.ayar) ? karatOf(data.ayar) : existing.karatPerMille;
const raw = present(data.original_value) ? data.original_value : existing.originalValue;
if (differs(data.ayar, existing.karatPerMille)) patch.karatPerMille = karat;
if (
(differs(data.ayar, existing.karatPerMille) || differs(data.original_value, existing.originalValue)) &&
karat &&
present(raw)
) {
return repaymentToGrams('MELTED_GOLD', String(raw), karat);
}
} else if (instrument === 'CASH') {
const perGram = present(data.price_per_gram) ? data.price_per_gram : existing.pricePerGramRialAtEntry;
const price = present(data.price) ? data.price : existing.priceRial;
if (differs(data.price_per_gram, existing.pricePerGramRialAtEntry)) {
patch.pricePerGramRialAtEntry = positiveFigure(data.price_per_gram, 'مظنه', MAX_RIAL);
}
if (
(differs(data.price_per_gram, existing.pricePerGramRialAtEntry) || differs(data.price, existing.priceRial)) &&
present(price) &&
present(perGram)
) {
return repaymentToGrams('CASH', String(price), undefined, String(perGram));
}
}
return present(data.value) ? decimalOf(data.value, 'مقدار') : null;
}
/**
* Legacy `POST cash/confirem/{id}` (legacy's own spelling) — a TOGGLE: the grid
* shows a tick on an unconfirmed row and a cross on a confirmed one, both
@@ -314,32 +428,11 @@ export class AdminDocumentsService {
const e = await this.prisma.ledgerEntry.findUnique({ where: { id } });
if (!e) throw new NotFoundException('سند یافت نشد');
if (!e.confirmed) {
await this.ledger.confirmEntry(id, adminUserId);
this.notifyDocumentConfirm(e.customerId, true);
return this.findOne(id);
}
// Un-confirm: undo this entry's effect on the cached balance, then clear the
// flag. A repayment is a CREDIT against gold debt, so reversing it adds the
// amount back.
await this.prisma.$transaction(async (tx) => {
const delta =
e.direction === LedgerDirection.CREDIT ? e.amount : e.amount.negated();
const field =
e.currency === LedgerCurrency.GOLD_GRAM ? 'goldDebtGrams' : 'fiatDebtRial';
await tx.customer.update({
where: { id: e.customerId },
data: { [field]: { increment: delta } },
});
await tx.ledgerEntry.update({
where: { id },
data: { confirmed: false, confirmedAt: null },
});
});
this.notifyDocumentConfirm(e.customerId, false);
// Confirm or un-confirm (a repayment is a CREDIT, so undoing it adds the amount
// back to the debt). The ledger flips the flag and moves the cached balance in
// one atomic step, so a double-click or two admins at once cannot apply it twice.
const { changed } = await this.ledger.setConfirmed(id, !e.confirmed, adminUserId);
if (changed) this.notifyDocumentConfirm(e.customerId, !e.confirmed);
return this.findOne(id);
}
@@ -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;
},
@@ -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,215 @@
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 گرم', image: null, product: { title: 'النگو', images: [] } },
},
{
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 گرم', image: null, product: { title: 'گوشواره', images: [] } },
},
],
};
const service = new AdminOrdersService(
{ order: { findUnique: jest.fn(async () => order) } } as never,
{} as never,
{} as never,
{ pendingForOrder: async () => null, shapeRevision: () => null } 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, { pendingForOrder: async () => null, shapeRevision: () => null } 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, { pendingForOrder: async () => null, shapeRevision: () => null } 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;
});
@@ -7,6 +7,7 @@ import { AccountingHooksService } from '../accounting-sync/accounting-hooks.serv
import { OrdersService } from '../orders/orders.service';
import { OrderRevisionService } from '../orders/order-revision.service';
import { LedgerService } from '../accounting/ledger.service';
import { WageService } from '../pricing/wage.service';
import type { UpdateOrderStatusDto } from './update-status.dto';
/**
@@ -68,6 +69,7 @@ export class AdminOrdersService {
private readonly revisions: OrderRevisionService,
private readonly accounting: AccountingHooksService,
private readonly ledger: LedgerService,
private readonly wage: WageService,
) {}
/** Fields the XTable filter bar and sorter may reference. */
@@ -78,6 +80,7 @@ export class AdminOrdersService {
'createdAt',
'updatedAt',
'totalGoldWeightGrams',
'wageWeightGrams',
'flatRialCharge',
'isSuspendedOverCreditLimit',
'customer.fullName',
@@ -85,6 +88,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.
*
@@ -94,7 +116,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' },
@@ -149,12 +171,20 @@ export class AdminOrdersService {
totalGoldWeightGrams: o.totalGoldWeightGrams,
wageWeightGrams: o.wageWeightGrams,
flatRialCharge: o.flatRialCharge,
// The wholesale / sourcing grids bind the reference's order columns.
input: o.totalGoldWeightGrams,
w: o.totalGoldWeightGrams,
wage: o.wageWeightGrams,
rial_input: o.flatRialCharge,
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,
@@ -169,10 +199,14 @@ export class AdminOrdersService {
variant_title: it.productVariant.title,
qty: it.quantity,
grams: it.computedWeightGrams,
total_grams: it.computedWeightGrams.mul(it.quantity),
rash: it.rashWeightGramsSnapshot,
percent: it.wagePercentSnapshot.sub(it.discountPercentSnapshot),
wagePercent: it.wagePercentSnapshot,
discountPercent: it.discountPercentSnapshot,
flatRialCharge: it.flatRialCharge,
rial_pay: it.flatRialCharge,
total_rial: it.flatRialCharge.mul(it.quantity),
// Same preference as the customer app: variant photo, else product cover.
image: it.productVariant.image?.url ?? it.productVariant.product.images[0]?.url ?? null,
note: '',
@@ -206,7 +240,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' },
@@ -389,18 +423,26 @@ export class AdminOrdersService {
throw new BadRequestException('سبد سفارش خالی است');
}
// A line with no typed percent is priced at what the app would charge this
// customer (their percent + the variant's - the largest promotion); a typed one wins.
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),
flatRialCharge: Number(it.rial_input ?? 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.
...(it.rial_input != null ? { flatRialCharge: Number(it.rial_input) } : {}),
};
}),
);
@@ -0,0 +1,68 @@
import { BadRequestException } from '@nestjs/common';
import { PreOrderStatus } from '@prisma/client';
import { AdminPreOrdersService } from './admin-preorders.service';
/** The quantity of a made-to-order request (legacy `sum`) is stored and shown, not a hard-coded 1. */
describe('AdminPreOrdersService — quantity', () => {
const row = {
id: 'p1',
status: PreOrderStatus.PLACED,
colorName: 'طلایی',
sizeSpec: '18',
minaColor: null,
stoneColor: null,
quantity: 7,
description: null,
designImageUrl: null,
estimatedWeightGrams: null,
estimatedPriceRial: null,
convertedOrderId: null,
createdAt: new Date(),
updatedAt: new Date(),
city: null,
workshop: null,
customer: { id: 'c1', firstName: 'علی', lastName: 'رضایی', fullName: 'علی رضایی', mobile: '0912' },
};
function build() {
const updates: any[] = [];
const prisma = {
preOrder: {
findUnique: jest.fn(async () => row),
update: jest.fn(async ({ data }: any) => (updates.push(data), { ...row, ...data })),
},
};
return { service: new AdminPreOrdersService(prisma as never, {} as never), updates };
}
it("shows the request's own quantity as `sum`", async () => {
const { service } = build();
expect(((await service.findOne('p1')) as any).sum).toBe(7);
});
it('lets the panel correct the quantity', async () => {
const { service, updates } = build();
await service.update('p1', { sum: 12 });
expect(updates[0].quantity).toBe(12);
});
it('refuses a quantity that is not a whole number of at least one', async () => {
const { service, updates } = build();
for (const sum of [0, -2, 1.5, 'abc']) {
await expect(service.update('p1', { sum })).rejects.toThrow(BadRequestException);
}
expect(updates).toHaveLength(0);
});
});
describe('AdminPreOrdersService.list — sorting the «تعداد» column', () => {
const { TableService } = jest.requireActual('../admin-table/table.service') as typeof import('../admin-table/table.service');
it('sorts on the stored quantity when the grid asks for its legacy key `sum`', async () => {
const findMany = jest.fn(async () => []);
const prisma = { preOrder: { findMany, count: jest.fn(async () => 0) } };
const service = new AdminPreOrdersService(prisma as never, new TableService() as never);
await service.list({ page: 1, limit: 10, order: '-sum' } as never);
expect((findMany.mock.calls[0] as any)[0].orderBy).toEqual({ quantity: 'desc' });
});
});
@@ -37,6 +37,7 @@ export class AdminPreOrdersService {
'sizeSpec',
'stoneColor',
'minaColor',
'quantity',
'estimatedWeightGrams',
'estimatedPriceRial',
'customer.fullName',
@@ -47,7 +48,10 @@ export class AdminPreOrdersService {
async list(query: TableQueryDto) {
this.table.assertNotExport(query);
const { where, orderBy, skip, take, page, limit } = this.table.build(query, {
// The «تعداد» column is keyed `sum` (legacy name); the stored field is `quantity`.
const sorted = query.order && /^[+-]?sum$/.test(query.order) ? { ...query, order: query.order.replace('sum', 'quantity') } : query;
const { where, orderBy, skip, take, page, limit } = this.table.build(sorted, {
searchable: ['customer.fullName', 'customer.mobile'],
allowed: AdminPreOrdersService.ALLOWED,
defaultOrderBy: { createdAt: 'desc' },
@@ -79,8 +83,8 @@ export class AdminPreOrdersService {
size: p.sizeSpec,
color_mina: p.minaColor,
color_stone: p.stoneColor,
// legacy 'sum' (quantity); a PreOrder models a single design request
sum: 1,
// legacy 'sum' — the pieces asked for
sum: p.quantity,
description: p.description,
designImageUrl: p.designImageUrl,
estimatedWeightGrams: p.estimatedWeightGrams,
@@ -123,6 +127,11 @@ export class AdminPreOrdersService {
if (data.color_mina !== undefined) patch.minaColor = data.color_mina as string;
if (data.color_stone !== undefined) patch.stoneColor = data.color_stone as string;
if (data.description !== undefined) patch.description = data.description as string;
if (data.sum !== undefined) {
const sum = Number(data.sum);
if (!Number.isInteger(sum) || sum < 1) throw new BadRequestException('تعداد باید عدد صحیح و حداقل ۱ باشد');
patch.quantity = sum;
}
if (data.estimatedWeightGrams !== undefined) {
patch.estimatedWeightGrams = new Prisma.Decimal(data.estimatedWeightGrams as string);
}
@@ -0,0 +1,26 @@
import { BadRequestException } from '@nestjs/common';
import { TableService } from './table.service';
describe('TableService.build — sort and filter fields', () => {
const table = new TableService();
const allowed = ['createdAt', 'customer.fullName'];
const opts = { allowed, defaultOrderBy: { createdAt: 'desc' as const } };
it('sorts by an allowed field, nested paths included', () => {
expect(table.build({ order: '+createdAt' } as never, opts).orderBy).toEqual({ createdAt: 'asc' });
expect(table.build({ order: '-customer.fullName' } as never, opts).orderBy).toEqual({ customer: { fullName: 'desc' } });
});
it('a sort field that maps to nothing keeps the default order instead of failing the request', () => {
expect(table.build({ order: '-input' } as never, opts).orderBy).toEqual({ createdAt: 'desc' });
expect(table.build({ order: '+passwordHash' } as never, opts).orderBy).toEqual({ createdAt: 'desc' });
});
it('with no allow-list the field is used as given (callers that never listed one)', () => {
expect(table.build({ order: '+anything' } as never, { defaultOrderBy: { createdAt: 'desc' as const } }).orderBy).toEqual({ anything: 'asc' });
});
it('a FILTER on a field that is not allowed is still refused', () => {
expect(() => table.build({ filters: [{ field: 'passwordHash', op: '=', value: 'x' }] } as never, opts)).toThrow(BadRequestException);
});
});
@@ -117,8 +117,11 @@ export class TableService {
if (query.order) {
const dir = query.order.startsWith('-') ? 'desc' : 'asc';
const field = query.order.replace(/^[+-]/, '');
if (field) {
this.assertAllowed(field, opts.allowed);
// A sort field the grid cannot map to a stored field (a column key that is
// only the reference's name for something) keeps the default order. Failing
// the whole request over it left the grid with an error for a header click;
// unlike a FILTER, an order cannot widen what the caller may see.
if (field && (!opts.allowed || opts.allowed.includes(field))) {
orderBy = this.nest(field, dir) as Record<string, 'asc' | 'desc'>;
}
}
@@ -0,0 +1,31 @@
import { Body, Controller, Get, HttpCode, Post } from '@nestjs/common';
import { CustomerPreOrdersService, type CreatePreOrderInput } from './customer-preorders.service';
import { CurrentCustomer, CustomerAuth } from '../customer-auth/customer.guards';
import type { AuthenticatedCustomer } from '../customer-auth/customer-jwt.strategy';
/**
* «سفارش ساخت» in the apps.
*
* POST customer/me/pre-orders -> legacy POST /create-custom-order
* GET customer/me/pre-orders -> legacy GET /get-custom-orders
*
* The customer is the token's (`me.id` is the customer a sales expert is acting
* as, when they are); no id is accepted from the client.
*/
@Controller('customer/me/pre-orders')
export class CustomerPreOrdersController {
constructor(private readonly preOrders: CustomerPreOrdersService) {}
@CustomerAuth()
@Post()
@HttpCode(201)
create(@Body() body: CreatePreOrderInput, @CurrentCustomer() me: AuthenticatedCustomer) {
return this.preOrders.create(me.id, body ?? {});
}
@CustomerAuth()
@Get()
list(@CurrentCustomer() me: AuthenticatedCustomer) {
return this.preOrders.listMine(me.id);
}
}
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../prisma/prisma.module';
import { CustomerPreOrdersController } from './customer-preorders.controller';
import { CustomerPreOrdersService } from './customer-preorders.service';
/** «سفارش ساخت» — the customer's side of made-to-order requests. */
@Module({
imports: [PrismaModule],
controllers: [CustomerPreOrdersController],
providers: [CustomerPreOrdersService],
})
export class CustomerPreOrdersModule {}
@@ -0,0 +1,87 @@
import { BadRequestException } from '@nestjs/common';
import { PreOrderStatus } from '@prisma/client';
import { CustomerPreOrdersService, PREORDER_DESIGN_PURPOSE } from './customer-preorders.service';
/** «سفارش ساخت» — a customer's made-to-order requests. */
describe('CustomerPreOrdersService', () => {
function build(files: { id: string; purpose: string | null; url: string }[] = []) {
const created: any[] = [];
const stored: any[] = [];
const prisma: any = {
uploadedFile: {
findFirst: jest.fn(async ({ where }: any) => files.find((f) => f.id === where.id && f.purpose === where.purpose) ?? null),
},
preOrder: {
create: jest.fn(async ({ data }: any) => {
created.push(data);
const row = { id: 'p1', createdAt: new Date('2026-10-29'), convertedOrderId: null, ...data };
stored.push(row);
return row;
}),
findMany: jest.fn(async ({ where }: any) => stored.filter((r) => r.customerId === where.customerId)),
},
};
return { service: new CustomerPreOrdersService(prisma), created, prisma, stored };
}
const form = { color: 'طلایی', size: '18', color_mina: 'آبی', color_stone: 'قرمز', sum: 3, description: 'مطابق عکس' };
it('places a request for the signed-in customer, in the first status, with the quantity', async () => {
const t = build();
const row = await t.service.create('c1', form);
expect(t.created[0]).toMatchObject({ customerId: 'c1', status: PreOrderStatus.PLACED, colorName: 'طلایی', sizeSpec: '18', minaColor: 'آبی', stoneColor: 'قرمز', quantity: 3, description: 'مطابق عکس' });
expect(row).toMatchObject({ sts_str_fa: 'ثبت سفارش', step: 1, sum: 3, color: 'طلایی' });
});
it('keeps the uploaded design photo by its stored URL', async () => {
const t = build([{ id: 'f1', purpose: PREORDER_DESIGN_PURPOSE, url: '/uploads/abc.jpg' }]);
const row = await t.service.create('c1', { ...form, file_id: 'f1' });
expect(t.created[0].designImageUrl).toBe('/uploads/abc.jpg');
expect(row.image).toBe('/uploads/abc.jpg');
});
it('refuses a file id that is not a design photo (another purpose, or no such file)', async () => {
const t = build([{ id: 'f2', purpose: 'customer-document', url: '/uploads/id-card.jpg' }]);
await expect(t.service.create('c1', { ...form, file_id: 'f2' })).rejects.toThrow('تصویر');
await expect(t.service.create('c1', { ...form, file_id: 'nope' })).rejects.toThrow(BadRequestException);
expect(t.created).toHaveLength(0);
});
it('needs a colour and a whole number of pieces', async () => {
const t = build();
await expect(t.service.create('c1', { ...form, color: ' ' })).rejects.toThrow('رنگ محصول');
for (const sum of [0, -1, 1.5, 'abc', undefined, 100001]) {
await expect(t.service.create('c1', { ...form, sum })).rejects.toThrow('تعداد');
}
expect(t.created).toHaveLength(0);
});
it('bounds the free text', async () => {
const t = build();
await expect(t.service.create('c1', { ...form, description: 'x'.repeat(1001) })).rejects.toThrow('توضیحات');
await expect(t.service.create('c1', { ...form, size: 'x'.repeat(101) })).rejects.toThrow('سایز');
});
it('takes the customer from the argument only: the body cannot name one', async () => {
const t = build();
await t.service.create('c1', { ...form, customerId: 'victim', customer_id: 'victim' } as never);
expect(t.created[0].customerId).toBe('c1');
});
it("lists only the customer's own requests", async () => {
const t = build();
await t.service.create('c1', form);
await t.service.create('c2', form);
const { rows } = await t.service.listMine('c1');
expect(rows).toHaveLength(1);
expect(t.prisma.preOrder.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: { customerId: 'c1' } }));
});
it('puts each status on the five-step tracker the reference drew; cancelled is off it', async () => {
const t = build();
const svc = t.service as any;
const step = (status: PreOrderStatus) => svc.toRow({ id: 'x', status, colorName: 'a', sizeSpec: null, minaColor: null, stoneColor: null, quantity: 1, description: null, designImageUrl: null, createdAt: new Date() }).step;
expect([PreOrderStatus.PLACED, PreOrderStatus.ADMIN_APPROVED, PreOrderStatus.WORKSHOP_APPROVED, PreOrderStatus.READY_TO_SHIP, PreOrderStatus.DELIVERED].map(step)).toEqual([1, 2, 3, 4, 5]);
expect(step(PreOrderStatus.CANCELLED)).toBe(0);
});
});
@@ -0,0 +1,129 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { PreOrderStatus, Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
/** The `purpose` the app tags a design photo with when it uploads it. */
export const PREORDER_DESIGN_PURPOSE = 'preorder-design';
/**
* Where a request stands on the tracker the reference drew on «وضعیت سفارش»
* (makeOrderStatus.vue getOrderStatus): ثبت سفارش → تایید ادمین → تایید کارگاه →
* ارسال → تحویل شده. 0 = cancelled (off the tracker).
*/
const STEP: Record<PreOrderStatus, number> = {
PLACED: 1,
ADMIN_APPROVED: 2,
WORKSHOP_APPROVED: 3,
READY_TO_SHIP: 4,
DELIVERED: 5,
CANCELLED: 0,
};
/** The reference's labels (CustomOrder::sts_str_fa). */
const LABEL: Record<PreOrderStatus, string> = {
PLACED: 'ثبت سفارش',
ADMIN_APPROVED: 'تایید ادمین',
WORKSHOP_APPROVED: 'تایید کارگاه',
READY_TO_SHIP: 'ساخته شده آماده ارسال',
DELIVERED: 'تحویل شده',
CANCELLED: 'لغو شده',
};
export interface CreatePreOrderInput {
color?: unknown;
size?: unknown;
color_mina?: unknown;
color_stone?: unknown;
sum?: unknown;
file_id?: unknown;
description?: unknown;
}
/**
* «سفارش ساخت» — the customer's side of made-to-order requests (reference
* CustomOrderController::creatCustomOrder / getCustomOrders, `create-custom-order`
* and `get-custom-orders`).
*
* The request is the customer's own: the subject always comes from the token,
* never from the body, and a list only ever holds that customer's requests.
* What happens after it is placed — approval, the workshop, pricing, turning it
* into a real order — stays in the admin screens (admin-preorders); the reference
* app's own «تایید سفارش / ارسال سفارش» buttons for workshop users both posted
* the same status and let anyone move anyone's request, so they are not carried over.
*/
@Injectable()
export class CustomerPreOrdersService {
constructor(private readonly prisma: PrismaService) {}
private text(value: unknown, label: string, max: number, required = false): string | null {
const s = value === undefined || value === null ? '' : String(value).trim();
if (!s) {
if (required) throw new BadRequestException(`${label} الزامی است`);
return null;
}
if (s.length > max) throw new BadRequestException(`${label} نباید بیشتر از ${max} نویسه باشد`);
return s;
}
async create(customerId: string, input: CreatePreOrderInput) {
const color = this.text(input.color, 'رنگ محصول', 100, true);
const sum = Number(input.sum);
if (!Number.isInteger(sum) || sum < 1 || sum > 100000) {
throw new BadRequestException('تعداد باید عدد صحیح بین ۱ و ۱۰۰۰۰۰ باشد');
}
// The photo is one the app uploaded for this purpose; its stored URL is what the
// request keeps. An id that is not such a file is refused rather than ignored.
let designImageUrl: string | null = null;
if (input.file_id !== undefined && input.file_id !== null && String(input.file_id) !== '') {
const file = await this.prisma.uploadedFile.findFirst({
where: { id: String(input.file_id), purpose: PREORDER_DESIGN_PURPOSE },
select: { url: true },
});
if (!file) throw new BadRequestException('تصویر سفارش معتبر نیست؛ دوباره بارگذاری کنید');
designImageUrl = file.url;
}
const created = await this.prisma.preOrder.create({
data: {
customerId,
status: PreOrderStatus.PLACED,
colorName: color,
sizeSpec: this.text(input.size, 'سایز محصول', 100),
minaColor: this.text(input.color_mina, 'رنگ مینا', 100),
stoneColor: this.text(input.color_stone, 'رنگ سنگ', 100),
quantity: sum,
description: this.text(input.description, 'توضیحات', 1000),
designImageUrl,
},
});
return this.toRow(created);
}
/** Newest first, the customer's own only. */
async listMine(customerId: string) {
const rows = await this.prisma.preOrder.findMany({
where: { customerId },
orderBy: { createdAt: 'desc' },
take: 200,
});
return { rows: rows.map((r) => this.toRow(r)) };
}
private toRow(p: Prisma.PreOrderGetPayload<object>) {
return {
id: p.id,
status: p.status,
sts_str_fa: LABEL[p.status],
step: STEP[p.status],
color: p.colorName,
size: p.sizeSpec,
color_mina: p.minaColor,
color_stone: p.stoneColor,
sum: p.quantity,
description: p.description,
image: p.designImageUrl,
createdAt: p.createdAt,
};
}
}
@@ -1,6 +1,6 @@
import { Type } from 'class-transformer';
import { OrderChannel } from '@prisma/client';
import { ArrayMinSize, IsArray, IsEnum, IsInt, IsNumber, IsOptional, IsPositive, IsString, Min, ValidateNested } from 'class-validator';
import { ArrayMinSize, IsArray, IsEnum, IsInt, IsNumber, IsOptional, IsPositive, IsString, Max, Min, ValidateNested } from 'class-validator';
export class CreateOrderItemInputDto {
@IsString()
@@ -8,6 +8,8 @@ export class CreateOrderItemInputDto {
@IsInt()
@Min(1)
/** Same ceiling as the panel's line edit; beyond it the weights overflow their columns (a 500). */
@Max(100000)
quantity!: number;
/** Making-charge percentage negotiated for this order (may differ from the variant default). */
@@ -0,0 +1,280 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { LedgerCurrency, LedgerDirection, OrderStatus, Prisma } from '@prisma/client';
import { LedgerService } from '../accounting/ledger.service';
import { OrdersService } from './orders.service';
/**
* Correcting a placed order — the panel's «ویرایش ردیف» / «حذف ردیف»
* (reference order-item/edit, major/order_item/{id}) and the sales expert's
* weight entry + «ثبت نهایی» (reference PUT order/child/{id}).
*
* The real OrdersService and LedgerService run against an in-memory database
* that rounds the way the columns do (unit weight 4 places, percent 3), so
* what is asserted is what would be stored — and the customer's debt is read
* back from the ledger after every correction.
*/
const D = (v: Prisma.Decimal.Value) => new Prisma.Decimal(v);
type Row = Record<string, any>;
function world(opts: { status: OrderStatus; salesAgentId?: string | null; blocked?: string | null }) {
const order: Row = {
id: 'o1',
orderNumber: '100',
customerId: 'c1',
status: opts.status,
salesAgentId: opts.salesAgentId ?? 'agent1',
deliveredAt: null,
totalGoldWeightGrams: D(0),
wageWeightGrams: D(0),
flatRialCharge: D(0),
};
// Two lines placed at net 8% (10% charge − 2% promotion) and 12%.
const items: Row[] = [
{ id: 'i1', orderId: 'o1', quantity: 3, rashWeightGramsSnapshot: D('5.7856'), wagePercentSnapshot: D(10), discountPercentSnapshot: D(2), computedWeightGrams: D('6.2484'), flatRialCharge: D(150000) },
{ id: 'i2', orderId: 'o1', quantity: 10, rashWeightGramsSnapshot: D('2.5'), wagePercentSnapshot: D(12), discountPercentSnapshot: D(0), computedWeightGrams: D('2.8'), flatRialCharge: D(80000) },
];
const sum = (f: (i: Row) => Prisma.Decimal) => items.reduce((s, i) => s.add(f(i)), D(0));
order.totalGoldWeightGrams = sum((i) => i.computedWeightGrams.mul(i.quantity)); // 18.7452 + 28
order.wageWeightGrams = order.totalGoldWeightGrams.sub(sum((i) => i.rashWeightGramsSnapshot.mul(i.quantity)));
order.flatRialCharge = sum((i) => i.flatRialCharge.mul(i.quantity));
// What the columns store.
const round = (data: Row) => {
const out = { ...data };
for (const k of ['computedWeightGrams', 'rashWeightGramsSnapshot', 'totalGoldWeightGrams', 'wageWeightGrams']) {
if (out[k] !== undefined) out[k] = D(out[k]).toDecimalPlaces(4);
}
if (out.wagePercentSnapshot !== undefined) out.wagePercentSnapshot = D(out.wagePercentSnapshot).toDecimalPlaces(3);
return out;
};
const entries: Row[] = [];
const customer = { goldDebtGrams: D(0), fiatDebtRial: D(0) };
const events: Row[] = [];
const tx: Record<string, any> = {
order: {
findUnique: jest.fn(async ({ include }: { include?: { items?: boolean } }) => (include?.items ? { ...order, items: [...items] } : { ...order })),
findUniqueOrThrow: jest.fn(async () => ({ ...order })),
update: jest.fn(async ({ data }: { data: Row }) => {
Object.assign(order, round(data));
return { ...order };
}),
},
orderItem: {
findMany: jest.fn(async () => items.map((i) => ({ ...i }))),
findFirst: jest.fn(async ({ where }: { where: { id: string } }) => items.find((i) => i.id === where.id) ?? null),
update: jest.fn(async ({ where, data }: { where: { id: string }; data: Row }) => {
Object.assign(items.find((i) => i.id === where.id)!, round(data));
}),
delete: jest.fn(async ({ where }: { where: { id: string } }) => {
items.splice(items.findIndex((i) => i.id === where.id), 1);
}),
},
orderStatusEvent: { create: jest.fn(async ({ data }: { data: Row }) => void events.push(data)) },
ledgerEntry: {
create: jest.fn(async ({ data }: { data: Row }) => {
entries.push(data);
return data;
}),
groupBy: jest.fn(async ({ where }: { where: Row }) => {
const out: Row[] = [];
for (const d of [LedgerDirection.DEBIT, LedgerDirection.CREDIT]) {
const hit = entries.filter((e) => e.currency === where.currency && e.direction === d && e.confirmed && e.sourceType === 'ORDER_STOCK');
if (hit.length) out.push({ direction: d, _sum: { amount: hit.reduce((s, e) => s.add(e.amount), D(0)) } });
}
return out;
}),
},
customer: {
update: jest.fn(async ({ data }: { data: Row }) => {
if (data.goldDebtGrams) customer.goldDebtGrams = customer.goldDebtGrams.add(data.goldDebtGrams.increment);
if (data.fiatDebtRial) customer.fiatDebtRial = customer.fiatDebtRial.add(data.fiatDebtRial.increment);
}),
},
};
const prisma = { $transaction: jest.fn(async (fn: (t: unknown) => unknown) => fn(tx)) };
const accounting = {
orderEdited: jest.fn(async () => undefined),
orderDocumentBlocksEdit: jest.fn(async () => opts.blocked ?? null),
orderStatusReached: jest.fn(async () => undefined),
orderCancelled: jest.fn(async () => undefined),
};
const ledger = new LedgerService(prisma as never);
const service = new OrdersService(prisma as never, {} as never, ledger, accounting as never, {} as never, { send: jest.fn() } as never);
/** The debt a delivered order carries right now (what updateRemain would give). */
const owed = () => ({ gold: customer.goldDebtGrams, rial: customer.fiatDebtRial });
return { service, order, items, entries, accounting, owed, events, ledger, tx };
}
/** The reference's per-unit weight: rash + rash × percent / 100 (percent = net). */
const refUnit = (rash: number, netPercent: number) => rash + rash * (netPercent / 100);
const near = (actual: Prisma.Decimal.Value, expected: number, eps = 1e-3) => expect(Math.abs(Number(actual) - expected)).toBeLessThan(eps);
describe('editing one line of a placed order (order-item/edit)', () => {
/** A delivered order, with its debt posted the way delivery posts it. */
async function delivered() {
const w = world({ status: OrderStatus.DELIVERED_TO_AGENT });
await w.ledger.syncOrderDebt('o1', w.tx as never);
return w;
}
it('starts from a consistent state: debt equals the order', async () => {
const w = await delivered();
near(w.owed().gold, 18.7452 + 28);
expect(w.owed().rial.toString()).toBe('1250000');
});
it("recomputes the line, the order's totals and the debt after a quantity and weight correction", async () => {
const w = await delivered();
await w.service.editLine('o1', 'i1', { rashWeightGrams: '6', quantity: 5 });
const unit = refUnit(6, 8); // net 10 − 2 stays
near(w.items[0].computedWeightGrams, unit, 1e-4);
expect(w.items[0].quantity).toBe(5);
const total = unit * 5 + 2.8 * 10;
near(w.order.totalGoldWeightGrams, total);
near(w.order.wageWeightGrams, total - (6 * 5 + 2.5 * 10));
// The rial making charge follows the quantity too (the reference left it stale).
expect(w.order.flatRialCharge.toString()).toBe(String(150000 * 5 + 80000 * 10));
near(w.owed().gold, total);
expect(w.owed().rial.toString()).toBe(String(150000 * 5 + 80000 * 10));
});
it('posts only the difference, as a correction on the same order', async () => {
const w = await delivered();
const before = w.entries.length;
await w.service.editLine('o1', 'i1', { quantity: 4 });
expect(w.entries.length).toBeGreaterThan(before);
const fresh = w.entries.slice(before);
expect(fresh.every((e) => e.orderId === 'o1' && e.sourceType === 'ORDER_STOCK' && e.confirmed)).toBe(true);
expect(fresh.find((e) => e.currency === LedgerCurrency.GOLD_GRAM)!.direction).toBe(LedgerDirection.DEBIT);
});
it('a corrected net percent keeps the line\'s own promotion: charge = net + discount', async () => {
const w = await delivered();
await w.service.editLine('o1', 'i1', { netPercent: 9 });
expect(w.items[0].wagePercentSnapshot.toString()).toBe('11'); // 9 net + 2 discount
near(w.items[0].computedWeightGrams, refUnit(5.7856, 9), 1e-4);
});
it('an order not yet delivered owes nothing, before or after the correction', async () => {
const w = world({ status: OrderStatus.PLACED });
await w.service.editLine('o1', 'i2', { quantity: 20 });
expect(w.owed().gold.toString()).toBe('0');
expect(w.entries).toHaveLength(0);
near(w.order.totalGoldWeightGrams, 6.2484 * 3 + 2.8 * 20);
});
it('tells the accounting document the order changed', async () => {
const w = await delivered();
await w.service.editLine('o1', 'i2', { quantity: 11 });
expect(w.accounting.orderEdited).toHaveBeenCalledWith(expect.anything(), 'o1');
});
it('refuses nonsense and changes nothing', async () => {
const w = await delivered();
const total = w.order.totalGoldWeightGrams.toString();
await expect(w.service.editLine('o1', 'i1', { rashWeightGrams: 0 })).rejects.toThrow(BadRequestException);
await expect(w.service.editLine('o1', 'i1', { rashWeightGrams: -3 })).rejects.toThrow(BadRequestException);
await expect(w.service.editLine('o1', 'i1', { quantity: 1.5 })).rejects.toThrow('صحیح');
await expect(w.service.editLine('o1', 'i1', { quantity: 0 })).rejects.toThrow(BadRequestException);
await expect(w.service.editLine('o1', 'i1', { netPercent: 'abc' })).rejects.toThrow(BadRequestException);
await expect(w.service.editLine('o1', 'nope', { quantity: 2 })).rejects.toThrow(NotFoundException);
expect(w.order.totalGoldWeightGrams.toString()).toBe(total);
});
it('refuses a cancelled order', async () => {
const w = world({ status: OrderStatus.CANCELLED });
await expect(w.service.editLine('o1', 'i1', { quantity: 2 })).rejects.toThrow('لغو');
});
it('refuses while the accounting document is being sent or waits for review', async () => {
const w = world({ status: OrderStatus.DELIVERED_TO_AGENT, blocked: 'سند حسابداری این سفارش در حال ارسال است' });
await expect(w.service.editLine('o1', 'i1', { quantity: 2 })).rejects.toThrow('سند حسابداری');
expect(w.items[0].quantity).toBe(3);
});
});
describe('removing a line of a placed order (major/order_item/{id})', () => {
it('drops the line from the order and from the debt', async () => {
const w = world({ status: OrderStatus.DELIVERED_TO_CUSTOMER });
await w.ledger.syncOrderDebt('o1', w.tx as never);
await w.service.removeLine('o1', 'i1');
expect(w.items.map((i) => i.id)).toEqual(['i2']);
near(w.order.totalGoldWeightGrams, 28);
expect(w.order.flatRialCharge.toString()).toBe('800000');
near(w.owed().gold, 28);
expect(w.owed().rial.toString()).toBe('800000');
});
it('will not remove the last line (an order of nothing owes nothing and shows nothing)', async () => {
const w = world({ status: OrderStatus.PLACED });
await w.service.removeLine('o1', 'i1');
await expect(w.service.removeLine('o1', 'i2')).rejects.toThrow('آخرین ردیف');
expect(w.items).toHaveLength(1);
});
it('an unknown line is not found', async () => {
const w = world({ status: OrderStatus.PLACED });
await expect(w.service.removeLine('o1', 'zzz')).rejects.toThrow(NotFoundException);
});
});
describe("the sales expert's weights and «ثبت نهایی» (PUT order/child/{id})", () => {
async function inHand() {
const w = world({ status: OrderStatus.DELIVERED_TO_AGENT, salesAgentId: 'agent1' });
await w.ledger.syncOrderDebt('o1', w.tx as never);
return w;
}
it('keeps the making charge: the new raw weight × (1 + net percent), not the bare weight', async () => {
const w = await inHand();
const res = await w.service.expertAdjust('o1', 'agent1', [{ id: 'i1', rashWeightGrams: '5.9' }, { id: 'i2', rashWeightGrams: '2.4' }], false);
near(w.items[0].computedWeightGrams, refUnit(5.9, 8), 1e-4); // net 8, as placed
near(w.items[1].computedWeightGrams, refUnit(2.4, 12), 1e-4);
const total = refUnit(5.9, 8) * 3 + refUnit(2.4, 12) * 10;
near(w.order.totalGoldWeightGrams, total);
near(w.owed().gold, total);
expect(res).toMatchObject({ id: '100', status: OrderStatus.DELIVERED_TO_AGENT });
});
it('submitting as final hands the order to the customer and leaves the debt as corrected', async () => {
const w = await inHand();
const res = await w.service.expertAdjust('o1', 'agent1', [{ id: 'i1', rashWeightGrams: '5.9' }], true);
expect(w.order.status).toBe(OrderStatus.DELIVERED_TO_CUSTOMER);
expect(w.order.deliveredAt).toBeInstanceOf(Date);
expect(res.status).toBe(OrderStatus.DELIVERED_TO_CUSTOMER);
near(w.owed().gold, refUnit(5.9, 8) * 3 + 28);
expect(w.accounting.orderStatusReached).toHaveBeenCalledWith(expect.anything(), 'o1', OrderStatus.DELIVERED_TO_CUSTOMER);
});
it("is only for the expert's own order — anyone else sees it as not found", async () => {
const w = await inHand();
await expect(w.service.expertAdjust('o1', 'someone-else', [{ id: 'i1', rashWeightGrams: '1' }], false)).rejects.toThrow(NotFoundException);
expect(w.items[0].rashWeightGramsSnapshot.toString()).toBe('5.7856');
});
it('only while the order is in their hands', async () => {
const w = world({ status: OrderStatus.PREPARING, salesAgentId: 'agent1' });
await expect(w.service.expertAdjust('o1', 'agent1', [{ id: 'i1', rashWeightGrams: '1' }], false)).rejects.toThrow('تحویل مسئول فروش');
const done = world({ status: OrderStatus.DELIVERED_TO_CUSTOMER, salesAgentId: 'agent1' });
await expect(done.service.expertAdjust('o1', 'agent1', [{ id: 'i1', rashWeightGrams: '1' }], true)).rejects.toThrow('تحویل مسئول فروش');
});
it('refuses a missing, zero or negative weight and a line of another order', async () => {
const w = await inHand();
await expect(w.service.expertAdjust('o1', 'agent1', [{ id: 'i1', rashWeightGrams: 0 }], false)).rejects.toThrow(BadRequestException);
await expect(w.service.expertAdjust('o1', 'agent1', [{ id: 'i1', rashWeightGrams: -2 }], false)).rejects.toThrow(BadRequestException);
await expect(w.service.expertAdjust('o1', 'agent1', [{ id: 'i1', rashWeightGrams: undefined }], false)).rejects.toThrow(BadRequestException);
await expect(w.service.expertAdjust('o1', 'agent1', [{ id: 'other-order-line', rashWeightGrams: 3 }], false)).rejects.toThrow(BadRequestException);
expect(w.items[0].rashWeightGramsSnapshot.toString()).toBe('5.7856');
});
it('refuses while the accounting document is being sent or waits for review', async () => {
const w = world({ status: OrderStatus.DELIVERED_TO_AGENT, salesAgentId: 'agent1', blocked: 'سند حسابداری در حال ارسال' });
await expect(w.service.expertAdjust('o1', 'agent1', [{ id: 'i1', rashWeightGrams: '5' }], false)).rejects.toThrow('سند حسابداری');
});
});
+210 -27
View File
@@ -1,4 +1,4 @@
import { ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { OrderChannel, OrderStatus, Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { CatalogService } from '../catalog/catalog.service';
@@ -324,39 +324,222 @@ export class OrdersService {
}
async updateStatus(orderId: string, newStatus: OrderStatus, actorAdminUserId?: string, note?: string) {
return this.prisma.$transaction(async (tx) => {
const order = await tx.order.findUnique({ where: { id: orderId } });
if (!order) throw new NotFoundException(`Order ${orderId} not found`);
return this.prisma.$transaction((tx) => this.moveStatus(tx, orderId, newStatus, actorAdminUserId, note));
}
const updated = await tx.order.update({
where: { id: orderId },
/** The status change itself, inside the caller's transaction. */
private async moveStatus(
tx: Prisma.TransactionClient,
orderId: string,
newStatus: OrderStatus,
actorAdminUserId?: string,
note?: string,
) {
const order = await tx.order.findUnique({ where: { id: orderId } });
if (!order) throw new NotFoundException(`Order ${orderId} not found`);
const updated = await tx.order.update({
where: { id: orderId },
data: {
status: newStatus,
deliveredAt: newStatus === OrderStatus.DELIVERED_TO_CUSTOMER ? new Date() : order.deliveredAt,
},
});
await tx.orderStatusEvent.create({
data: { orderId, fromStatus: order.status, toStatus: newStatus, note, actorAdminId: actorAdminUserId },
});
// Debt follows the status, as legacy's recomputed `sts > 4` did: owed
// while delivered (to the expert or the customer), not otherwise, never
// twice. See LedgerService.syncOrderDebt (ACC-03 / MON-04).
await this.ledger.syncOrderDebt(orderId, tx);
// D27 — the accounting document (پیش‌فاکتور) follows the owner's rule on
// which status posts it, queued in this same transaction.
if (newStatus === OrderStatus.CANCELLED && order.status !== OrderStatus.CANCELLED) {
await this.accounting.orderCancelled(tx, orderId);
} else if (newStatus !== order.status) {
await this.accounting.orderStatusReached(tx, orderId, newStatus);
}
return updated;
}
/* ───────────── correcting a placed order ───────────── */
/**
* An order's totals from its stored lines — the same three sums createOrder
* takes, so a corrected order and a freshly placed one agree: grams owed,
* the making-charge part of them, and the rial making charge (unit × qty).
*/
private async recalcOrder(tx: Prisma.TransactionClient, orderId: string) {
const items = await tx.orderItem.findMany({ where: { orderId } });
const zero = new Prisma.Decimal(0);
const total = items.reduce((s, i) => s.add(new Prisma.Decimal(i.computedWeightGrams).mul(i.quantity)), zero);
const rash = items.reduce((s, i) => s.add(new Prisma.Decimal(i.rashWeightGramsSnapshot).mul(i.quantity)), zero);
const rial = items.reduce((s, i) => s.add(new Prisma.Decimal(i.flatRialCharge).mul(i.quantity)), zero);
return tx.order.update({
where: { id: orderId },
data: { totalGoldWeightGrams: total, wageWeightGrams: total.sub(rash), flatRialCharge: rial },
});
}
/**
* After a line changed: totals, then the debt (a delivered order owes the new
* figure — only the difference is posted, see LedgerService.syncOrderDebt),
* then the accounting document, which no longer matches if it was already sent.
*/
private async afterCorrection(tx: Prisma.TransactionClient, orderId: string) {
await this.recalcOrder(tx, orderId);
await this.ledger.syncOrderDebt(orderId, tx);
await this.accounting.orderEdited(tx, orderId);
}
private async assertCorrectable(tx: Prisma.TransactionClient, orderId: string) {
const order = await tx.order.findUnique({ where: { id: orderId } });
if (!order) throw new NotFoundException('سفارش یافت نشد');
if (order.status === OrderStatus.CANCELLED) throw new BadRequestException('سفارش لغوشده قابل ویرایش نیست');
const blocked = await this.accounting.orderDocumentBlocksEdit(tx, orderId);
if (blocked) throw new BadRequestException(blocked);
return order;
}
/** A figure from a form: a finite number inside [min, max], or a Persian 400. */
private figure(value: unknown, label: string, min: number, max: number, minExclusive = false): Prisma.Decimal {
let d: Prisma.Decimal;
try {
d = new Prisma.Decimal(String(value).trim());
} catch {
throw new BadRequestException(`${label}: عدد معتبر نیست`);
}
if (!d.isFinite() || (minExclusive ? d.lte(min) : d.lt(min)) || d.gt(max)) {
throw new BadRequestException(`${label}: باید ${minExclusive ? 'بیشتر از' : 'حداقل'} ${min} و حداکثر ${max} باشد`);
}
return d;
}
/**
* Legacy `POST order-item/edit` (MajorOrderController::orderItemEdit): the
* panel corrects one line of a wholesale order — its raw weight, its quantity
* and the percent actually charged.
*
* w = rash × (1 + percent/100) per unit, as the reference computed it
*
* then the order's totals and the customer's debt follow (the reference ran
* updateRemain). Not copied from the reference: its order `wage` came out as
* the whole weight (it summed a column that does not exist), and the order's
* rial charge stayed at the old quantity — here both follow the lines.
* The line's own discount is kept; the percent given is the NET one.
*/
async editLine(
orderId: string,
lineId: string,
input: { rashWeightGrams?: unknown; quantity?: unknown; netPercent?: unknown },
) {
return this.prisma.$transaction(async (tx) => {
await this.assertCorrectable(tx, orderId);
const line = await tx.orderItem.findFirst({ where: { id: lineId, orderId } });
if (!line) throw new NotFoundException('ردیف سفارش یافت نشد');
const has = (v: unknown) => v !== undefined && v !== null && v !== '';
const rash = has(input.rashWeightGrams)
? this.figure(input.rashWeightGrams, 'وزن خام', 0, 100000, true)
: new Prisma.Decimal(line.rashWeightGramsSnapshot);
const quantity = has(input.quantity) ? this.figure(input.quantity, 'تعداد', 1, 100000) : new Prisma.Decimal(line.quantity);
if (!quantity.isInteger()) throw new BadRequestException('تعداد: باید عدد صحیح باشد');
const discount = new Prisma.Decimal(line.discountPercentSnapshot);
const net = has(input.netPercent)
? this.figure(input.netPercent, 'درصد اجرت', -100, 1000)
: new Prisma.Decimal(line.wagePercentSnapshot).sub(discount);
await tx.orderItem.update({
where: { id: lineId },
data: {
status: newStatus,
deliveredAt: newStatus === OrderStatus.DELIVERED_TO_CUSTOMER ? new Date() : order.deliveredAt,
rashWeightGramsSnapshot: rash,
quantity: quantity.toNumber(),
wagePercentSnapshot: net.add(discount),
computedWeightGrams: rash.mul(net.div(100).add(1)),
},
});
await tx.orderStatusEvent.create({
data: { orderId, fromStatus: order.status, toStatus: newStatus, note, actorAdminId: actorAdminUserId },
});
// Debt follows the status, as legacy's recomputed `sts > 4` did: owed
// while delivered (to the expert or the customer), not otherwise, never
// twice. See LedgerService.syncOrderDebt (ACC-03 / MON-04).
await this.ledger.syncOrderDebt(orderId, tx);
// D27 — the accounting document (پیش‌فاکتور) follows the owner's rule on
// which status posts it, queued in this same transaction.
if (newStatus === OrderStatus.CANCELLED && order.status !== OrderStatus.CANCELLED) {
await this.accounting.orderCancelled(tx, orderId);
} else if (newStatus !== order.status) {
await this.accounting.orderStatusReached(tx, orderId, newStatus);
}
return updated;
await this.afterCorrection(tx, orderId);
});
}
/**
* Legacy `DELETE major/order_item/{id}` (MajorOrderController::deleteOrderItem).
* The last line cannot go: an order with no lines owes nothing and shows
* nothing — delete the order instead.
*/
async removeLine(orderId: string, lineId: string) {
return this.prisma.$transaction(async (tx) => {
await this.assertCorrectable(tx, orderId);
const lines = await tx.orderItem.findMany({ where: { orderId }, select: { id: true } });
if (!lines.some((l) => l.id === lineId)) throw new NotFoundException('ردیف سفارش یافت نشد');
if (lines.length < 2) {
throw new BadRequestException('آخرین ردیف سفارش را نمی‌توان حذف کرد؛ کل سفارش را حذف یا لغو کنید.');
}
await tx.orderItem.delete({ where: { id: lineId } });
await this.afterCorrection(tx, orderId);
});
}
/**
* Legacy `PUT order/child/{id}` (ApiMobileController::updateChildOrder): the
* sales expert, with the order in hand («تحویل مسئول فروش»), enters the real
* weight of each piece and may then submit it for good («ثبت نهایی»), which
* hands it to the customer.
*
* What the screen asks for is the corrected RAW weight per piece; the final
* weight is that weight with the making charge the order was placed at. The
* reference computed it from a `percent_pay` its own order lines do not
* carry, so the charge silently dropped out and the order shrank to raw
* weight — the intent is kept here, the breakage is not. It also let anyone
* edit any order; here it has to be this expert's own, still with them.
*/
async expertAdjust(
orderId: string,
expertAdminUserId: string,
lines: { id: unknown; rashWeightGrams: unknown }[],
finalSubmit: boolean,
) {
const result = await this.prisma.$transaction(async (tx) => {
const order = await tx.order.findUnique({ where: { id: orderId }, include: { items: true } });
// Someone else's order is "not found", not "forbidden" (no enumeration).
if (!order || order.salesAgentId !== expertAdminUserId) throw new NotFoundException('سفارش یافت نشد');
if (order.status !== OrderStatus.DELIVERED_TO_AGENT) {
throw new BadRequestException('این سفارش در وضعیت «تحویل مسئول فروش» نیست');
}
const blocked = await this.accounting.orderDocumentBlocksEdit(tx, orderId);
if (blocked) throw new BadRequestException(blocked);
if (!Array.isArray(lines)) throw new BadRequestException('ردیف‌های سفارش ارسال نشده است');
const before = new Prisma.Decimal(order.totalGoldWeightGrams);
for (const l of lines) {
const line = order.items.find((i) => i.id === String(l.id));
if (!line) throw new BadRequestException('ردیف سفارش پیدا نشد');
const rash = this.figure(l.rashWeightGrams, 'وزن', 0, 100000, true);
const net = new Prisma.Decimal(line.wagePercentSnapshot).sub(line.discountPercentSnapshot);
await tx.orderItem.update({
where: { id: line.id },
data: { rashWeightGramsSnapshot: rash, computedWeightGrams: rash.mul(net.div(100).add(1)) },
});
}
await this.afterCorrection(tx, orderId);
if (finalSubmit) await this.moveStatus(tx, orderId, OrderStatus.DELIVERED_TO_CUSTOMER, undefined, 'ثبت نهایی توسط کارشناس فروش');
const after = await tx.order.findUniqueOrThrow({ where: { id: orderId } });
return { orderNumber: after.orderNumber, status: after.status, before, after: new Prisma.Decimal(after.totalGoldWeightGrams) };
});
// The ledger shows the difference as a correction entry; this is the plain-text trail.
this.logger.log(
`Order ${result.orderNumber} weights corrected by sales expert ${expertAdminUserId}: ` +
`${result.before.toString()} g → ${result.after.toString()} g${finalSubmit ? ' (submitted as final)' : ''}.`,
);
return { id: result.orderNumber, status: result.status };
}
/** Unscoped read. Only reachable from the admin-guarded route. */
getById(orderId: string) {
return this.prisma.order.findUnique({
@@ -0,0 +1,74 @@
import { Prisma } from '@prisma/client';
import { WageService } from './wage.service';
/**
* WageService.quote against the reference's promotion rules (henza_pwa
* Mixins/product: getCandidPromotions / checkIgnoreList / getPromotion).
*/
describe('WageService.quote — promotions and the exclusion list', () => {
const D = (v: Prisma.Decimal.Value) => new Prisma.Decimal(v);
function build(exclusions: { type: number; itemId: string }[]) {
const variants = [
{ id: 'v1', defaultWagePercent: D(7), product: { id: 'p1', categoryId: 'cat1', brandId: 'b1' } },
{ id: 'v2', defaultWagePercent: D(7), product: { id: 'p2', categoryId: 'cat2', brandId: 'b2' } },
];
const promotions = [
{ scope: 'PRODUCT', productId: 'p1', categoryId: null, brandId: null, discountPercent: D(2) },
{ scope: 'CATEGORY', productId: null, categoryId: 'cat1', brandId: null, discountPercent: D(1) },
{ scope: 'PRODUCT', productId: 'p2', categoryId: null, brandId: null, discountPercent: D(4) },
];
const prisma = {
customer: { findUnique: jest.fn(async () => ({ makingChargeOffsetPercent: D(3) })) },
productVariant: { findMany: jest.fn(async () => variants) },
promotion: { findMany: jest.fn(async () => promotions) },
promotionExclusion: { findMany: jest.fn(async () => exclusions) },
setting: { findUnique: jest.fn(async () => null) }, // legacy formula
};
return new WageService(prisma as never);
}
const final = async (svc: WageService, id: string) => (await svc.quote('c1', ['v1', 'v2'])).get(id)!.finalPercent;
it('with no exclusions: variant + customer − the largest promotion', async () => {
const svc = build([]);
expect(await final(svc, 'v1')).toBe('8'); // 7 + 3 − max(2, 1)
expect(await final(svc, 'v2')).toBe('6'); // 7 + 3 − 4
});
it('a product on the exclusion list gets no promotion at all', async () => {
const svc = build([{ type: 1, itemId: 'p1' }]);
expect(await final(svc, 'v1')).toBe('10'); // 7 + 3, nothing taken off
expect(await final(svc, 'v2')).toBe('6'); // untouched
});
it('an excluded category switches off every promotion of its products', async () => {
const svc = build([{ type: 2, itemId: 'cat1' }]);
expect(await final(svc, 'v1')).toBe('10');
expect(await final(svc, 'v2')).toBe('6');
});
it('an excluded brand switches off its products', async () => {
const svc = build([{ type: 4, itemId: 'b2' }]);
expect(await final(svc, 'v1')).toBe('8');
expect(await final(svc, 'v2')).toBe('10');
});
it('an excluded customer gets no promotion on anything', async () => {
const svc = build([{ type: 3, itemId: 'c1' }]);
expect(await final(svc, 'v1')).toBe('10');
expect(await final(svc, 'v2')).toBe('10');
});
it('an exclusion of another type with the same id is not a match', async () => {
// type 3 (customer) "p1" must not switch off product p1.
const svc = build([{ type: 3, itemId: 'p1' }]);
expect(await final(svc, 'v1')).toBe('8');
});
it('the stored snapshot still satisfies wage − discount = final', async () => {
const svc = build([]);
const q = (await svc.quote('c1', ['v1'])).get('v1')!;
expect(D(q.wagePercentSnapshot).sub(q.discountPercentSnapshot).toString()).toBe(q.finalPercent);
});
});
+26 -6
View File
@@ -113,6 +113,13 @@ export class WageService {
* 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).
*
* The «استثنا» list (views/offersIgnore) switches promotions off: legacy's
* `checkIgnoreList` (henza_pwa Mixins/product) returned no promotion at all
* for a product, category, brand or customer on it. The list was stored and
* edited in the panel but nothing here read it, so an excluded product still
* got the discount taken off its making charge — fewer grams charged than the
* reference charges.
*/
async quote(customerId: string, variantIds: string[], tx: Tx = this.prisma): Promise<Map<string, WageQuote>> {
const customer = await tx.customer.findUnique({
@@ -127,6 +134,12 @@ export class WageService {
select: { id: true, defaultWagePercent: true, product: { select: { id: true, categoryId: true, brandId: true } } },
});
// Type codes are the promotion scope codes: 1 product | 2 category | 3 customer | 4 brand.
const exclusions = await tx.promotionExclusion.findMany({ select: { type: true, itemId: true } });
const excluded = (type: number, itemId: string | null | undefined) =>
!!itemId && exclusions.some((e) => e.type === type && e.itemId === itemId);
const customerExcluded = excluded(3, customerId);
const now = new Date();
const promotions = await tx.promotion.findMany({
where: {
@@ -148,12 +161,19 @@ export class WageService {
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 ignored =
customerExcluded ||
excluded(1, v.product.id) ||
excluded(2, v.product.categoryId) ||
excluded(4, v.product.brandId);
const applying = ignored
? []
: 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,
@@ -0,0 +1,23 @@
import { agentSalesWhere, customerSalesWhere } from './agent-orders';
/**
* A cancelled order is a removed one (the reference deleted it, and with it its
* debt and its share of «میزان فروش»); both sales sums must leave it out while
* still counting every live status.
*/
describe('sales sums leave cancelled orders out', () => {
it("an expert's sales: both arms, and never a cancelled order", () => {
expect(agentSalesWhere('a1')).toEqual({
AND: [
{ OR: [{ salesAgentId: 'a1' }, { customer: { salesExpertTradingFor: { adminUserId: 'a1' } } }] },
{ status: { not: 'CANCELLED' } },
],
});
});
it("a customer's sales: their own orders and the ones that named them, never a cancelled order", () => {
expect(customerSalesWhere('c1')).toEqual({
AND: [{ OR: [{ customerId: 'c1' }, { referrerCustomerId: 'c1' }] }, { status: { not: 'CANCELLED' } }],
});
});
});
@@ -1,11 +1,22 @@
import type { Prisma } from '@prisma/client';
import { OrderStatus, type Prisma } from '@prisma/client';
/**
* A cancelled order is a removed order. The reference had no cancelled state:
* removing an order (`Order::safeDelete`) deleted it, and with it its debt and
* its share of «میزان فروش» (updateGramSell summed what was left). The target
* keeps the row and marks it CANCELLED, so every sales sum has to leave it out
* or a cancelled order would keep counting as sold.
*/
const NOT_CANCELLED: Prisma.OrderWhereInput = { status: { not: OrderStatus.CANCELLED } };
/**
* The orders that count as a sales expert's sales («میزان فروش»).
*
* Legacy `CustomerController::updateGramSell`:
* SUM(orders.input) WHERE customer_id = X OR reagent_id = X
* (soft-deleted orders excluded by the model, no status filter).
* (soft-deleted orders excluded by the model, no status filter — the only
* statuses that existed were the live ones; cancelled ones are left out here
* for the reason above).
*
* - `reagent_id = X` is Order.salesAgentId: the expert recorded on the order
* when it was placed — NOT the customer's current referrer, so a customer
@@ -15,7 +26,10 @@ import type { Prisma } from '@prisma/client';
*/
export function agentSalesWhere(agentId: string): Prisma.OrderWhereInput {
return {
OR: [{ salesAgentId: agentId }, { customer: { salesExpertTradingFor: { adminUserId: agentId } } }],
AND: [
{ OR: [{ salesAgentId: agentId }, { customer: { salesExpertTradingFor: { adminUserId: agentId } } }] },
NOT_CANCELLED,
],
};
}
@@ -25,5 +39,5 @@ export function agentSalesWhere(agentId: string): Prisma.OrderWhereInput {
* order that named them as its referrer (Order.referrerCustomerId).
*/
export function customerSalesWhere(customerId: string): Prisma.OrderWhereInput {
return { OR: [{ customerId }, { referrerCustomerId: customerId }] };
return { AND: [{ OR: [{ customerId }, { referrerCustomerId: customerId }] }, NOT_CANCELLED] };
}
@@ -52,7 +52,8 @@ export class SalesAgentService {
* Two deliberate fidelity notes:
* - The `OR customer_id = X` arm is the expert's trading account (D21): an
* AdminUser cannot own an Order, so their own orders sit on that Customer.
* - No status filter, matching legacy. The admin panel disagrees with itself
* - No status filter, matching legacy, except that a cancelled order is a
* removed one (legacy deleted it) and is left out. The admin panel disagrees with itself
* here — `AdminCustomersService.listReagents` aggregates unfiltered while
* `reagentDetail` filters on the SOLD statuses — so this matches
* listReagents (the grid figure) and legacy. If those three are ever
@@ -1,4 +1,4 @@
import { Body, Controller, Get, HttpCode, Post, Query } from '@nestjs/common';
import { Body, Controller, Get, HttpCode, Param, Post, Put, Query } from '@nestjs/common';
import { SalesExpertAppService } from './sales-expert-app.service';
import { CurrentCustomer, CustomerAuth } from '../customer-auth/customer.guards';
import type { AuthenticatedCustomer } from '../customer-auth/app-principal';
@@ -7,6 +7,9 @@ import type { AuthenticatedCustomer } from '../customer-auth/app-principal';
* The sales expert's own screens in the customer apps.
*
* POST expert/child-orders -> legacy POST /order/child (ApiMobileController:759)
* PUT expert/child-orders/:id -> legacy PUT /order/child/{id} (updateChildOrder)
* POST expert/customers/:id/toggle-active -> legacy POST customer/toggle-active
* PUT expert/customers/:id -> legacy POST customer/update
* POST expert/my-customers -> legacy POST /my-child (ApiMobileController:184)
* GET expert/me -> the expert's profile strip
*
@@ -32,6 +35,38 @@ export class SalesExpertAppController {
return this.expert.childOrders(me.adminUserId, body ?? {});
}
/** Legacy PUT /order/child/{id} — correct the weights, or submit as final. */
@CustomerAuth()
@Put('child-orders/:orderId')
@HttpCode(200)
adjustChildOrder(
@Param('orderId') orderId: string,
@Body() body: { items?: { id?: unknown; rash_weight?: unknown }[]; final_submit?: unknown },
@CurrentCustomer() me: AuthenticatedCustomer,
) {
return this.expert.adjustChildOrder(me.adminUserId, orderId, body ?? {});
}
/** Legacy POST customer/toggle-active — switch one of the expert's customers off or on. */
@CustomerAuth()
@Post('customers/:customerId/toggle-active')
@HttpCode(200)
toggleCustomerActive(@Param('customerId') customerId: string, @CurrentCustomer() me: AuthenticatedCustomer) {
return this.expert.toggleCustomerActive(me.adminUserId, customerId);
}
/** Legacy POST customer/update — correct the contact details of one of the expert's customers. */
@CustomerAuth()
@Put('customers/:customerId')
@HttpCode(200)
updateCustomer(
@Param('customerId') customerId: string,
@Body() body: Record<string, unknown>,
@CurrentCustomer() me: AuthenticatedCustomer,
) {
return this.expert.updateCustomer(me.adminUserId, customerId, body ?? {});
}
/** Legacy POST /my-child. */
@CustomerAuth()
@Post('my-customers')
@@ -0,0 +1,197 @@
import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
import { AdminRole } from '@prisma/client';
import { SalesExpertAppService } from './sales-expert-app.service';
/**
* «مشتریان من» in the app — switching a customer on/off and editing their
* contact details (reference customer/toggle-active and customer/update).
*/
describe('SalesExpertAppService — my customers: toggle and edit', () => {
const EXPERT = 'expert1';
const OWN = { id: 'c1', fullName: 'علی رضایی', firstName: 'علی', lastName: 'رضایی', mobile: '09121111111', isActive: true, referredByAgentId: EXPERT };
function build(opts: { customer?: any | null; expertHoldsMobile?: boolean; otherCustomerHoldsMobile?: boolean } = {}) {
const customer = opts.customer === undefined ? { ...OWN } : opts.customer;
const updates: any[] = [];
const prisma: any = {
customer: {
// Ownership is part of the query: another expert's customer is simply not found.
findFirst: jest.fn(async ({ where }: any) => {
if (where.referredByAgentId !== undefined) {
return customer && where.id === customer.id && where.referredByAgentId === customer.referredByAgentId ? customer : null;
}
return opts.otherCustomerHoldsMobile ? { id: 'someone-else' } : null; // the mobile lookup
}),
update: jest.fn(async ({ data }: any) => {
updates.push(data);
return { ...customer, ...Object.fromEntries(Object.entries(data).filter(([, v]) => typeof v !== 'object' || v === null)) };
}),
},
adminUser: { findFirst: jest.fn(async () => (opts.expertHoldsMobile ? { id: 'ex' } : null)) },
};
prisma.$transaction = jest.fn(async (fn: any) => fn(prisma));
const sms = { send: jest.fn(async () => ({ ok: true })) };
const accounting = { customerEdited: jest.fn(async () => undefined) };
const orders = {};
const service = new SalesExpertAppService(prisma, orders as never, sms as never, accounting as never);
return { service, prisma, sms, accounting, updates };
}
describe('toggle active', () => {
it("switches an own customer off, and says so without texting them", async () => {
const t = build();
const res = await t.service.toggleCustomerActive(EXPERT, 'c1');
expect(res).toEqual({ id: 'c1', active: -1 });
expect(t.updates[0]).toEqual({ isActive: false });
expect(t.sms.send).not.toHaveBeenCalled();
});
it('switches an inactive one back on and texts them, as the reference did', async () => {
const t = build({ customer: { ...OWN, isActive: false } });
const res = await t.service.toggleCustomerActive(EXPERT, 'c1');
expect(res).toEqual({ id: 'c1', active: 1 });
expect(t.sms.send).toHaveBeenCalledWith('09121111111', expect.stringContaining('فعال شد'));
});
it("another expert's customer is not found — and nothing is changed", async () => {
const t = build();
await expect(t.service.toggleCustomerActive('expert2', 'c1')).rejects.toThrow(NotFoundException);
expect(t.updates).toHaveLength(0);
});
it('refuses anyone who is not a sales expert', async () => {
const t = build();
await expect(t.service.toggleCustomerActive(undefined, 'c1')).rejects.toThrow(ForbiddenException);
});
});
describe('edit', () => {
it('saves the contact details the form carries', async () => {
const t = build();
await t.service.updateCustomer(EXPERT, 'c1', {
fname: 'محمد',
lname: 'کریمی',
phone: '02122223333',
address: 'بازار، پاساژ طلا',
store_name: 'گالری کریمی',
pasazh_name: 'پاساژ طلا',
zone: '12',
national_code: '۰۰۱۲۳۴۵۶۷۸',
birth_date: '1990-05-20',
city_id: 'city1',
});
const d = t.updates[0];
expect(d).toMatchObject({ firstName: 'محمد', lastName: 'کریمی', phone: '02122223333', storeName: 'گالری کریمی', pasazhName: 'پاساژ طلا', zone: '12', nationalCode: '0012345678' });
expect(d.city).toEqual({ connect: { id: 'city1' } });
expect(d.birthDate).toEqual(new Date(Date.UTC(1990, 4, 20)));
});
it('can NEVER change the percent, credit, kind, expert or the account switches — whatever the request carries', async () => {
const t = build();
await t.service.updateCustomer(EXPERT, 'c1', {
fname: 'علی',
lname: 'رضایی',
percent: 0,
makingChargeOffsetPercent: 0,
gram_level: 99999,
goldCreditLimitGrams: 99999,
type: 2,
kind: 'SALES_AGENT',
reagent_id: 'expert2',
referredByAgentId: 'expert2',
isActive: false,
active: -1,
canPlaceOrders: false,
sts: -1,
mcode: 'X1',
password: 'hacked',
goldDebtGrams: 0,
fiatDebtRial: 0,
});
expect(Object.keys(t.updates[0]).sort()).toEqual(['firstName', 'lastName']);
});
it("another expert's customer is not found", async () => {
const t = build();
await expect(t.service.updateCustomer('expert2', 'c1', { fname: 'a', lname: 'b' })).rejects.toThrow(NotFoundException);
expect(t.updates).toHaveLength(0);
});
it('needs both names', async () => {
const t = build({ customer: { ...OWN, firstName: null, lastName: null } });
await expect(t.service.updateCustomer(EXPERT, 'c1', { fname: 'علی' })).rejects.toThrow('نام و نام خانوادگی');
await expect(t.service.updateCustomer(EXPERT, 'c1', { fname: ' ', lname: 'x' })).rejects.toThrow(BadRequestException);
});
it('a changed mobile must be a mobile, not held by an active expert, not held by another customer', async () => {
await expect(build().service.updateCustomer(EXPERT, 'c1', { fname: 'a', lname: 'b', mobile: '12345' })).rejects.toThrow('معتبر');
await expect(build({ expertHoldsMobile: true }).service.updateCustomer(EXPERT, 'c1', { fname: 'a', lname: 'b', mobile: '09125556666' })).rejects.toThrow('کارشناس فروش');
await expect(build({ otherCustomerHoldsMobile: true }).service.updateCustomer(EXPERT, 'c1', { fname: 'a', lname: 'b', mobile: '09125556666' })).rejects.toThrow('مشتری دیگری');
});
it('saves a new mobile written in Persian digits as ASCII, and leaves an unchanged one alone', async () => {
const a = build();
await a.service.updateCustomer(EXPERT, 'c1', { fname: 'a', lname: 'b', mobile: '۰۹۱۲۵۵۵۶۶۶۶' });
expect(a.updates[0].mobile).toBe('09125556666');
const b = build();
await b.service.updateCustomer(EXPERT, 'c1', { fname: 'a', lname: 'b', mobile: '09121111111' });
expect('mobile' in b.updates[0]).toBe(false);
});
it('tells the accounting account only when a field it carries changed', async () => {
const a = build();
await a.service.updateCustomer(EXPERT, 'c1', { fname: 'a', lname: 'b', phone: '021' });
expect(a.accounting.customerEdited).toHaveBeenCalledWith(expect.anything(), 'c1');
const b = build();
await b.service.updateCustomer(EXPERT, 'c1', { store_name: 'x' });
expect(b.accounting.customerEdited).not.toHaveBeenCalled();
});
it('a name or text field that is an object or a list is refused, not stored as [object Object]', async () => {
const { service, updates } = build();
await expect(service.updateCustomer(EXPERT, 'c1', { fname: { a: 1 }, lname: 'x' })).rejects.toThrow(BadRequestException);
await expect(service.updateCustomer(EXPERT, 'c1', { fname: 'a', lname: ['x'] })).rejects.toThrow(BadRequestException);
await expect(service.updateCustomer(EXPERT, 'c1', { fname: 'a', lname: 'b', address: { x: 1 } })).rejects.toThrow(BadRequestException);
expect(updates).toHaveLength(0);
});
it('refuses an impossible birth date', async () => {
await expect(build().service.updateCustomer(EXPERT, 'c1', { fname: 'a', lname: 'b', birth_date: '2999-01-01' })).rejects.toThrow('تاریخ تولد');
});
});
it('the expert-role helper exists for the only role that can use these routes', () => {
expect(AdminRole.SALES_EXPERT).toBeDefined();
});
});
/**
* «ثبت نهایی» and the weight entry: the expert cannot touch the percent. The
* route reads only `id` and `rash_weight` from each line, so a percent sent
* along is not even passed on.
*/
describe('SalesExpertAppService.adjustChildOrder — no percent', () => {
it('passes on only the line ids and raw weights', async () => {
const orders = { expertAdjust: jest.fn(async () => ({ id: '100', status: 'DELIVERED_TO_AGENT' })) };
const service = new SalesExpertAppService({} as never, orders as never, {} as never, {} as never);
await service.adjustChildOrder('expert1', 'o1', {
items: [{ id: 'i1', rash_weight: 5.9, percent: 0, percent_pay: 0, discount: 50, wage: 0 } as never],
final_submit: 'true',
});
expect(orders.expertAdjust).toHaveBeenCalledWith('o1', 'expert1', [{ id: 'i1', rashWeightGrams: 5.9 }], true);
});
it('refuses a customer token', async () => {
const service = new SalesExpertAppService({} as never, {} as never, {} as never, {} as never);
await expect(service.adjustChildOrder(undefined, 'o1', { items: [] })).rejects.toThrow(ForbiddenException);
});
it('items that are not a list are a 400 (it used to be a TypeError, a 500)', async () => {
const orders = { expertAdjust: jest.fn() };
const service = new SalesExpertAppService({} as never, orders as never, {} as never, {} as never);
for (const items of ['x', 5, { a: 1 }, true]) {
await expect(service.adjustChildOrder('expert1', 'o1', { items } as never)).rejects.toThrow(BadRequestException);
}
expect(orders.expertAdjust).not.toHaveBeenCalled();
});
});
@@ -1,11 +1,12 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../prisma/prisma.module';
import { OrdersModule } from '../orders/orders.module';
import { SalesExpertAppController } from './sales-expert-app.controller';
import { SalesExpertAppService } from './sales-expert-app.service';
/** The sales expert's own screens in the customer apps (D21). */
@Module({
imports: [PrismaModule],
imports: [PrismaModule, OrdersModule],
controllers: [SalesExpertAppController],
providers: [SalesExpertAppService],
exports: [SalesExpertAppService],
@@ -1,6 +1,13 @@
import { ForbiddenException, Injectable } from '@nestjs/common';
import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { OrderStatus, Prisma } from '@prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import { OrdersService } from '../orders/orders.service';
import { SmsService } from '../sms/sms.service';
import { AccountingHooksService } from '../accounting-sync/accounting-hooks.service';
import { faToEn } from '../customer-auth/digits';
import { brandName, brandSiteUrl } from '../../common/brand';
import { birthDateOrNull, nationalCodeOrNull } from '../../common/customer-input';
import { activeExpertHoldsMobile, EXPERT_HOLDS_MOBILE } from '../../common/mobile-owner';
/**
* The two screens a sales expert has in the app beyond what every customer
@@ -16,7 +23,161 @@ import { PrismaService } from '../../prisma/prisma.service';
*/
@Injectable()
export class SalesExpertAppService {
constructor(private readonly prisma: PrismaService) {}
private readonly logger = new Logger(SalesExpertAppService.name);
constructor(
private readonly prisma: PrismaService,
private readonly orders: OrdersService,
private readonly sms: SmsService,
private readonly accounting: AccountingHooksService,
) {}
/**
* The customer, but only if it is one of THIS expert's. Anyone else's is "not
* found" — never "forbidden" — so ids cannot be probed. (The reference's
* `customer/toggle-active` checked nothing at all: any signed-in customer
* could switch any account off; its `customer/update` did check ownership.)
*/
private async ownCustomer(agentId: string, customerId: string) {
const c = await this.prisma.customer.findFirst({
where: { id: customerId, referredByAgentId: agentId },
});
if (!c) throw new NotFoundException('مشتری یافت نشد');
return c;
}
/**
* Port of `POST customer/toggle-active` (AdminController::customerToggleActive)
* for «مشتریان من»: the expert switches one of their customers' accounts off
* or on. Off, the customer's token stops working on the next request (the
* customer guard reads `isActive` every time); on, the customer is told by SMS,
* as the reference told them.
*/
async toggleCustomerActive(adminUserId: string | undefined, customerId: string) {
const agentId = this.assertExpert(adminUserId);
const c = await this.ownCustomer(agentId, customerId);
const updated = await this.prisma.customer.update({
where: { id: c.id },
data: { isActive: !c.isActive },
});
if (updated.isActive) {
const text =
`${updated.fullName} عزیز\n` +
'حساب کاربری شما در اپلیکیشن فعال شد.\n' +
'هم‌اکنون می‌توانید محصولات جدید را بررسی و ثبت سفارش کنید.\n' +
'با مراجعه به لینک زیر می‌توانید وارد پروفایل کاربری خود شوید.\n' +
`${brandSiteUrl()}\n${brandName()}`;
void this.sms.send(updated.mobile, text).catch((e: unknown) => {
this.logger.error(`Activation SMS to ${updated.mobile} failed: ${String(e)}`);
});
}
return { id: updated.id, active: updated.isActive ? 1 : -1 };
}
/**
* Port of `POST customer/update` (AdminController::updateCustomer) for
* «ویرایش مشتری»: the expert corrects the contact details of one of their own
* customers.
*
* Only contact details can be changed here. The customer's making-charge
* percent, credit limit, kind, expert, account switches and codes are money
* and administration — the admin panel's, not the app's — so they are not
* read from the request at all, whatever it carries. (The reference form
* could also change the customer's `type`; that is not carried over.)
*/
async updateCustomer(adminUserId: string | undefined, customerId: string, body: Record<string, unknown>) {
const agentId = this.assertExpert(adminUserId);
const existing = await this.ownCustomer(agentId, customerId);
const patch: Prisma.CustomerUpdateInput = {};
// A value that is not text (an object, a list) would be stored as "[object Object]".
const text = (key: string): unknown => {
const v = body[key];
if (v !== undefined && v !== null && typeof v !== 'string' && typeof v !== 'number') {
throw new BadRequestException('مقدار وارد شده معتبر نیست');
}
return v;
};
// The reference required both names.
const firstName = String(text('fname') ?? existing.firstName ?? '').trim();
const lastName = String(text('lname') ?? existing.lastName ?? '').trim();
if (!firstName || !lastName) throw new BadRequestException('نام و نام خانوادگی الزامی است');
patch.firstName = firstName;
patch.lastName = lastName;
if (body.mobile !== undefined && body.mobile !== null && String(body.mobile).trim() !== '') {
const mobile = faToEn(String(body.mobile).trim());
if (!/^09\d{9}$/.test(mobile)) throw new BadRequestException('شماره موبایل معتبر نیست');
if (mobile !== existing.mobile) {
// One mobile, one person: another customer (the unique index) or an active
// expert (they sign in with it too) may already hold it.
if (await activeExpertHoldsMobile(this.prisma, mobile)) throw new BadRequestException(EXPERT_HOLDS_MOBILE);
if (await this.prisma.customer.findFirst({ where: { mobile, NOT: { id: existing.id } }, select: { id: true } })) {
throw new BadRequestException('این شماره موبایل قبلاً برای مشتری دیگری ثبت شده است');
}
patch.mobile = mobile;
}
}
const optional = (key: string, max: number) => {
if (body[key] === undefined) return undefined;
const s = String(text(key) ?? '').trim();
if (s.length > max) throw new BadRequestException('طول مقدار وارد شده بیش از حد مجاز است');
return s || null;
};
const phone = optional('phone', 30);
if (phone !== undefined) patch.phone = phone;
const address = optional('address', 500);
if (address !== undefined) patch.address = address;
const storeName = optional('store_name', 150);
if (storeName !== undefined) patch.storeName = storeName;
const pasazhName = optional('pasazh_name', 150);
if (pasazhName !== undefined) patch.pasazhName = pasazhName;
const zone = optional('zone', 20);
if (zone !== undefined) patch.zone = zone;
if (body.city_id !== undefined) {
patch.city = body.city_id ? { connect: { id: String(body.city_id) } } : { disconnect: true };
}
if (body.national_code !== undefined) patch.nationalCode = nationalCodeOrNull(body.national_code);
const birthDate = birthDateOrNull(body.birth_date);
if (birthDate !== undefined) patch.birthDate = birthDate;
// D27 — the accounting account carries name, mobile, national code, phone, address.
const contactChanged = ['fname', 'lname', 'mobile', 'national_code', 'phone', 'address'].some((k) => body[k] !== undefined);
const updated = await this.prisma.$transaction(async (tx) => {
const row = await tx.customer.update({ where: { id: existing.id }, data: patch });
if (contactChanged) await this.accounting.customerEdited(tx, existing.id);
return row;
});
return { id: updated.id, name: updated.fullName, mobile: updated.mobile };
}
/**
* Port of `updateChildOrder` (ApiMobileController.php, `PUT order/child/{id}`):
* the expert enters the real raw weight of each piece of an order that is in
* their hands, and may submit it as final, which hands it to the customer.
* Scope is the expert's own orders only; the rules and the totals, debt and
* accounting that follow are OrdersService.expertAdjust.
*/
async adjustChildOrder(
adminUserId: string | undefined,
orderId: string,
body: { items?: { id?: unknown; rash_weight?: unknown }[]; final_submit?: unknown },
) {
const agentId = this.assertExpert(adminUserId);
const finalSubmit = body?.final_submit === true || body?.final_submit === 'true';
const items = body?.items ?? [];
if (!Array.isArray(items)) throw new BadRequestException('items باید فهرست ردیف‌ها باشد');
return this.orders.expertAdjust(
orderId,
agentId,
items.map((i) => ({ id: i?.id, rashWeightGrams: i?.rash_weight })),
finalSubmit,
);
}
/** Refuses any principal that is not a sales expert. */
private assertExpert(adminUserId: string | undefined): string {
@@ -80,7 +241,15 @@ export class SalesExpertAppService {
skip: (page - 1) * limit,
take: limit,
include: {
items: true,
// The product and variant titles label each line where the expert
// enters the real weights.
items: {
include: {
productVariant: {
select: { id: true, title: true, product: { select: { id: true, title: true } } },
},
},
},
customer: { select: { id: true, fullName: true, mobile: true } },
},
}),
@@ -150,8 +319,15 @@ export class SalesExpertAppService {
select: {
id: true,
fullName: true,
firstName: true,
lastName: true,
mobile: true,
phone: true,
address: true,
zone: true,
cityId: true,
nationalCode: true,
birthDate: true,
storeName: true,
pasazhName: true,
goldCreditLimitGrams: true,
@@ -167,8 +343,16 @@ export class SalesExpertAppService {
// legacy key, which the ported card binds to
name: c.fullName,
fullName: c.fullName,
// What «ویرایش مشتری» opens with.
fname: c.firstName ?? c.fullName,
lname: c.lastName ?? '',
mobile: c.mobile,
phone: c.phone,
address: c.address,
zone: c.zone,
city_id: c.cityId,
national_code: c.nationalCode,
birth_date: c.birthDate ? c.birthDate.toISOString().slice(0, 10) : null,
store_name: c.storeName,
pasazh_name: c.pasazhName,
gram_level: c.goldCreditLimitGrams,
+74
View File
@@ -373,6 +373,15 @@ export interface ExpertCustomer {
phone: string | null;
store_name: string | null;
pasazh_name: string | null;
/** What «ویرایش مشتری» opens with. */
fname: string;
lname: string;
address: string | null;
zone: string | null;
city_id: string | null;
national_code: string | null;
/** Gregorian YYYY-MM-DD, or null. */
birth_date: string | null;
gram_level: string;
remain: string;
rial_remain: string;
@@ -380,6 +389,47 @@ export interface ExpertCustomer {
createdAt: string;
}
/** The contact details an expert may change on one of their customers — never the percent or credit. */
export interface ExpertCustomerEdit {
fname: string;
lname: string;
mobile?: string;
phone?: string | null;
address?: string | null;
store_name?: string | null;
pasazh_name?: string | null;
national_code?: string | null;
birth_date?: string | null;
}
/** One «سفارش ساخت» request as the customer sees it. */
export interface PreOrderRow {
id: string;
status: string;
/** The reference's label: ثبت سفارش، تایید ادمین، تایید کارگاه، … */
sts_str_fa: string;
/** 1–5 on the tracker; 0 = cancelled. */
step: number;
color: string | null;
size: string | null;
color_mina: string | null;
color_stone: string | null;
sum: number;
description: string | null;
image: string | null;
createdAt: string;
}
export interface PreOrderInput {
color: string;
size?: string;
color_mina?: string;
color_stone?: string;
sum: number;
file_id?: string | null;
description?: string;
}
/** One row of "سفارش مشتریان". */
export interface ExpertChildOrder {
id: string;
@@ -653,6 +703,30 @@ export const api = {
'/expert/child-orders',
{ method: 'POST', body: JSON.stringify({ page, search: search ?? null }) },
),
/**
* The expert's real weight for each piece of an order in their hands, and
* optionally «ثبت نهایی» (hand it to the customer). Only raw weights travel:
* the making charge, the totals and the debt are the server's to compute.
* Mirrors legacy PUT /order/child/{id}.
*/
/** Switch one of the expert's customers off or on (legacy customer/toggle-active). */
toggleExpertCustomer: (customerId: string) =>
request<{ id: string; active: number }>(`/expert/customers/${encodeURIComponent(customerId)}/toggle-active`, { method: 'POST' }),
/** Correct a customer's contact details (legacy customer/update). */
updateExpertCustomer: (customerId: string, body: ExpertCustomerEdit) =>
request<{ id: string; name: string; mobile: string }>(`/expert/customers/${encodeURIComponent(customerId)}`, {
method: 'PUT',
body: JSON.stringify(body),
}),
// ── «سفارش ساخت» (legacy create-custom-order / get-custom-orders) ──
createPreOrder: (body: PreOrderInput) =>
request<PreOrderRow>('/customer/me/pre-orders', { method: 'POST', body: JSON.stringify(body) }),
getMyPreOrders: () => request<{ rows: PreOrderRow[] }>('/customer/me/pre-orders'),
adjustExpertChildOrder: (orderId: string, items: { id: string; rash_weight: number }[], finalSubmit: boolean) =>
request<{ id: string; status: string }>(`/expert/child-orders/${encodeURIComponent(orderId)}`, {
method: 'PUT',
body: JSON.stringify({ items, final_submit: finalSubmit }),
}),
// ── complete_profile ─────────────────────────────────────────────────
// Ports POST /major/complete (henza_server Major/CustomerController::complete)
+4
View File
@@ -45,6 +45,10 @@ export const router = createRouter({
},
{ path: '/profile', name: 'profileData', component: () => import('../views/user/ProfileView.vue'), meta: { requiresAuth: true } },
{ path: '/favorites', name: 'favoritesList', component: () => import('../views/user/FavoritesView.vue'), meta: { requiresAuth: true } },
// «سفارش ساخت» — ask for a piece to be made, and follow the request.
// Reference paths and names kept (henza_pwa router/index.ts:113-128).
{ path: '/makeOrder', name: 'makeOrderList', component: () => import('../views/user/MakeOrderView.vue'), meta: { requiresAuth: true } },
{ path: '/makeOrder_status', name: 'makeOrderStatus', component: () => import('../views/user/MakeOrderStatusView.vue'), meta: { requiresAuth: true } },
// Theme + reduced motion. Device preferences, so no requiresAuth.
{ path: '/settings/appearance', name: 'appearanceSettings', component: () => import('../views/user/AppearanceView.vue') },
// D21 — the sales expert's two screens. Reference paths and names kept
+14
View File
@@ -85,6 +85,9 @@
{{ cart.total() }}
<i>گرم</i>
</span>
<span v-if="cart.totalRial() > 0" class="cart__totalRial">
+ اجرت ریالی {{ price(cart.totalRial()) }} <i>ریال</i>
</span>
</div>
<button type="button" class="cart__submit" @click="cartData.notify = true">ثبت نهایی سفارش</button>
</div>
@@ -157,6 +160,7 @@ import PageBar from '../components/ui/PageBar.vue'
import Spinner from '../components/Spinner.vue'
import DialogVant from '../components/DialogVant.vue'
import CartItem from '../components/product/CartItem.vue'
import { price } from '../utils/filters'
import { useCartStore, type CartItem as CartItemType } from '../stores/cartStore'
import { useSessionStore } from '../stores/session'
import { useReveal } from '../composables/useReveal'
@@ -390,6 +394,16 @@ onMounted(async () => {
font-weight: 800;
color: @ink;
}
.cart__totalRial {
font-size: 11.5px;
font-weight: 700;
color: @inkSoft;
}
.cart__totalRial i {
font-style: normal;
font-weight: 600;
color: @inkFaint;
}
.cart__totalValue i {
font-style: normal;
font-size: 11px;
@@ -16,11 +16,15 @@
returns orders at that status (`where('sts', 5)`, ApiMobileController:766)
- the card is tappable, as the reference's was (:29)
Deliberately NOT ported: the reference's edit-order drawer behind that tap
(toEditOrder -> PUT /order/child/{id}) recalculated an order's weights and
prices from values the CLIENT sent. Bringing that across during a delivery
would put a money path in the app with no server-side recomputation; the tap
opens a read-only summary instead. Recorded in D21/DELIVERY.md.
The reference's edit-order drawer behind that tap (toEditOrder ->
PUT /order/child/{id}) is back, in a form that keeps the reason it was held
back: it used to recalculate an order's weights and prices from values the
CLIENT sent. Here the expert enters ONLY the real raw weight of each piece;
the server applies the making charge the order was placed at, recomputes the
totals and the customer's debt, and refuses anyone but the expert the order
belongs to (OrdersService.expertAdjust). The figures shown under each field
are a preview of that formula, not what is saved. «ثبت نهایی» hands the order
to the customer.
Scope is the server's: every row belongs to a customer this expert referred.
No id travels from the client.
@@ -133,6 +137,37 @@
<dd>تحویل مسئول فروش</dd>
</div>
</dl>
<!-- The real weight of each piece. Only the raw weight is sent; the making
charge, totals and debt are worked out by the server, and the figure
shown under each field is a preview of that. -->
<h3 v-if="drafts.length" class="cho__editTitle">وزن واقعی هر عدد (گرم)</h3>
<div v-for="d in drafts" :key="d.id" class="cho__line">
<div class="cho__lineHead">
<span class="cho__lineName">{{ d.title }}</span>
<span class="cho__lineQty" dir="rtl"><bdi>{{ d.qty }}</bdi> عدد · اجرت <bdi>{{ d.netPercent }}٪</bdi></span>
</div>
<InputNumber v-model:value="d.weight" class="cho__input" :min="0.001" :step="0.01" :precision="3" size="large" />
<div class="cho__lineSum" dir="ltr">
{{ grams(preview(d)) }} g
<span class="cho__muted">({{ d.qty }} × {{ grams(unitPreview(d)) }})</span>
</div>
</div>
<div v-if="drafts.length" class="cho__previewTotal">
<span>جمع وزن پس از ثبت</span>
<b dir="ltr">{{ grams(previewTotal) }} گرم</b>
</div>
<p v-if="saveError" class="cho__error">{{ saveError }}</p>
<div v-if="drafts.length" class="cho__actions">
<button type="button" class="cho__save" :disabled="saving" @click="save(false)">ثبت تغییرات</button>
<button type="button" class="cho__final" :disabled="saving" @click="confirmFinal = true">ثبت نهایی</button>
</div>
<p v-if="confirmFinal" class="cho__confirm">
آیا مطمئن به ثبت نهایی هستید؟ پس از آن سفارش تحویل مشتری می‌شود.
<button type="button" class="cho__final" :disabled="saving" @click="save(true)">بله، ثبت نهایی</button>
<button type="button" class="cho__link" @click="confirmFinal = false">بی‌خیال</button>
</p>
<button type="button" class="cho__close" @click="detailOpen = false">بستن</button>
</div>
</Drawer>
@@ -140,8 +175,8 @@
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { Drawer } from 'ant-design-vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { Drawer, InputNumber } from 'ant-design-vue'
import AppPullRefresh from '../../components/ui/AppPullRefresh.vue'
import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue'
@@ -212,11 +247,67 @@ async function onRefresh() {
}
}
/** One line of the order as the expert edits it: the raw weight is the only input. */
interface Draft {
id: string
title: string
qty: number
/** The percent the order was placed at (charge − promotion), kept as it is. */
netPercent: number
weight: number | undefined
}
const drafts = ref<Draft[]>([])
const saving = ref(false)
const saveError = ref('')
const confirmFinal = ref(false)
function openDetail(item: ExpertChildOrder) {
active.value = item
drafts.value = (item.items ?? []).map((i) => ({
id: i.id,
title: [i.productVariant?.product?.title, i.productVariant?.title].filter(Boolean).join(' — ') || 'محصول',
qty: i.quantity,
netPercent: Number(i.wagePercentSnapshot) - Number(i.discountPercentSnapshot),
weight: Number(i.rashWeightGramsSnapshot),
}))
saveError.value = ''
confirmFinal.value = false
detailOpen.value = true
}
/** rash × (1 + net/100) — the server's own formula, shown so the effect is visible before saving. */
const unitPreview = (d: Draft) => (d.weight ?? 0) * (1 + d.netPercent / 100)
const preview = (d: Draft) => unitPreview(d) * d.qty
const previewTotal = computed(() => drafts.value.reduce((s, d) => s + preview(d), 0))
async function save(finalSubmit: boolean) {
if (!active.value || saving.value) return
if (drafts.value.some((d) => !d.weight || d.weight <= 0)) {
saveError.value = 'وزن هر عدد باید بیشتر از صفر باشد.'
return
}
saving.value = true
saveError.value = ''
try {
await api.adjustExpertChildOrder(
active.value.id,
drafts.value.map((d) => ({ id: d.id, rash_weight: d.weight as number })),
finalSubmit,
)
detailOpen.value = false
await reload()
} catch (e) {
// The server's Persian reason (not found, wrong status, accounting busy, …) is in the body.
const text = e instanceof Error ? e.message : ''
const m = text.match(/"message":"([^"]+)"/)
saveError.value = m ? m[1] : 'ثبت تغییرات انجام نشد؛ دوباره تلاش کنید.'
} finally {
saving.value = false
confirmFinal.value = false
}
}
// Infinite scroll, the reference's behaviour (useInfiniteScroll at :216/:330).
const sentinel = ref<HTMLElement | null>(null)
let observer: IntersectionObserver | null = null
@@ -402,6 +493,109 @@ onBeforeUnmount(() => observer?.disconnect())
font-weight: 600;
}
.cho__editTitle {
margin: 18px 0 6px;
font-size: 13px;
font-weight: 800;
color: @ink;
}
.cho__line {
padding: 12px 0;
border-bottom: 1px solid @hairline;
}
.cho__lineHead {
display: flex;
justify-content: space-between;
gap: 10px;
margin-bottom: 8px;
font-size: 12.5px;
}
.cho__lineName {
font-weight: 700;
color: @ink;
}
.cho__lineQty {
color: @inkFaint;
white-space: nowrap;
}
.cho__input {
width: 100%;
}
.cho__lineSum {
margin-top: 6px;
font-size: 12px;
font-weight: 700;
color: @inkSoft;
}
.cho__previewTotal {
display: flex;
justify-content: space-between;
padding: 14px 0 4px;
font-size: 13px;
}
.cho__error {
margin: 10px 0 0;
font-size: 12.5px;
color: #c0392b;
}
.cho__actions {
display: flex;
gap: 10px;
margin-top: 14px;
}
.cho__save,
.cho__final {
flex: 1;
padding: 12px;
border: 1px solid @wine;
border-radius: 14px;
background: transparent;
color: @wine;
font-family: inherit;
font-size: 13.5px;
font-weight: 700;
cursor: pointer;
}
.cho__final {
background: @wine;
color: #fff;
}
.cho__save:disabled,
.cho__final:disabled {
opacity: 0.5;
cursor: default;
}
.cho__confirm {
display: flex;
flex-direction: column;
gap: 8px;
margin: 12px 0 0;
font-size: 12.5px;
color: @inkSoft;
}
.cho__link {
border: none;
background: transparent;
color: @inkFaint;
font-family: inherit;
font-size: 12.5px;
cursor: pointer;
}
.cho__close {
margin-top: 18px;
width: 100%;
@@ -22,10 +22,11 @@
expert keeps their own token and the client names the customer in a header,
which the server re-authorises on every request.
Still deliberately NOT ported (D21):
- The activate/deactivate toggle and the edit button, which posted to
`/customer/toggle-active` and the customer form. Those are admin-panel
operations here. The state is shown, read-only.
The activate/deactivate toggle and the edit button (the reference's
`/customer/toggle-active` and `/customer/update`) are back, scoped the way
the reference's update was and its toggle was not: the server only lets an
expert touch THEIR OWN customers, and the edit covers contact details only —
never the percent, the credit limit, the kind or the account switches.
Scope is the server's: `referredByAgentId` = this expert, from the token.
-->
@@ -129,14 +130,56 @@
<button type="button" class="cch__enter" :disabled="entering" @click="enterAccount(active)">
{{ entering ? 'در حال ورود…' : 'ورود به حساب این مشتری' }}
</button>
<div class="cch__actions">
<button type="button" class="cch__act" @click="startEdit(active)">ویرایش اطلاعات</button>
<button
type="button"
class="cch__act"
:class="{ 'is-danger': active.active === 1 }"
:disabled="toggling"
@click="confirmToggle = true"
>
{{ active.active === 1 ? 'غیرفعال کردن' : 'فعال کردن' }}
</button>
</div>
<p v-if="confirmToggle" class="cch__confirm">
{{ active.active === 1 ? 'کاربر غیرفعال شود؟' : 'کاربر فعال شود؟' }}
<button type="button" class="cch__act is-danger" :disabled="toggling" @click="toggleActive(active)">بله</button>
<button type="button" class="cch__link" @click="confirmToggle = false">بی‌خیال</button>
</p>
<button type="button" class="cch__close" @click="detailOpen = false">بستن</button>
</div>
</Drawer>
<!-- «ویرایش مشتری»: contact details only. -->
<Drawer v-model:visible="editOpen" placement="bottom" height="auto" :closable="false" :body-style="{ padding: '0' }">
<form class="cch__detail" @submit.prevent="saveEdit">
<h2 class="cch__detailTitle">ویرایش اطلاعات مشتری</h2>
<label class="cch__field"><span>نام</span><input v-model.trim="edit.fname" type="text" maxlength="100" /></label>
<label class="cch__field"><span>نام خانوادگی</span><input v-model.trim="edit.lname" type="text" maxlength="100" /></label>
<label class="cch__field">
<span>موبایل</span>
<input v-model.trim="edit.mobile" type="tel" dir="ltr" maxlength="11" inputmode="numeric" />
</label>
<label class="cch__field"><span>تلفن ثابت</span><input v-model.trim="edit.phone" type="tel" dir="ltr" maxlength="30" /></label>
<label class="cch__field"><span>نام فروشگاه</span><input v-model.trim="edit.store_name" type="text" maxlength="150" /></label>
<label class="cch__field"><span>نام پاساژ</span><input v-model.trim="edit.pasazh_name" type="text" maxlength="150" /></label>
<label class="cch__field">
<span>کد ملی</span>
<input v-model.trim="edit.national_code" type="text" dir="ltr" maxlength="10" inputmode="numeric" />
</label>
<label class="cch__field"><span>تاریخ تولد</span><input v-model="edit.birth_date" type="date" dir="ltr" /></label>
<label class="cch__field"><span>آدرس</span><textarea v-model.trim="edit.address" rows="2" maxlength="500" /></label>
<p v-if="editError" class="cch__error">{{ editError }}</p>
<button type="submit" class="cch__enter" :disabled="saving">{{ saving ? 'در حال ثبت…' : 'ثبت تغییرات' }}</button>
<button type="button" class="cch__close" @click="editOpen = false">بستن</button>
</form>
</Drawer>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Drawer } from 'ant-design-vue'
import AppPullRefresh from '../../components/ui/AppPullRefresh.vue'
@@ -228,6 +271,91 @@ async function enterAccount(item: ExpertCustomer) {
}
}
// ── «فعال/غیرفعال کردن» and «ویرایش» (reference customer/toggle-active, customer/update)
const toggling = ref(false)
const confirmToggle = ref(false)
async function toggleActive(item: ExpertCustomer) {
toggling.value = true
try {
const res = await api.toggleExpertCustomer(item.id)
item.active = res.active
const row = rows.value.find((r) => r.id === item.id)
if (row) row.active = res.active
toast.success(res.active === 1 ? 'کاربر فعال شد' : 'کاربر غیرفعال شد')
} catch {
toast.error('تغییر وضعیت انجام نشد؛ دوباره تلاش کنید.')
} finally {
toggling.value = false
confirmToggle.value = false
}
}
const editOpen = ref(false)
const saving = ref(false)
const editError = ref('')
const edit = reactive({
id: '',
fname: '',
lname: '',
mobile: '',
phone: '',
store_name: '',
pasazh_name: '',
national_code: '',
birth_date: '',
address: '',
})
function startEdit(item: ExpertCustomer) {
Object.assign(edit, {
id: item.id,
fname: item.fname ?? '',
lname: item.lname ?? '',
mobile: item.mobile ?? '',
phone: item.phone ?? '',
store_name: item.store_name ?? '',
pasazh_name: item.pasazh_name ?? '',
national_code: item.national_code ?? '',
birth_date: item.birth_date ?? '',
address: item.address ?? '',
})
editError.value = ''
detailOpen.value = false
editOpen.value = true
}
async function saveEdit() {
if (!edit.fname || !edit.lname) {
editError.value = 'نام و نام خانوادگی الزامی است.'
return
}
saving.value = true
editError.value = ''
try {
await api.updateExpertCustomer(edit.id, {
fname: edit.fname,
lname: edit.lname,
mobile: edit.mobile || undefined,
phone: edit.phone,
store_name: edit.store_name,
pasazh_name: edit.pasazh_name,
national_code: edit.national_code,
birth_date: edit.birth_date,
address: edit.address,
})
editOpen.value = false
toast.success('اطلاعات مشتری ثبت شد')
await load()
} catch (e) {
// The server's Persian reason (duplicate mobile, bad date, …) is in the body.
const m = (e instanceof Error ? e.message : '').match(/"message":"([^"]+)"/)
editError.value = m ? m[1] : 'ثبت تغییرات انجام نشد؛ دوباره تلاش کنید.'
} finally {
saving.value = false
}
}
onMounted(load)
</script>
@@ -392,6 +520,80 @@ onMounted(load)
}
/* Secondary now that "enter the account" is the primary action here. */
.cch__actions {
display: flex;
gap: 10px;
margin-top: 10px;
}
.cch__act {
flex: 1;
padding: 12px;
border: 1px solid @wine;
border-radius: 14px;
background: transparent;
color: @wine;
font-family: inherit;
font-size: 13.5px;
font-weight: 700;
cursor: pointer;
&.is-danger {
border-color: @red;
color: @red;
}
&:disabled {
opacity: 0.5;
cursor: default;
}
}
.cch__confirm {
display: flex;
align-items: center;
gap: 10px;
margin: 12px 0 0;
font-size: 13px;
color: @inkSoft;
}
.cch__link {
border: none;
background: transparent;
color: @inkFaint;
font-family: inherit;
font-size: 12.5px;
cursor: pointer;
}
.cch__field {
display: flex;
flex-direction: column;
gap: 5px;
padding: 8px 0;
font-size: 12.5px;
color: @inkSoft;
}
.cch__field input,
.cch__field textarea {
width: 100%;
padding: 10px 12px;
border: 1px solid @hairline;
border-radius: 12px;
background: transparent;
color: @ink;
font-family: inherit;
font-size: 14px;
}
.cch__error {
margin: 8px 0 0;
font-size: 12.5px;
color: @red;
}
.cch__close {
margin-top: 10px;
width: 100%;
@@ -198,6 +198,14 @@
<div class="text-center text-danger" @click="data.loginNotify = false">بیخیال</div>
</template>
</DialogVant>
<!-- «سفارش ساخت»: place a request, or see the ones already placed. -->
<Drawer v-model:visible="makeOrderChoice" placement="bottom" height="auto" :closable="false" :body-style="{ padding: '0' }">
<div class="dash__choice">
<button type="button" class="dash__choiceBtn" @click="chooseMakeOrder('makeOrderList')">ثبت سفارش</button>
<button type="button" class="dash__choiceBtn is-quiet" @click="chooseMakeOrder('makeOrderStatus')">مشاهده سفارش</button>
</div>
</Drawer>
</div>
</template>
@@ -205,6 +213,7 @@
import { brand } from '../../brand'
import { computed, onActivated, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Drawer } from 'ant-design-vue'
import AppPullRefresh from '../../components/ui/AppPullRefresh.vue'
import Icon from '../../components/icon/Icon.vue'
import SectionHead from '../../components/home/SectionHead.vue'
@@ -257,6 +266,8 @@ const BASE_TILES = [
// gated on hasMCode() (Dashboard.vue:179, :449). The target has no mcode, so
// every signed-in customer sees it and the server scopes it to their token.
{ id: 6, icon: 'vuesax-linear:chart-2', title: 'گزارش', tone: 'slate', disabled: false },
// «سفارش ساخت» (reference makeOrder): ask for a piece to be made.
{ id: 8, icon: 'vuesax-linear:3d-cube-scan', title: 'سفارش ساخت', tone: 'rose', disabled: false },
]
/**
@@ -297,6 +308,15 @@ function goToDetails(id: number) {
if (id === 4) router.push({ name: 'ChildOrder' })
if (id === 5) router.push({ name: 'customerChoice' })
if (id === 6) router.push({ name: 'report' })
if (id === 8) makeOrderChoice.value = true
}
// «سفارش ساخت»: the reference opened a two-button dialog — place a request, or
// see the ones already placed.
const makeOrderChoice = ref(false)
function chooseMakeOrder(name: 'makeOrderList' | 'makeOrderStatus') {
makeOrderChoice.value = false
router.push({ name })
}
async function logOut() {
@@ -756,4 +776,28 @@ onMounted(async () => {
color: @inkFaint;
opacity: 0.7;
}
.dash__choice {
display: flex;
flex-direction: column;
gap: 10px;
padding: 20px 16px 24px;
}
.dash__choiceBtn {
padding: 15px;
border: 1px solid @wine;
border-radius: 14px;
background: @wine;
color: #fff;
font-family: inherit;
font-size: 15px;
font-weight: 700;
cursor: pointer;
}
.dash__choiceBtn.is-quiet {
background: transparent;
color: @wine;
}
</style>
@@ -0,0 +1,237 @@
<!--
"وضعیت سفارش" — where each «سفارش ساخت» request stands.
Ported from henza_pwa/src/views/user/src/components/makeOrderStatus.vue
(route /makeOrder_status, name makeOrderStatus), restyled into this app's
design language.
Kept from the reference:
- the header title "وضعیت سفارش"
- one card per request, newest first, with the five-step tracker
ثبت سفارش → تایید ادمین → تایید کارگاه → ارسال → تحویل شده
- the facts it listed: رنگ محصول، سایز محصول، رنگ مینا، رنگ سنگ، تعداد، وضعیت،
تاریخ ثبت، ساعت ثبت, and the photo
Deliberately NOT ported: the «تایید سفارش» / «ارسال سفارش» buttons the reference
showed to workshop-type users. Both posted the same `sts: 2` and the server
checked neither who asked nor whose request it was. Moving a request along is
the admin screen's job («سفارشات ساختنی»).
-->
<template>
<div class="mks">
<PageBar title="وضعیت سفارش" back-to="userPage::hasSubRoute" />
<Spinner :spinning="loading && !rows.length" tip="درحال دریافت اطلاعات">
<AppPullRefresh v-model="refreshing" @refresh="onRefresh">
<container class="mks__body">
<article v-for="item in rows" :key="item.id" class="mks__card">
<ol v-if="item.step > 0" class="mks__track" :aria-label="item.sts_str_fa">
<li
v-for="(label, i) in STEPS"
:key="label"
:class="{ 'is-done': item.step > i + 1, 'is-now': item.step === i + 1 }"
>
<i aria-hidden="true" />
<span>{{ label }}</span>
</li>
</ol>
<p v-else class="mks__cancelled">این سفارش لغو شده است.</p>
<dl class="mks__facts">
<div><dt>رنگ محصول</dt><dd>{{ item.color || '—' }}</dd></div>
<div><dt>سایز محصول</dt><dd>{{ item.size || '—' }}</dd></div>
<div><dt>رنگ مینا</dt><dd>{{ item.color_mina || '—' }}</dd></div>
<div><dt>رنگ سنگ</dt><dd>{{ item.color_stone || '—' }}</dd></div>
<div><dt>تعداد</dt><dd>{{ item.sum }}</dd></div>
<div><dt>وضعیت</dt><dd>{{ item.sts_str_fa }}</dd></div>
<div><dt>تاریخ ثبت</dt><dd dir="ltr">{{ jdate(item.createdAt) }}</dd></div>
<div><dt>ساعت ثبت</dt><dd dir="ltr">{{ timeOf(item.createdAt) }}</dd></div>
</dl>
<p v-if="item.description" class="mks__note">{{ item.description }}</p>
<img v-if="item.image" class="mks__photo" :src="item.image" alt="" />
</article>
<section v-if="!rows.length && !loading && !failed" class="mks__empty">
<Icon icon="vuesax-linear:box" size="26" />
<p>هنوز سفارش ساختی ثبت نکرده‌اید.</p>
<button type="button" class="mks__cta" @click="router.push({ name: 'makeOrderList' })">ثبت سفارش</button>
</section>
<section v-if="failed" class="mks__empty">
<Icon icon="vuesax-linear:danger" size="26" />
<p>دریافت اطلاعات با خطا روبه‌رو شد.</p>
<button type="button" class="mks__cta" @click="load">تلاش دوباره</button>
</section>
</container>
</AppPullRefresh>
</Spinner>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import AppPullRefresh from '../../components/ui/AppPullRefresh.vue'
import Icon from '../../components/icon/Icon.vue'
import PageBar from '../../components/ui/PageBar.vue'
import Spinner from '../../components/Spinner.vue'
import { jdate } from '../../utils/filters'
import { api, type PreOrderRow } from '../../api/client'
const router = useRouter()
const STEPS = ['ثبت سفارش', 'تایید ادمین', 'تایید کارگاه', 'ارسال', 'تحویل شده']
const rows = ref<PreOrderRow[]>([])
const loading = ref(false)
const refreshing = ref(false)
const failed = ref(false)
/** HH:mm in Tehran time, as the reference showed the hour of registration. */
function timeOf(iso: string): string {
return new Date(iso).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', timeZone: 'Asia/Tehran' })
}
async function load() {
loading.value = true
failed.value = false
try {
rows.value = (await api.getMyPreOrders()).rows
} catch {
failed.value = true
} finally {
loading.value = false
}
}
async function onRefresh() {
try {
await load()
} finally {
refreshing.value = false
}
}
onMounted(load)
</script>
<style scoped lang="less">
.mks__body {
padding-top: 84px;
padding-bottom: 90px;
}
.mks__card {
padding: 18px 2px;
border-bottom: 1px solid @hairline;
}
.mks__track {
display: flex;
justify-content: space-between;
gap: 4px;
margin: 0 0 16px;
padding: 0;
list-style: none;
}
.mks__track li {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
text-align: center;
font-size: 10.5px;
color: @inkFaint;
}
.mks__track i {
display: block;
width: 100%;
height: 3px;
border-radius: 999px;
background: @hairline;
}
.mks__track li.is-done {
color: @inkSoft;
}
.mks__track li.is-done i {
background: @wine;
}
.mks__track li.is-now {
color: @wine;
font-weight: 800;
}
.mks__track li.is-now i {
background: @wine;
opacity: 0.55;
}
.mks__cancelled {
margin: 0 0 12px;
font-size: 12.5px;
font-weight: 700;
color: @red;
}
.mks__facts {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px 14px;
margin: 0;
}
.mks__facts div {
display: flex;
gap: 6px;
font-size: 12.5px;
}
.mks__facts dt {
color: @inkFaint;
}
.mks__facts dd {
margin: 0;
font-weight: 700;
color: @ink;
}
.mks__note {
margin: 12px 0 0;
font-size: 12.5px;
color: @inkSoft;
}
.mks__photo {
display: block;
width: 100%;
max-height: 260px;
margin-top: 12px;
border-radius: 14px;
object-fit: cover;
}
.mks__empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 48px 0;
color: @inkFaint;
text-align: center;
}
.mks__cta {
padding: 11px 22px;
border: none;
border-radius: 999px;
background: @wine;
color: #fff;
font-family: inherit;
font-size: 13.5px;
font-weight: 700;
cursor: pointer;
}
</style>
@@ -0,0 +1,197 @@
<!--
"سفارش ساخت" — ask for a piece to be made.
Ported from henza_pwa/src/views/user/src/components/makeOrder.vue (route
/makeOrder, name makeOrderList), restyled into this app's design language.
Kept from the reference:
- the header title "سفارش ساخت"
- the photo of the piece first, then the questions: رنگ محصول، سایز محصول،
رنگ مینا، رنگ سنگ، تعداد، توضیحات (the last one optional)
- every field but the description required, with the reference's own messages
- POST create-custom-order -> customer/me/pre-orders here; the customer is the
token's, nothing about WHO is sent
The reference then sent the customer home; this goes to «وضعیت سفارش» so the
request just placed is the first thing seen.
-->
<template>
<div class="mko">
<PageBar title="سفارش ساخت" back-to="userPage::hasSubRoute" />
<Spinner :spinning="submitting" tip="در حال ثبت اطلاعات">
<container class="mko__body">
<form class="mko__form" novalidate @submit.prevent="submit">
<SingleImageUploader
upload-text="بارگذاری عکس سفارش"
purpose="preorder-design"
@done="(id: string) => (form.file_id = id)"
/>
<p class="mko__lead">لطفا به سوالات زیر پاسخ دهید</p>
<label class="mko__field">
<span>رنگ محصول</span>
<input v-model.trim="form.color" type="text" maxlength="100" />
</label>
<label class="mko__field">
<span>سایز محصول</span>
<input v-model.trim="form.size" type="text" maxlength="100" />
</label>
<label class="mko__field">
<span>رنگ مینا</span>
<input v-model.trim="form.color_mina" type="text" maxlength="100" />
</label>
<label class="mko__field">
<span>رنگ سنگ</span>
<input v-model.trim="form.color_stone" type="text" maxlength="100" />
</label>
<label class="mko__field">
<span>تعداد</span>
<input v-model.number="form.sum" type="number" inputmode="numeric" min="1" step="1" />
</label>
<label class="mko__field">
<span>توضیحات</span>
<textarea v-model.trim="form.description" rows="3" maxlength="1000" />
</label>
<p v-if="error" class="mko__error">{{ error }}</p>
<button type="submit" class="mko__submit" :disabled="submitting">ارسال اطلاعات</button>
</form>
</container>
</Spinner>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { toast } from '../../composables/useToast'
import PageBar from '../../components/ui/PageBar.vue'
import Spinner from '../../components/Spinner.vue'
import SingleImageUploader from '../../components/upload/SingleImageUploader.vue'
import { api } from '../../api/client'
const router = useRouter()
const form = reactive({
file_id: null as string | null,
color: '',
size: '',
color_mina: '',
color_stone: '',
sum: 1,
description: '',
})
const submitting = ref(false)
const error = ref<string | null>(null)
/** A message about a missing field must not outlive the fix (e.g. the photo being uploaded). */
watch(form, () => (error.value = null))
/** The reference's required-field messages, in the order it asked. */
function firstMissing(): string | null {
if (!form.file_id) return 'لطفا تصویر سفارش را آپلود کنید'
if (!form.color) return 'لطفا رنگ محصول را وارد کنید'
if (!form.size) return 'لطفا سایز محصول را وارد کنید'
if (!form.color_mina) return 'لطفا رنگ مینا را وارد کنید'
if (!form.color_stone) return 'لطفا رنگ سنگ را وارد کنید'
if (!Number.isInteger(form.sum) || form.sum < 1) return 'لطفا تعداد را وارد کنید'
return null
}
async function submit() {
error.value = firstMissing()
if (error.value) return
submitting.value = true
try {
await api.createPreOrder({
color: form.color,
size: form.size,
color_mina: form.color_mina,
color_stone: form.color_stone,
sum: form.sum,
file_id: form.file_id,
description: form.description || undefined,
})
toast.success('سفارش با موفقیت ثبت شد')
router.replace({ name: 'makeOrderStatus' })
} catch {
error.value = 'خطا در ثبت سفارش'
} finally {
submitting.value = false
}
}
</script>
<style scoped lang="less">
.mko__body {
padding-top: 84px;
padding-bottom: 110px;
}
.mko__form {
display: flex;
flex-direction: column;
}
.mko__lead {
margin: 18px 2px 4px;
font-size: 13px;
font-weight: 700;
color: @inkMuted;
}
.mko__field {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px 0;
border-bottom: 1px solid @hairline;
font-size: 12.5px;
color: @inkSoft;
}
.mko__field input,
.mko__field textarea {
width: 100%;
padding: 11px 12px;
border: 1px solid @hairline;
border-radius: 12px;
background: transparent;
color: @ink;
font-family: inherit;
font-size: 14px;
}
.mko__error {
margin: 12px 2px 0;
font-size: 12.5px;
color: @red;
}
.mko__submit {
position: fixed;
left: 16px;
right: 16px;
bottom: 20px;
z-index: 20;
max-width: 736px;
margin: 0 auto;
padding: 15px;
border: none;
border-radius: 999px;
background: @wine;
color: #fff;
font-family: inherit;
font-size: 15px;
font-weight: 700;
cursor: pointer;
}
.mko__submit:disabled {
opacity: 0.6;
cursor: progress;
}
</style>
+5 -1
View File
@@ -122,6 +122,10 @@
<dt>وزن سفارش</dt>
<dd>{{ parseFloat(viewData.item.totalGoldWeightGrams).toFixed(2) }} گرم</dd>
</div>
<div v-if="parseFloat(viewData.item.flatRialCharge) > 0" class="detail__spec">
<dt>اجرت ریالی</dt>
<dd>{{ price(Math.round(parseFloat(viewData.item.flatRialCharge))) }} ریال</dd>
</div>
<div class="detail__spec">
<dt>تاریخ سفارش</dt>
<dd>{{ jdate(viewData.item.createdAt) }}</dd>
@@ -230,7 +234,7 @@ import {
orderStatusIcon,
orderStatusLabel,
} from '../../utils/orderStatus'
import { jdate, jtimelineWhen } from '../../utils/filters'
import { jdate, jtimelineWhen, price } from '../../utils/filters'
import { useSessionStore } from '../../stores/session'
import { api, type OrderDetail } from '../../api/client'
import { useQueryCache, KEYS } from '../../stores/queryCache'