test(api): B2B admin scan + write-path smoke, both green
scan-admin-b2b.cjs (34 ok, 0 broken) hits every read endpoint the wholesale desk uses daily. smoke-admin-b2b-writes.cjs (25/25) covers the money-touching writes: admin order placement and a cash payment document, asserting the ledger and the customer balance rather than the HTTP status. Verified: the making charge computes exactly, a document is created unconfirmed, confirming moves the gold debt, and confirming again toggles back and restores the balance to the byte. Retail/storefront surfaces were deliberately not scanned (D13). No B2B breakage found. Three first-draft failures were test bugs, corrected against the real callers: cities/list -> admin/cities/list, by-ids takes products_id, and document type 4 is cash (2 is a transfer). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
8b7da90f51
commit
6d0f5c5359
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* B2B admin scan (D19 step 2) — hits every read endpoint the wholesale side of
|
||||
* the admin panel uses on a normal working day, looking for things that are
|
||||
* OUTRIGHT BROKEN (500s, crashes, empty required shapes).
|
||||
*
|
||||
* Scope per the owner: wholesale customers, sales experts, products and
|
||||
* variants, wholesale orders, documents. Retail/storefront surfaces
|
||||
* (single*, wallet, comments) are D13 — preserved, not scanned.
|
||||
*
|
||||
* This is a breakage scan, not a parity audit: a 200 with plausible data
|
||||
* passes. Anything non-2xx is reported with its body so it can be triaged.
|
||||
*
|
||||
* Usage: node scripts/scan-admin-b2b.cjs [apiBaseUrl]
|
||||
*/
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const prisma = new PrismaClient();
|
||||
const API = (process.argv[2] || 'http://localhost:3000/api').replace(/\/$/, '');
|
||||
|
||||
const broken = [];
|
||||
const ok = [];
|
||||
let H = {};
|
||||
|
||||
async function probe(label, method, path, body) {
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`${API}/${path}`, {
|
||||
method,
|
||||
headers: H,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
} catch (e) {
|
||||
broken.push({ label, method, path, status: 'NETWORK', detail: String(e) });
|
||||
console.log(` ERR ${label} (${String(e)})`);
|
||||
return null;
|
||||
}
|
||||
|
||||
let payload = null;
|
||||
const text = await res.text();
|
||||
try {
|
||||
payload = JSON.parse(text);
|
||||
} catch {
|
||||
/* non-JSON */
|
||||
}
|
||||
|
||||
if (res.ok) {
|
||||
ok.push(label);
|
||||
const n = Array.isArray(payload)
|
||||
? payload.length
|
||||
: Array.isArray(payload?.rows)
|
||||
? payload.rows.length
|
||||
: null;
|
||||
console.log(` ok ${label}${n === null ? '' : ` (${n} rows)`}`);
|
||||
} else {
|
||||
const detail =
|
||||
(payload && (payload.message || payload.error)) || text.slice(0, 200);
|
||||
broken.push({ label, method, path, status: res.status, detail });
|
||||
console.log(` FAIL ${label} -> ${res.status} ${JSON.stringify(detail).slice(0, 160)}`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
const page = { page: 1, limit: 5 };
|
||||
|
||||
(async () => {
|
||||
const login = await fetch(`${API}/admin/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ phone: '09128081392', password: '09128081392' }),
|
||||
});
|
||||
const auth = await login.json();
|
||||
if (!auth.api_token) {
|
||||
console.log('login failed');
|
||||
process.exit(1);
|
||||
}
|
||||
H = { 'Content-Type': 'application/json', Authorization: 'Bearer ' + auth.api_token };
|
||||
|
||||
console.log('\n── WHOLESALE CUSTOMERS ──────────────────────────────');
|
||||
const omde = await probe('customers: omde list', 'POST', 'admin/customers-omde/list', page);
|
||||
await probe('customers: banker list', 'POST', 'admin/customers-omde/banker/list', page);
|
||||
await probe('customers: shopkeeper list', 'POST', 'admin/customers-omde/shopkeeper/list', page);
|
||||
await probe('customers: non-verified list', 'POST', 'admin/customers-omde/non-verified/list', page);
|
||||
await probe('customers: archived list', 'POST', 'admin/customers-omde/archived/list', page);
|
||||
await probe('customers: referrer dropdown', 'GET', 'admin/customers-omde/query/reagent');
|
||||
|
||||
const aCustomer = omde?.rows?.[0];
|
||||
if (aCustomer) {
|
||||
await probe('customer: detail', 'GET', `admin/customers-omde/${aCustomer.id}`);
|
||||
await probe('customer: dashboard', 'GET', `admin/customers-omde/dashboard/${aCustomer.id}`);
|
||||
await probe('customer: orders tab', 'GET', `admin/customers-omde/${aCustomer.id}/orders`);
|
||||
await probe('customer: documents tab', 'GET', `admin/customers-omde/${aCustomer.id}/documents`);
|
||||
}
|
||||
|
||||
console.log('\n── SALES EXPERTS ────────────────────────────────────');
|
||||
const reagents = await probe('experts: list', 'POST', 'admin/customers-omde/reagent/list', page);
|
||||
const anExpert = reagents?.rows?.[0];
|
||||
if (anExpert) {
|
||||
await probe('expert: detail', 'GET', `admin/customers-omde/reagent/${anExpert.id}`);
|
||||
await probe('expert: as admin user', 'GET', `admin/users/${anExpert.id}`);
|
||||
}
|
||||
|
||||
console.log('\n── PRODUCTS & VARIANTS ──────────────────────────────');
|
||||
const prods = await probe('products: list', 'POST', 'admin/products/list', page);
|
||||
await probe('products: variants list', 'POST', 'admin/products/variants/list', page);
|
||||
await probe('products: brands', 'GET', 'admin/products/brands');
|
||||
await probe('products: brands table', 'POST', 'admin/products/brands-table', page);
|
||||
await probe('products: collections', 'GET', 'admin/products/collections');
|
||||
await probe('products: show-type options', 'GET', 'admin/products/show-type-options');
|
||||
const aProduct = prods?.rows?.[0];
|
||||
if (aProduct) {
|
||||
await probe('product: detail (edit form)', 'GET', `admin/products/${aProduct.id}`);
|
||||
await probe('product: its variants', 'GET', `admin/products/${aProduct.id}/variants`);
|
||||
// The order builder posts `products_id` (stores/cart.ts:102), not `ids`.
|
||||
const byIds = await probe('product: by-ids', 'POST', 'admin/products/by-ids', {
|
||||
products_id: [aProduct.id],
|
||||
});
|
||||
if (byIds && Array.isArray(byIds) && byIds.length === 0) {
|
||||
broken.push({
|
||||
label: 'product: by-ids returned nothing for a known id',
|
||||
method: 'POST',
|
||||
path: 'admin/products/by-ids',
|
||||
status: '200-empty',
|
||||
detail: `asked for ${aProduct.id}, got []`,
|
||||
});
|
||||
console.log(' FAIL product: by-ids returned [] for a product that exists');
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n── WHOLESALE ORDERS ─────────────────────────────────');
|
||||
const wOrders = await probe('orders: wholesale list', 'POST', 'admin/orders/wholesale/list', page);
|
||||
await probe('orders: tamin list', 'POST', 'admin/orders/tamin/list', page);
|
||||
await probe('orders: status options', 'GET', 'admin/orders/status-options');
|
||||
await probe('orders: type options', 'GET', 'admin/orders/type-options');
|
||||
const anOrder = wOrders?.rows?.[0];
|
||||
if (anOrder) {
|
||||
await probe('order: detail', 'GET', `admin/orders/${anOrder.id}`);
|
||||
}
|
||||
|
||||
console.log('\n── DOCUMENTS ────────────────────────────────────────');
|
||||
const docs = await probe('documents: list', 'POST', 'admin/documents/list', page);
|
||||
const aDoc = docs?.rows?.[0];
|
||||
if (aDoc) await probe('document: detail', 'GET', `admin/documents/${aDoc.id}`);
|
||||
|
||||
console.log('\n── SUPPORTING LOOKUPS THE B2B FORMS NEED ────────────');
|
||||
await probe('cities: list', 'POST', 'admin/cities/list', { page: 1, limit: 500 });
|
||||
await probe('branches: list', 'POST', 'admin/branches/list', page);
|
||||
await probe('workshops: list', 'POST', 'admin/workshops/list', page);
|
||||
await probe('pre-orders: list', 'POST', 'admin/pre-orders/list', page);
|
||||
await probe('users: list', 'POST', 'admin/users/list', page);
|
||||
|
||||
console.log(`\n${'='.repeat(62)}`);
|
||||
console.log(`B2B SCAN: ${ok.length} ok, ${broken.length} broken`);
|
||||
if (broken.length) {
|
||||
console.log('\nBROKEN:');
|
||||
for (const b of broken) {
|
||||
console.log(` - [${b.status}] ${b.label}`);
|
||||
console.log(` ${b.method} ${b.path}`);
|
||||
console.log(` ${JSON.stringify(b.detail).slice(0, 300)}`);
|
||||
}
|
||||
}
|
||||
console.log('='.repeat(62));
|
||||
|
||||
await prisma.$disconnect();
|
||||
process.exit(broken.length ? 1 : 0);
|
||||
})().catch(async (e) => {
|
||||
console.error(e);
|
||||
await prisma.$disconnect();
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* B2B admin WRITE paths (D19 step 2) — the money-touching operations the
|
||||
* wholesale desk performs daily, which a read-only scan cannot cover:
|
||||
*
|
||||
* place an order for a customer from the admin panel
|
||||
* record a payment document (cash) and confirm it
|
||||
*
|
||||
* Both move a customer's gold/rial debt, so each assertion checks the LEDGER,
|
||||
* not just the HTTP status. Everything created here is removed again, and the
|
||||
* customer's balances are compared before and after to prove nothing was left
|
||||
* behind.
|
||||
*
|
||||
* Usage: node scripts/smoke-admin-b2b-writes.cjs [apiBaseUrl]
|
||||
*/
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const prisma = new PrismaClient();
|
||||
const API = (process.argv[2] || 'http://localhost:3000/api').replace(/\/$/, '');
|
||||
|
||||
let pass = 0;
|
||||
let fail = 0;
|
||||
const failures = [];
|
||||
const chk = (c, m) => {
|
||||
if (c) {
|
||||
pass++;
|
||||
console.log(' PASS ' + m);
|
||||
} else {
|
||||
fail++;
|
||||
failures.push(m);
|
||||
console.log(' FAIL ' + m);
|
||||
}
|
||||
};
|
||||
const step = (t) => console.log('\n' + t);
|
||||
|
||||
let H = {};
|
||||
const call = async (method, p, body) => {
|
||||
const res = await fetch(`${API}/${p}`, {
|
||||
method,
|
||||
headers: H,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
const text = await res.text();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = JSON.parse(text);
|
||||
} catch {
|
||||
/* empty */
|
||||
}
|
||||
return { status: res.status, ok: res.ok, body: payload, raw: text };
|
||||
};
|
||||
|
||||
const createdOrders = [];
|
||||
const createdEntries = [];
|
||||
|
||||
(async () => {
|
||||
const login = await fetch(`${API}/admin/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ phone: '09128081392', password: '09128081392' }),
|
||||
});
|
||||
const auth = await login.json();
|
||||
if (!auth.api_token) {
|
||||
console.log('login failed');
|
||||
process.exit(1);
|
||||
}
|
||||
H = { 'Content-Type': 'application/json', Authorization: 'Bearer ' + auth.api_token };
|
||||
|
||||
// A WHOLESALE customer — retail is out of scope (D13).
|
||||
const customer = await prisma.customer.findFirst({
|
||||
where: { kind: { not: 'RETAIL' }, isActive: true, isArchived: false },
|
||||
select: { id: true, fullName: true, goldDebtGrams: true, fiatDebtRial: true },
|
||||
});
|
||||
if (!customer) {
|
||||
console.log('no wholesale customer available');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`Customer under test: ${customer.fullName}`);
|
||||
console.log(` gold debt before: ${customer.goldDebtGrams} rial debt before: ${customer.fiatDebtRial}`);
|
||||
|
||||
const variant = await prisma.productVariant.findFirst({
|
||||
where: { OR: [{ qty: { gt: 0 } }, { isPreOrder: true }] },
|
||||
select: { id: true, title: true, rashWeightGrams: true, defaultWagePercent: true },
|
||||
});
|
||||
if (!variant) {
|
||||
console.log('no orderable variant available');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// ── 1. ADMIN PLACES AN ORDER ────────────────────────────────────────────
|
||||
step('1 — PLACE A WHOLESALE ORDER FROM THE ADMIN PANEL');
|
||||
const wage = Number(variant.defaultWagePercent);
|
||||
const order = await call('POST', 'admin/orders/create', {
|
||||
customer_id: customer.id,
|
||||
order_items: [{ vid: variant.id, qty: 2, percent: wage }],
|
||||
note: 'سفارش آزمایشی تحویل',
|
||||
});
|
||||
chk(order.ok, `POST admin/orders/create -> ${order.status}` + (order.ok ? '' : ` ${order.raw.slice(0, 200)}`));
|
||||
|
||||
const orderId = order.body?.id ?? order.body?.order?.id;
|
||||
if (orderId) createdOrders.push(orderId);
|
||||
chk(Boolean(orderId), 'an order id came back');
|
||||
|
||||
if (orderId) {
|
||||
const row = await prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
include: { items: true },
|
||||
});
|
||||
chk(row?.customerId === customer.id, 'the order is attached to the chosen customer');
|
||||
chk(row?.items.length === 1, 'the line item was saved');
|
||||
chk(row?.items[0]?.quantity === 2, 'the quantity (2) was saved');
|
||||
|
||||
// The reference formula, per unit: rash * (1 + (wage - discount)/100)
|
||||
const rash = Number(variant.rashWeightGrams);
|
||||
const expectedPerUnit = rash * (1 + wage / 100);
|
||||
const actualPerUnit = Number(row?.items[0]?.computedWeightGrams ?? 0);
|
||||
chk(
|
||||
Math.abs(actualPerUnit - expectedPerUnit) < 0.00005,
|
||||
`making charge per unit: ${rash}g +${wage}% = ${expectedPerUnit.toFixed(4)} (got ${actualPerUnit})`,
|
||||
);
|
||||
chk(
|
||||
Math.abs(Number(row?.totalGoldWeightGrams ?? 0) - expectedPerUnit * 2) < 0.00005,
|
||||
`the order total is the per-unit weight x2 (${(expectedPerUnit * 2).toFixed(4)})`,
|
||||
);
|
||||
|
||||
step('1b — the order appears in the wholesale grid');
|
||||
const grid = await call('POST', 'admin/orders/wholesale/list', { page: 1, limit: 50 });
|
||||
chk(grid.ok, `POST admin/orders/wholesale/list -> ${grid.status}`);
|
||||
chk(
|
||||
(grid.body?.rows ?? []).some((r) => r.id === orderId),
|
||||
'the new order is listed',
|
||||
);
|
||||
|
||||
step('1c — its detail opens');
|
||||
const detail = await call('GET', `admin/orders/${orderId}`);
|
||||
chk(detail.ok, `GET admin/orders/:id -> ${detail.status}`);
|
||||
chk(Array.isArray(detail.body?.activity), 'the status timeline came back');
|
||||
}
|
||||
|
||||
// ── 2. RECORD A PAYMENT DOCUMENT ────────────────────────────────────────
|
||||
step('2 — RECORD A CASH PAYMENT DOCUMENT');
|
||||
const before = await prisma.customer.findUnique({
|
||||
where: { id: customer.id },
|
||||
select: { goldDebtGrams: true, fiatDebtRial: true },
|
||||
});
|
||||
|
||||
// type 4 = «پولی» (cash), which takes `price` + `price_per_gram`
|
||||
// (addFinDoc.vue:33,104-115). Type 2 is «حواله» and takes a weight in
|
||||
// `value` — sending cash fields with type 2 yields a zero amount and a 400.
|
||||
const doc = await call('POST', 'admin/documents', {
|
||||
type: 4,
|
||||
customer_id: customer.id,
|
||||
price: 10000000,
|
||||
price_per_gram: 50000000,
|
||||
note: 'پرداخت آزمایشی تحویل',
|
||||
});
|
||||
chk(doc.ok, `POST admin/documents -> ${doc.status}` + (doc.ok ? '' : ` ${doc.raw.slice(0, 200)}`));
|
||||
const docId = doc.body?.id;
|
||||
if (docId) createdEntries.push(docId);
|
||||
chk(Boolean(docId), 'a document id came back');
|
||||
|
||||
if (docId) {
|
||||
const entry = await prisma.ledgerEntry.findUnique({ where: { id: docId } });
|
||||
chk(Boolean(entry), 'a ledger entry was written');
|
||||
chk(entry?.customerId === customer.id, 'it is attached to the right customer');
|
||||
chk(entry?.confirmed === false, 'it starts UNCONFIRMED, as the reference does');
|
||||
|
||||
step('2b — it appears in the documents grid');
|
||||
const dlist = await call('POST', 'admin/documents/list', { page: 1, limit: 50 });
|
||||
chk(dlist.ok, `POST admin/documents/list -> ${dlist.status}`);
|
||||
chk((dlist.body?.rows ?? []).some((r) => r.id === docId), 'the new document is listed');
|
||||
|
||||
step('2c — CONFIRM it, and the customer balance moves');
|
||||
const conf = await call('POST', `admin/documents/${docId}/confirm`);
|
||||
chk(conf.ok, `POST admin/documents/:id/confirm -> ${conf.status}`);
|
||||
const afterConfirm = await prisma.ledgerEntry.findUnique({ where: { id: docId } });
|
||||
chk(afterConfirm?.confirmed === true, 'the entry is now confirmed');
|
||||
|
||||
const balAfter = await prisma.customer.findUnique({
|
||||
where: { id: customer.id },
|
||||
select: { goldDebtGrams: true, fiatDebtRial: true },
|
||||
});
|
||||
const moved =
|
||||
String(balAfter?.goldDebtGrams) !== String(before?.goldDebtGrams) ||
|
||||
String(balAfter?.fiatDebtRial) !== String(before?.fiatDebtRial);
|
||||
chk(moved, `confirming moved the balance (gold ${before?.goldDebtGrams} -> ${balAfter?.goldDebtGrams})`);
|
||||
|
||||
step('2d — confirm is a TOGGLE, as the reference has it');
|
||||
const unconf = await call('POST', `admin/documents/${docId}/confirm`);
|
||||
chk(unconf.ok, `second confirm -> ${unconf.status}`);
|
||||
const toggled = await prisma.ledgerEntry.findUnique({ where: { id: docId } });
|
||||
chk(toggled?.confirmed === false, 'it toggled back to unconfirmed');
|
||||
|
||||
const balBack = await prisma.customer.findUnique({
|
||||
where: { id: customer.id },
|
||||
select: { goldDebtGrams: true, fiatDebtRial: true },
|
||||
});
|
||||
chk(
|
||||
String(balBack?.goldDebtGrams) === String(before?.goldDebtGrams) &&
|
||||
String(balBack?.fiatDebtRial) === String(before?.fiatDebtRial),
|
||||
'un-confirming restored the balance exactly',
|
||||
);
|
||||
}
|
||||
|
||||
// ── CLEANUP ─────────────────────────────────────────────────────────────
|
||||
step('CLEANUP');
|
||||
if (createdEntries.length) {
|
||||
await prisma.ledgerEntry.deleteMany({ where: { id: { in: createdEntries } } });
|
||||
}
|
||||
if (createdOrders.length) {
|
||||
await prisma.orderItem.deleteMany({ where: { orderId: { in: createdOrders } } });
|
||||
await prisma.orderStatusEvent.deleteMany({ where: { orderId: { in: createdOrders } } });
|
||||
await prisma.ledgerEntry.deleteMany({ where: { orderId: { in: createdOrders } } });
|
||||
await prisma.order.deleteMany({ where: { id: { in: createdOrders } } });
|
||||
}
|
||||
|
||||
const finalBal = await prisma.customer.findUnique({
|
||||
where: { id: customer.id },
|
||||
select: { goldDebtGrams: true, fiatDebtRial: true },
|
||||
});
|
||||
chk(
|
||||
String(finalBal?.goldDebtGrams) === String(customer.goldDebtGrams) &&
|
||||
String(finalBal?.fiatDebtRial) === String(customer.fiatDebtRial),
|
||||
`the customer's balances are exactly as they started ` +
|
||||
`(gold ${finalBal?.goldDebtGrams}, rial ${finalBal?.fiatDebtRial})`,
|
||||
);
|
||||
console.log(
|
||||
` deleted ${createdOrders.length} order(s) and ${createdEntries.length} ledger entry/entries ` +
|
||||
'created by this run. No pre-existing data was removed.',
|
||||
);
|
||||
|
||||
console.log(`\n${'='.repeat(60)}`);
|
||||
console.log(`B2B WRITE SMOKE: passed ${pass} failed ${fail}`);
|
||||
if (fail) {
|
||||
console.log('\nFailures:');
|
||||
failures.forEach((f) => console.log(' - ' + f));
|
||||
}
|
||||
console.log('='.repeat(60));
|
||||
|
||||
await prisma.$disconnect();
|
||||
process.exit(fail ? 1 : 0);
|
||||
})().catch(async (e) => {
|
||||
console.error('\nABORTED:', e);
|
||||
await prisma.$disconnect();
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -692,3 +692,40 @@ page that is actually being demonstrated. Listed under known issues.
|
||||
|
||||
**Data touched:** the script creates one order and deletes it again, with its
|
||||
items, status events and ledger entries. No pre-existing data is modified.
|
||||
|
||||
## 2026-09-23 — B2B admin scan (D19 step 2)
|
||||
|
||||
Two new scripts, scoped to the B2B surfaces the owner named. Retail/storefront
|
||||
(`single*`, wallet, comments) was **not** scanned — D13, preserved untouched.
|
||||
|
||||
**`scripts/scan-admin-b2b.cjs` — 34 ok, 0 broken.** Every read endpoint the
|
||||
wholesale desk opens daily: the five customer grids, the referrer dropdown,
|
||||
customer detail/dashboard/orders/documents, the sales-expert list and detail,
|
||||
products, variants, brands, collections, product detail and its variants,
|
||||
wholesale and tamin order grids, order detail, the documents grid and detail,
|
||||
and the supporting lookups (cities, branches, workshops, pre-orders, users).
|
||||
|
||||
**`scripts/smoke-admin-b2b-writes.cjs` — 25/25.** The money-touching writes a
|
||||
read-only scan cannot cover:
|
||||
|
||||
- Placing a wholesale order from the admin panel: the making charge computes
|
||||
exactly (`8.133g +2% = 8.2957` per unit, ×2 = `16.5913` total), the order
|
||||
lands on the right customer and appears in the grid with a status timeline.
|
||||
- Recording a cash payment document: the reference's formula
|
||||
`price / (price_per_gram / 4.331802)` gives 0.8664g, the entry is created
|
||||
**unconfirmed** (the intentional deviation from legacy's auto-confirm),
|
||||
confirming moves the customer's gold debt 8.25 → 7.3836, and confirming again
|
||||
**toggles it back** and restores the balance exactly.
|
||||
|
||||
No B2B breakage was found. Three failures in the first drafts were test bugs,
|
||||
not product bugs, each corrected after checking the real caller:
|
||||
|
||||
| Wrong assumption | What the admin panel actually sends |
|
||||
|---|---|
|
||||
| `POST cities/list` | `admin/cities/list` (endpoints.ts:152) |
|
||||
| `by-ids` takes `ids` | `products_id` (stores/cart.ts:102) |
|
||||
| document `type: 2` is cash | `type: 4` is «پولی»; 2 is «حواله» and takes a weight in `value` (addFinDoc.vue:33) |
|
||||
|
||||
**Data touched:** both scripts delete only what they create (one order with its
|
||||
items/status events/ledger entries; one ledger entry). The write script also
|
||||
asserts the customer's gold and rial balances end exactly as they started.
|
||||
|
||||
Reference in New Issue
Block a user