diff --git a/.env.example b/.env.example index 96d0c9e..67fd6c7 100644 --- a/.env.example +++ b/.env.example @@ -166,7 +166,7 @@ ORDER_ADMIN_MOBILES= # --------------------------------------------------------------------------- # Brand # --------------------------------------------------------------------------- -# The name at the top of every SMS («زرتاج\n…») and the site link in the +# The name at the top of every SMS («تهران تاج\n…») and the site link in the # welcome SMS. Blank = the defaults in apps/api/src/common/brand.ts. The # frontends take their brand from the repo-root brand.ts instead; the full # rebrand checklist is docs/BRANDING.md. diff --git a/apps/api/prisma/migrations/20261017120000_app_orders_wholesale_channel/migration.sql b/apps/api/prisma/migrations/20261017120000_app_orders_wholesale_channel/migration.sql new file mode 100644 index 0000000..818c245 --- /dev/null +++ b/apps/api/prisma/migrations/20261017120000_app_orders_wholesale_channel/migration.sql @@ -0,0 +1,14 @@ +-- App (B2B) orders were stored as RETAIL because createOrder defaulted +-- channel to RETAIL and the apps never sent one. Legacy's mobile setOrder +-- wrote to the wholesale `orders` table. +-- +-- Flip mis-filed rows: any RETAIL order whose customer is NOT a تک +-- (CustomerKind.RETAIL) belongs on the omde list. Genuine retail-list +-- orders for CustomerKind.RETAIL stay RETAIL. + +UPDATE "Order" AS o +SET channel = 'WHOLESALE' +FROM "Customer" AS c +WHERE o."customerId" = c.id + AND o.channel = 'RETAIL' + AND c.kind <> 'RETAIL'; diff --git a/apps/api/prisma/migrations/20261017140000_order_number_sequence/migration.sql b/apps/api/prisma/migrations/20261017140000_order_number_sequence/migration.sql new file mode 100644 index 0000000..8f59ac1 --- /dev/null +++ b/apps/api/prisma/migrations/20261017140000_order_number_sequence/migration.sql @@ -0,0 +1,31 @@ +-- Short global customer-facing order numbers (SMS / app), replacing +-- ORD-{timestamp}-{random}. Pure digits, starting at 100. +-- Existing ORD-* / MOCK-ORD-* rows stay as-is; the sequence only advances +-- past any already-numeric orderNumber so new allocations never collide. + +DROP SEQUENCE IF EXISTS order_number_seq; + +CREATE SEQUENCE order_number_seq + AS BIGINT + INCREMENT BY 1 + MINVALUE 1 + START WITH 100 + NO CYCLE; + +DO $$ +DECLARE + max_numeric bigint; +BEGIN + SELECT MAX("orderNumber"::bigint) + INTO max_numeric + FROM "Order" + WHERE "orderNumber" ~ '^[0-9]+$'; + + IF max_numeric IS NULL THEN + -- next nextval() returns 100 + PERFORM setval('order_number_seq', 100, false); + ELSE + -- next nextval() returns max_numeric + 1 + PERFORM setval('order_number_seq', max_numeric, true); + END IF; +END $$; diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index 6695e2f..98afa09 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -389,8 +389,8 @@ async function main() { await prisma.brand.updateMany({ where: { id: 'brand-henza' }, data: { id: 'brand-montzar' } }); const brandMain = await prisma.brand.upsert({ where: { id: 'brand-montzar' }, - update: { title: 'زرتاج طلا' }, - create: { id: 'brand-montzar', title: 'زرتاج طلا' }, + update: { title: 'تهران تاج طلا' }, + create: { id: 'brand-montzar', title: 'تهران تاج طلا' }, }); const brandArtisan = await prisma.brand.upsert({ where: { id: 'brand-artisan' }, diff --git a/apps/api/scripts/check-accounting-config.ts b/apps/api/scripts/check-accounting-config.ts new file mode 100644 index 0000000..d35b12e --- /dev/null +++ b/apps/api/scripts/check-accounting-config.ts @@ -0,0 +1,16 @@ +import { PrismaClient } from '@prisma/client' + +const prisma = new PrismaClient() + +async function main() { + const row = await prisma.setting.findUnique({ where: { key: 'accounting.config' } }) + console.log('accounting.config:', row ? JSON.stringify(row.value, null, 2) : 'MISSING') + const links = await prisma.accountingLink.count({ where: { entityType: 'PRODUCT' } }) + console.log('PRODUCT accounting links:', links) + await prisma.$disconnect() +} + +main().catch((e) => { + console.error(e) + process.exit(1) +}) diff --git a/apps/api/scripts/check-order-seq.ts b/apps/api/scripts/check-order-seq.ts new file mode 100644 index 0000000..caad250 --- /dev/null +++ b/apps/api/scripts/check-order-seq.ts @@ -0,0 +1,15 @@ +import { PrismaClient } from '@prisma/client' + +const prisma = new PrismaClient() + +async function main() { + const a = await prisma.$queryRaw>`SELECT nextval('order_number_seq') AS n` + const b = await prisma.$queryRaw>`SELECT nextval('order_number_seq') AS n` + console.log(JSON.stringify({ first: String(a[0]!.n), second: String(b[0]!.n) })) + await prisma.$disconnect() +} + +main().catch((e) => { + console.error(e) + process.exit(1) +}) diff --git a/apps/api/scripts/count-mock-orders.ts b/apps/api/scripts/count-mock-orders.ts new file mode 100644 index 0000000..baa6eab --- /dev/null +++ b/apps/api/scripts/count-mock-orders.ts @@ -0,0 +1,27 @@ +import { PrismaClient } from '@prisma/client' + +const prisma = new PrismaClient() + +async function main() { + const counts = await prisma.order.groupBy({ + by: ['status'], + _count: { _all: true }, + where: { orderNumber: { startsWith: 'MOCK-ORD' } }, + }) + const total = counts.reduce((s, c) => s + c._count._all, 0) + const sample = await prisma.order.findFirst({ + where: { orderNumber: { startsWith: 'MOCK-ORD' } }, + select: { + orderNumber: true, + status: true, + customer: { select: { mobile: true, fullName: true } }, + }, + }) + console.log(JSON.stringify({ total, byStatus: Object.fromEntries(counts.map((c) => [c.status, c._count._all])), sample }, null, 2)) + await prisma.$disconnect() +} + +main().catch((e) => { + console.error(e) + process.exit(1) +}) diff --git a/apps/api/scripts/legacy-import/index.ts b/apps/api/scripts/legacy-import/index.ts index fc44225..fa6a09d 100644 --- a/apps/api/scripts/legacy-import/index.ts +++ b/apps/api/scripts/legacy-import/index.ts @@ -1,5 +1,5 @@ /** - * Legacy system (MySQL `oapi`) → zartaj (PostgreSQL) data import. + * Legacy system (MySQL `oapi`) → tehran-taj (PostgreSQL) data import. * * The rules live in docs/DATA-MIGRATION-MAP.md; every step cites its section. * diff --git a/apps/api/scripts/legacy-import/lib/context.ts b/apps/api/scripts/legacy-import/lib/context.ts index 858cc63..d907bf5 100644 --- a/apps/api/scripts/legacy-import/lib/context.ts +++ b/apps/api/scripts/legacy-import/lib/context.ts @@ -56,7 +56,7 @@ export class Report { } render(header: string[]): string { - const out: string[] = ['# legacy (oapi) → zartaj import report', '', ...header, '']; + const out: string[] = ['# legacy (oapi) → tehran-taj import report', '', ...header, '']; for (const [step, s] of this.sections) { out.push(`## ${step}`, ''); if (s.counts.size) { diff --git a/apps/api/scripts/reassign-mock-orders.ts b/apps/api/scripts/reassign-mock-orders.ts new file mode 100644 index 0000000..0945edf --- /dev/null +++ b/apps/api/scripts/reassign-mock-orders.ts @@ -0,0 +1,53 @@ +/** + * Move all MOCK-ORD-* orders to a target customer mobile. + * Run: npx tsx scripts/reassign-mock-orders.ts + */ +import { PrismaClient } from '@prisma/client' + +const prisma = new PrismaClient() +const TARGET_MOBILE = process.env.MOCK_ORDERS_MOBILE?.trim() || '09382507041' +const TAG = 'MOCK-ORD' + +async function main() { + const customer = await prisma.customer.findUnique({ + where: { mobile: TARGET_MOBILE }, + select: { id: true, mobile: true, fullName: true }, + }) + if (!customer) { + throw new Error(`Customer not found for mobile=${TARGET_MOBILE}`) + } + + const before = await prisma.order.count({ where: { orderNumber: { startsWith: TAG } } }) + const result = await prisma.order.updateMany({ + where: { orderNumber: { startsWith: TAG } }, + data: { customerId: customer.id }, + }) + + const byStatus = await prisma.order.groupBy({ + by: ['status'], + _count: { _all: true }, + where: { orderNumber: { startsWith: TAG }, customerId: customer.id }, + }) + + console.log( + JSON.stringify( + { + target: { mobile: customer.mobile, fullName: customer.fullName }, + matched: before, + updated: result.count, + byStatus: Object.fromEntries(byStatus.map((c) => [c.status, c._count._all])), + }, + null, + 2, + ), + ) +} + +main() + .catch((e) => { + console.error(e) + process.exit(1) + }) + .finally(async () => { + await prisma.$disconnect() + }) diff --git a/apps/api/scripts/seed-mock-orders.ts b/apps/api/scripts/seed-mock-orders.ts new file mode 100644 index 0000000..7c4485b --- /dev/null +++ b/apps/api/scripts/seed-mock-orders.ts @@ -0,0 +1,199 @@ +/** + * Dev helper: seed >15 mock orders per OrderStatus so the Orders UI can be inspected. + * Run: npx tsx scripts/seed-mock-orders.ts + */ +import { + OrderChannel, + OrderStatus, + Prisma, + PrismaClient, +} from '@prisma/client' + +const prisma = new PrismaClient() + +const STATUSES: OrderStatus[] = [ + OrderStatus.SUSPENDED, + OrderStatus.PLACED, + OrderStatus.ADMIN_APPROVED, + OrderStatus.SOURCING, + OrderStatus.PREPARING, + OrderStatus.DELIVERED_TO_AGENT, + OrderStatus.DELIVERED_TO_CUSTOMER, + OrderStatus.CANCELLED, +] + +const PER_STATUS = 18 +const TAG = 'MOCK-ORD' + +function d(n: number | string) { + return new Prisma.Decimal(n) +} + +function orderNumber(status: OrderStatus, i: number) { + // Unique, easy to spot/delete later + return `${TAG}-${status.slice(0, 4)}-${String(i).padStart(3, '0')}-${Date.now().toString(36)}` +} + +async function main() { + const mobileOverride = process.env.MOCK_ORDERS_MOBILE?.trim() + + const customer = mobileOverride + ? await prisma.customer.findUnique({ + where: { mobile: mobileOverride }, + select: { id: true, mobile: true, fullName: true, referredByAgentId: true }, + }) + : await prisma.customer.findFirst({ + orderBy: { updatedAt: 'desc' }, + select: { id: true, mobile: true, fullName: true, referredByAgentId: true }, + }) + + if (!customer) { + throw new Error( + mobileOverride + ? `No customer with mobile=${mobileOverride}` + : 'No customers in DB — create/login a customer first', + ) + } + + const variants = await prisma.productVariant.findMany({ + take: 30, + where: { isActive: true }, + include: { product: { select: { title: true } } }, + }) + if (variants.length === 0) { + throw new Error('No product variants in DB — seed catalog first') + } + + const branch = await prisma.branch.findFirst({ select: { id: true, name: true } }) + + console.log( + `Seeding ${PER_STATUS} orders × ${STATUSES.length} statuses for customer ${customer.mobile} (${customer.fullName}), ${variants.length} variants` + + (branch ? `, branch=${branch.name}` : ''), + ) + + let created = 0 + + for (const status of STATUSES) { + for (let i = 1; i <= PER_STATUS; i++) { + const variant = variants[i % variants.length]! + const qty = 1 + (i % 3) + const rash = Number(variant.rashWeightGrams ?? 1.25) + const wagePct = Number(variant.defaultWagePercent ?? 7) + const discountPct = 0 + const computed = rash * (1 + (wagePct - discountPct) / 100) + const totalGold = computed * qty + const wageWeight = totalGold - rash * qty + const flatRial = i % 4 === 0 ? 150_000 * i : 0 + const daysAgo = (STATUSES.indexOf(status) * PER_STATUS + i) % 40 + const createdAt = new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000 - i * 60_000) + const deliveredAt = + status === OrderStatus.DELIVERED_TO_CUSTOMER + ? new Date(createdAt.getTime() + 2 * 24 * 60 * 60 * 1000) + : null + + // 1–2 line items so list/detail both look realistic + const second = variants[(i + 3) % variants.length]! + const useSecond = i % 3 === 0 + + const order = await prisma.order.create({ + data: { + orderNumber: orderNumber(status, i), + customerId: customer.id, + branchId: branch?.id ?? null, + channel: i % 5 === 0 ? OrderChannel.WHOLESALE : OrderChannel.RETAIL, + status, + totalGoldWeightGrams: d( + (useSecond + ? totalGold + Number(second.rashWeightGrams) * (1 + Number(second.defaultWagePercent) / 100) + : totalGold + ).toFixed(4), + ), + wageWeightGrams: d(Math.max(0, wageWeight).toFixed(4)), + flatRialCharge: d(flatRial), + isSuspendedOverCreditLimit: status === OrderStatus.SUSPENDED, + deliveredAt, + salesAgentId: customer.referredByAgentId ?? null, + createdAt, + updatedAt: createdAt, + items: { + create: [ + { + productVariantId: variant.id, + quantity: qty, + rashWeightGramsSnapshot: d(rash.toFixed(4)), + wagePercentSnapshot: d(wagePct.toFixed(3)), + discountPercentSnapshot: d(discountPct.toFixed(3)), + computedWeightGrams: d(computed.toFixed(4)), + flatRialCharge: d(flatRial), + }, + ...(useSecond + ? [ + { + productVariantId: second.id, + quantity: 1, + rashWeightGramsSnapshot: d(Number(second.rashWeightGrams).toFixed(4)), + wagePercentSnapshot: d(Number(second.defaultWagePercent).toFixed(3)), + discountPercentSnapshot: d(0), + computedWeightGrams: d( + ( + Number(second.rashWeightGrams) * + (1 + Number(second.defaultWagePercent) / 100) + ).toFixed(4), + ), + flatRialCharge: d(0), + }, + ] + : []), + ], + }, + statusEvents: { + create: [ + { + fromStatus: null, + toStatus: OrderStatus.PLACED, + note: 'mock seed: placed', + createdAt, + }, + ...(status !== OrderStatus.PLACED + ? [ + { + fromStatus: OrderStatus.PLACED, + toStatus: status, + note: `mock seed: set to ${status}`, + createdAt: new Date(createdAt.getTime() + 60_000), + }, + ] + : []), + ], + }, + }, + }) + created++ + if (i === 1) { + console.log(` ${status}: first=${order.orderNumber}`) + } + } + console.log(` ${status}: +${PER_STATUS}`) + } + + const counts = await prisma.order.groupBy({ + by: ['status'], + _count: { _all: true }, + where: { orderNumber: { startsWith: TAG } }, + }) + console.log(`Done. Created ${created} mock orders (tag ${TAG}).`) + console.log( + 'Mock counts:', + Object.fromEntries(counts.map((c) => [c.status, c._count._all])), + ) + console.log(`Login as mobile ${customer.mobile} to see them in the app.`) +} + +main() + .catch((e) => { + console.error(e) + process.exit(1) + }) + .finally(async () => { + await prisma.$disconnect() + }) diff --git a/apps/api/src/common/brand.ts b/apps/api/src/common/brand.ts index 0d446e1..c5b116a 100644 --- a/apps/api/src/common/brand.ts +++ b/apps/api/src/common/brand.ts @@ -8,7 +8,7 @@ import { envOr } from './env'; * docs/BRANDING.md for the full list. */ export function brandName(): string { - return envOr('BRAND_NAME', 'زرتاج'); + return envOr('BRAND_NAME', 'تهران تاج'); } export function brandSiteUrl(): string { diff --git a/apps/api/src/modules/accounting-sync/accounting-sync.service.ts b/apps/api/src/modules/accounting-sync/accounting-sync.service.ts index 34da792..d8afa82 100644 --- a/apps/api/src/modules/accounting-sync/accounting-sync.service.ts +++ b/apps/api/src/modules/accounting-sync/accounting-sync.service.ts @@ -367,21 +367,26 @@ export class AccountingSyncService { select: { accountingGoldBalance: true, accountingRialBalance: true, accountingBalanceAt: true }, }); const stale = !c?.accountingBalanceAt || Date.now() - c.accountingBalanceAt.getTime() > BALANCE_FRESH_MS; - if (stale) { - try { - const [b] = await a.provider.getBalances( - { ...a.settings, timeoutMs: Math.min(a.settings.timeoutMs, BALANCE_ON_DEMAND_TIMEOUT_MS) }, - [link.externalCode], + + // Already have a stored figure: hand it back immediately and refresh in the + // background. Waiting on Tahesab/Zarrin here blocked GET /customer/me for up + // to BALANCE_ON_DEMAND_TIMEOUT_MS on almost every dashboard open (freshness + // is only a few seconds), so the app painted the guest invite while the + // token was still valid. + if (c?.accountingBalanceAt && stale) { + void this.refreshStoredBalance(a, customerId, link.externalCode).catch((err) => { + this.logger.warn( + `Background balance refresh for ${customerId} failed: ${(err as Error).message}`, ); - if (b) { - c = await this.prisma.customer.update({ - where: { id: customerId }, - data: { accountingGoldBalance: b.goldGrams, accountingRialBalance: b.rial, accountingBalanceAt: new Date() }, - select: { accountingGoldBalance: true, accountingRialBalance: true, accountingBalanceAt: true }, - }); - } + }); + } else if (stale) { + // First read — nothing to show yet, so wait (with the short timeout). + try { + c = (await this.refreshStoredBalance(a, customerId, link.externalCode)) ?? c; } catch (err) { - this.logger.warn(`On-demand balance for ${customerId} failed, serving the stored one: ${(err as Error).message}`); + this.logger.warn( + `On-demand balance for ${customerId} failed, serving the stored one: ${(err as Error).message}`, + ); } } if (!c?.accountingBalanceAt) return null; @@ -393,6 +398,32 @@ export class AccountingSyncService { }; } + /** Pull one linked customer's balance from the provider and persist it. */ + private async refreshStoredBalance( + a: ActiveProvider, + customerId: string, + externalCode: string, + ) { + const [b] = await a.provider.getBalances( + { ...a.settings, timeoutMs: Math.min(a.settings.timeoutMs, BALANCE_ON_DEMAND_TIMEOUT_MS) }, + [externalCode], + ); + if (!b) return null; + return this.prisma.customer.update({ + where: { id: customerId }, + data: { + accountingGoldBalance: b.goldGrams, + accountingRialBalance: b.rial, + accountingBalanceAt: new Date(), + }, + select: { + accountingGoldBalance: true, + accountingRialBalance: true, + accountingBalanceAt: true, + }, + }); + } + // ════════════════════════════════════════════════════════════════════ // Real-time: change poll + live reads diff --git a/apps/api/src/modules/catalog/catalog-paging.spec.ts b/apps/api/src/modules/catalog/catalog-paging.spec.ts index c6c00cc..988b63d 100644 --- a/apps/api/src/modules/catalog/catalog-paging.spec.ts +++ b/apps/api/src/modules/catalog/catalog-paging.spec.ts @@ -133,6 +133,12 @@ describe('CatalogService — paging and filtering', () => { }); }); + it('accepts several brands as a comma-separated brandId', async () => { + const { service, findMany } = setup(); + await service.findProducts({ page: 1, brandId: 'b1,b2' }); + expect(lastWhere(findMany).brandId).toEqual({ in: ['b1', 'b2'] }); + }); + it('matches a gram range against the variants, as the app did', async () => { // A product qualifies when ANY of its variants is in range // (SearchView.vue:189-195), not when all of them are. diff --git a/apps/api/src/modules/catalog/catalog.service.ts b/apps/api/src/modules/catalog/catalog.service.ts index d836097..0a426c6 100644 --- a/apps/api/src/modules/catalog/catalog.service.ts +++ b/apps/api/src/modules/catalog/catalog.service.ts @@ -143,7 +143,17 @@ export class CatalogService { where.title = { contains: query.search.trim(), mode: 'insensitive' }; } if (query.categoryId) where.categoryId = query.categoryId; - if (query.brandId) where.brandId = query.brandId; + // Apps' filter sheet allows several brands at once. Accept a single id or + // a comma-separated list (`?brandId=a,b`) so Search does not have to fake + // multi-brand by loading the whole catalogue. + if (query.brandId) { + const brandIds = query.brandId + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + if (brandIds.length === 1) where.brandId = brandIds[0]; + else if (brandIds.length > 1) where.brandId = { in: brandIds }; + } if (query.collectionId) where.collections = { some: { collectionId: query.collectionId } }; // A product is in range when at least ONE of its variants is, which is what diff --git a/apps/api/src/modules/customer-auth/customer-auth.controller.ts b/apps/api/src/modules/customer-auth/customer-auth.controller.ts index db5de71..fd4308d 100644 --- a/apps/api/src/modules/customer-auth/customer-auth.controller.ts +++ b/apps/api/src/modules/customer-auth/customer-auth.controller.ts @@ -97,8 +97,19 @@ export class CustomerAuthController { @CustomerAuth() @Get('me/orders') - myOrders(@CurrentCustomer() me: AuthenticatedCustomer) { - return this.customers.getOrders(me.id); + myOrders( + @CurrentCustomer() me: AuthenticatedCustomer, + @Query('page') page?: string, + @Query('limit') limit?: string, + @Query('status') status?: string, + @Query('q') q?: string, + ) { + return this.customers.getOrders(me.id, { + page: Number(page) || 1, + limit: limit !== undefined && limit !== '' ? Number(limit) : undefined, + status, + q, + }); } @CustomerAuth() diff --git a/apps/api/src/modules/customers/customers.service.ts b/apps/api/src/modules/customers/customers.service.ts index 95e3e74..5fe19e6 100644 --- a/apps/api/src/modules/customers/customers.service.ts +++ b/apps/api/src/modules/customers/customers.service.ts @@ -1,8 +1,23 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; -import { Prisma } from '@prisma/client'; +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { OrderStatus, Prisma } from '@prisma/client'; import { PrismaService } from '../../prisma/prisma.service'; import { AccountingSyncService } from '../accounting-sync/accounting-sync.service'; +/** Default page size for GET /customer/me/orders (mobile infinite scroll). */ +const ORDERS_PAGE_LIMIT = 20; +const ORDERS_PAGE_LIMIT_MAX = 50; + +const ORDER_STATUSES = new Set(Object.values(OrderStatus)); + +export interface GetOrdersQuery { + page?: number; + limit?: number; + /** One OrderStatus value, or omit / ALL for every status. */ + status?: string; + /** Substring match on `orderNumber`. */ + q?: string; +} + // passwordHash must never leave the API — every customer read goes through this. const PUBLIC_CUSTOMER_SELECT = { id: true, @@ -36,6 +51,8 @@ const PUBLIC_CUSTOMER_SELECT = { */ @Injectable() export class CustomersService { + private readonly logger = new Logger(CustomersService.name); + constructor( private readonly prisma: PrismaService, private readonly accounting: AccountingSyncService, @@ -51,52 +68,105 @@ export class CustomersService { // D27 — with the accounting integration on, the customer sees the balance // the accounting software holds. The apps only display these two fields; // the credit-limit check runs server-side on the internal ledger, untouched. - const accounting = await this.accounting.customerBalance(customerId); - if (accounting) { - return { - ...customer, - goldDebtGrams: accounting.gold, - fiatDebtRial: accounting.rial, - balanceSource: 'accounting' as const, - balanceAt: accounting.at, - }; + // + // Accounting must never take down /customer/me: the dashboard treats a + // failed me() as "no customer" and shows the guest invite while the token + // (and logout) still work — orders keep loading because they skip this path. + try { + const accounting = await this.accounting.customerBalance(customerId); + if (accounting) { + return { + ...customer, + goldDebtGrams: accounting.gold, + fiatDebtRial: accounting.rial, + balanceSource: 'accounting' as const, + balanceAt: accounting.at, + }; + } + } catch (err) { + this.logger.warn( + `Balance overlay for ${customerId} failed; serving app ledger: ${(err as Error).message}`, + ); } return { ...customer, balanceSource: 'app' as const, balanceAt: null }; } - getOrders(customerId: string) { - return this.prisma.order.findMany({ - where: { customerId }, - include: { - items: { - // Each item carried only `productVariantId`, so the apps looked the - // title up by loading the ENTIRE catalogue and joining in the browser - // (composables/useOrderItemProduct.ts). With ~16k products that is - // the wrong trade for a few row labels, and it also failed for an - // order whose product has since left the storefront — the catalogue - // hides it, so the row rendered blank. - // - // A nested read here is one join and always answers, including for - // products that are no longer on sale. - include: { - productVariant: { - select: { - id: true, - title: true, - product: { - select: { - id: true, - title: true, - images: { orderBy: { sortOrder: 'asc' }, take: 1, select: { url: true } }, - }, + /** + * Paginated order history for the signed-in customer. + * + * Shape matches the report endpoints (`result` / `count` / `page` / `limit`) + * so the apps can infinite-scroll the same way. Status and search are applied + * server-side — client-side filtering after a page slice would miss matches + * that sit on later pages. + */ + async getOrders(customerId: string, query: GetOrdersQuery = {}) { + const page = Math.max(1, Math.floor(query.page ?? 1) || 1); + const limit = Math.min( + ORDERS_PAGE_LIMIT_MAX, + Math.max(1, Math.floor(query.limit ?? ORDERS_PAGE_LIMIT) || ORDERS_PAGE_LIMIT), + ); + + const where: Prisma.OrderWhereInput = { customerId }; + if (query.status && query.status !== 'ALL' && ORDER_STATUSES.has(query.status)) { + where.status = query.status as OrderStatus; + } + const q = query.q?.trim(); + if (q) { + where.orderNumber = { contains: q, mode: 'insensitive' }; + } + + const include = { + items: { + // Each item carried only `productVariantId`, so the apps looked the + // title up by loading the ENTIRE catalogue and joining in the browser + // (composables/useOrderItemProduct.ts). With ~16k products that is + // the wrong trade for a few row labels, and it also failed for an + // order whose product has since left the storefront — the catalogue + // hides it, so the row rendered blank. + // + // A nested read here is one join and always answers, including for + // products that are no longer on sale. + include: { + productVariant: { + select: { + id: true, + title: true, + product: { + select: { + id: true, + title: true, + images: { orderBy: { sortOrder: 'asc' as const }, take: 1, select: { url: true } }, }, }, }, }, }, }, - orderBy: { createdAt: 'desc' }, - }); + // Chronological status changes for the order-detail timeline in the app. + statusEvents: { + orderBy: { createdAt: 'asc' as const }, + select: { + id: true, + fromStatus: true, + toStatus: true, + note: true, + createdAt: true, + }, + }, + }; + + const [result, count] = await Promise.all([ + this.prisma.order.findMany({ + where, + include, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * limit, + take: limit, + }), + this.prisma.order.count({ where }), + ]); + + return { result, count, page, limit }; } getLedgerEntries(customerId: string) { diff --git a/apps/api/src/modules/customers/get-or-throw.spec.ts b/apps/api/src/modules/customers/get-or-throw.spec.ts new file mode 100644 index 0000000..6145306 --- /dev/null +++ b/apps/api/src/modules/customers/get-or-throw.spec.ts @@ -0,0 +1,71 @@ +import { NotFoundException } from '@nestjs/common'; +import { CustomersService } from './customers.service'; + +describe('CustomersService.getOrThrow', () => { + const base = { + id: 'c1', + fullName: 'Honar', + mobile: '09382507041', + nationalCode: null, + cityId: null, + referredByAgentId: null, + goldCreditLimitGrams: '100', + goldDebtGrams: '1.5', + fiatDebtRial: '2000', + verificationStep: 'VERIFIED', + nationalIdImageUrl: null, + businessLicenseUrl: null, + shopImageUrl: null, + isActive: true, + isArchived: false, + createdAt: new Date('2026-01-01'), + updatedAt: new Date('2026-01-01'), + }; + + it('falls back to the app ledger when accounting throws', async () => { + const prisma = { + customer: { findUnique: jest.fn().mockResolvedValue(base) }, + }; + const accounting = { + customerBalance: jest.fn().mockRejectedValue(new Error('tahesab down')), + }; + const service = new CustomersService(prisma as never, accounting as never); + + await expect(service.getOrThrow('c1')).resolves.toMatchObject({ + id: 'c1', + goldDebtGrams: '1.5', + fiatDebtRial: '2000', + balanceSource: 'app', + balanceAt: null, + }); + }); + + it('overlays the accounting balance when available', async () => { + const prisma = { + customer: { findUnique: jest.fn().mockResolvedValue(base) }, + }; + const accounting = { + customerBalance: jest.fn().mockResolvedValue({ + gold: '3.25', + rial: '9000', + at: new Date('2026-10-17'), + provider: 'ته‌حساب', + }), + }; + const service = new CustomersService(prisma as never, accounting as never); + + await expect(service.getOrThrow('c1')).resolves.toMatchObject({ + goldDebtGrams: '3.25', + fiatDebtRial: '9000', + balanceSource: 'accounting', + }); + }); + + it('404s when the customer is missing', async () => { + const prisma = { + customer: { findUnique: jest.fn().mockResolvedValue(null) }, + }; + const service = new CustomersService(prisma as never, { customerBalance: jest.fn() } as never); + await expect(service.getOrThrow('missing')).rejects.toBeInstanceOf(NotFoundException); + }); +}); diff --git a/apps/api/src/modules/customers/order-items-include.spec.ts b/apps/api/src/modules/customers/order-items-include.spec.ts index a26bb86..9570237 100644 --- a/apps/api/src/modules/customers/order-items-include.spec.ts +++ b/apps/api/src/modules/customers/order-items-include.spec.ts @@ -16,10 +16,11 @@ import { CustomersService } from './customers.service'; describe('CustomersService.getOrders — items carry their product', () => { function setup() { const findMany = jest.fn().mockResolvedValue([]); - const prisma = { order: { findMany } }; + const count = jest.fn().mockResolvedValue(0); + const prisma = { order: { findMany, count } }; // getOrders touches neither the accounting sync nor anything on it. const service = new CustomersService(prisma as never, {} as never); - return { service, findMany }; + return { service, findMany, count }; } it('includes the variant, its product and the first image', async () => { @@ -39,6 +40,22 @@ describe('CustomersService.getOrders — items carry their product', () => { }); }); + it('includes status events oldest-first for the detail timeline', async () => { + const { service, findMany } = setup(); + await service.getOrders('c1'); + + expect(findMany.mock.calls[0][0].include.statusEvents).toMatchObject({ + orderBy: { createdAt: 'asc' }, + select: { + id: true, + fromStatus: true, + toStatus: true, + note: true, + createdAt: true, + }, + }); + }); + it('is scoped to the caller and newest first', async () => { // Guards the two properties that were already here: one customer must never // receive another's orders, and the list is reverse-chronological. @@ -59,4 +76,34 @@ describe('CustomersService.getOrders — items carry their product', () => { const product = findMany.mock.calls[0][0].include.items.include.productVariant.select.product; expect(product.where).toBeUndefined(); }); + + it('pages with skip/take and returns count', async () => { + const { service, findMany, count } = setup(); + findMany.mockResolvedValue([{ id: 'o1' }]); + count.mockResolvedValue(41); + + await expect(service.getOrders('c1', { page: 2, limit: 20 })).resolves.toEqual({ + result: [{ id: 'o1' }], + count: 41, + page: 2, + limit: 20, + }); + expect(findMany.mock.calls[0][0]).toMatchObject({ skip: 20, take: 20 }); + }); + + it('filters by status and orderNumber on the server', async () => { + const { service, findMany } = setup(); + await service.getOrders('c1', { status: 'PLACED', q: '12' }); + expect(findMany.mock.calls[0][0].where).toEqual({ + customerId: 'c1', + status: 'PLACED', + orderNumber: { contains: '12', mode: 'insensitive' }, + }); + }); + + it('ignores an unknown status instead of throwing', async () => { + const { service, findMany } = setup(); + await service.getOrders('c1', { status: 'NOT_A_STATUS' }); + expect(findMany.mock.calls[0][0].where).toEqual({ customerId: 'c1' }); + }); }); diff --git a/apps/api/src/modules/iam/iam.service.spec.ts b/apps/api/src/modules/iam/iam.service.spec.ts index a7638a0..f912773 100644 --- a/apps/api/src/modules/iam/iam.service.spec.ts +++ b/apps/api/src/modules/iam/iam.service.spec.ts @@ -24,7 +24,7 @@ describe('IamService', () => { IAM_ISSUER: 'https://auth.test.local', IAM_CLIENT_ID: 'goldbank_api', IAM_CLIENT_SECRET: 'server-secret', - IAM_TENANT_SLUG: 'zartaj', + IAM_TENANT_SLUG: 'tehrantaj', IAM_LOGIN_DELEGATION: 'fallback', ...overrides, }; @@ -110,7 +110,7 @@ describe('IamService', () => { expect(body.identifier).toBe('09120000000'); expect(body.client_id).toBe('goldbank_api'); expect(body.client_secret).toBe('server-secret'); - expect(body.tenant_slug).toBe('zartaj'); + expect(body.tenant_slug).toBe('tehrantaj'); // Without this the IAM would rate-limit on this server's IP and lock // out every admin after a handful of attempts. expect(body.client_ip).toBe('203.0.113.7'); diff --git a/apps/api/src/modules/orders/dto/create-order.dto.ts b/apps/api/src/modules/orders/dto/create-order.dto.ts index c029120..0620cf8 100644 --- a/apps/api/src/modules/orders/dto/create-order.dto.ts +++ b/apps/api/src/modules/orders/dto/create-order.dto.ts @@ -47,7 +47,11 @@ export class CreateOrderDto { /** * Which sales channel placed the order. Legacy had no such field because the * two channels lived in separate tables (orders vs orders_orders); the one - * Order model carries the distinction here. Defaults to RETAIL. + * Order model carries the distinction here. + * + * On the customer (app) path the server forces WHOLESALE and ignores this + * field. Operators may set it; admin MakeOrder always sends WHOLESALE. + * Default when an operator omits it remains RETAIL (retail list builder). */ @IsOptional() @IsEnum(OrderChannel) diff --git a/apps/api/src/modules/orders/orders.service.spec.ts b/apps/api/src/modules/orders/orders.service.spec.ts index 42967d4..e589c09 100644 --- a/apps/api/src/modules/orders/orders.service.spec.ts +++ b/apps/api/src/modules/orders/orders.service.spec.ts @@ -5,6 +5,9 @@ import { OrdersService } from './orders.service'; /** Order SMS is fire-and-forget (D20); these suites only assert order data. */ const smsStub = { send: jest.fn(async () => ({ ok: true })) }; +/** Stands in for Postgres `nextval('order_number_seq')` inside the create tx. */ +const nextOrderNumberRaw = () => jest.fn(async () => [{ n: 100n }]); + /** * Owner ruling 2026-10-08: an order is credited to the customer's expert AT * THE MOMENT IT IS PLACED (legacy `orders.reagent_id`), and a later change of @@ -20,6 +23,7 @@ describe('OrdersService.createOrder — sales expert credit', () => { return { id: 'o1', ...data }; }), }, + $queryRaw: nextOrderNumberRaw(), }; const prisma = { customer: { @@ -48,6 +52,12 @@ describe('OrdersService.createOrder — sales expert credit', () => { expect(created[0].salesAgentId).toBe('agent-A'); }); + it('assigns a short numeric orderNumber from the sequence', async () => { + const { service, created } = build(null); + await service.createOrder(dto, 'c1'); + expect(created[0].orderNumber).toBe('100'); + }); + it('records no expert for a customer without one', async () => { const { service, created } = build(null); await service.createOrder(dto, 'c1'); @@ -63,7 +73,10 @@ const wageStub = { /** MON-05 (owner ruling 2026-10-08): «عدم سفارش» stops app orders only. */ describe('OrdersService.createOrder — order ban', () => { function build(canPlaceOrders: boolean) { - const tx = { order: { create: jest.fn(async ({ data }: { data: object }) => ({ id: 'o1', ...data })) } }; + const tx = { + order: { create: jest.fn(async ({ data }: { data: object }) => ({ id: 'o1', ...data })) }, + $queryRaw: nextOrderNumberRaw(), + }; const prisma = { customer: { findUnique: jest.fn(async () => ({ @@ -98,7 +111,15 @@ describe('OrdersService.createOrder — order ban', () => { describe('OrdersService.createOrder — server-side making charge', () => { function build() { const created: Record[] = []; - const tx = { order: { create: jest.fn(async ({ data }: { data: Record }) => { created.push(data); return { id: 'o1', ...data }; }) } }; + const tx = { + order: { + create: jest.fn(async ({ data }: { data: Record }) => { + created.push(data); + return { id: 'o1', ...data }; + }), + }, + $queryRaw: nextOrderNumberRaw(), + }; const prisma = { customer: { findUnique: jest.fn(async () => ({ id: 'c1', canPlaceOrders: true, referredByAgentId: null, goldDebtGrams: new Prisma.Decimal(0), goldCreditLimitGrams: new Prisma.Decimal(1000) })) }, $transaction: jest.fn(async (fn: (t: typeof tx) => unknown) => fn(tx)), @@ -200,6 +221,75 @@ describe('OrdersService.createOrder — server-side making charge', () => { }); }); +/** App orders are B2B wholesale; RETAIL is only for the admin retail list. */ +describe('OrdersService.createOrder — channel', () => { + function build() { + const created: Record[] = []; + const tx = { + order: { + create: jest.fn(async ({ data }: { data: Record }) => { + created.push(data); + return { id: 'o1', ...data }; + }), + }, + $queryRaw: nextOrderNumberRaw(), + }; + const prisma = { + customer: { + findUnique: jest.fn(async () => ({ + id: 'c1', + canPlaceOrders: true, + referredByAgentId: null, + goldDebtGrams: new Prisma.Decimal(0), + goldCreditLimitGrams: new Prisma.Decimal(1000), + })), + }, + $transaction: jest.fn(async (fn: (t: typeof tx) => unknown) => fn(tx)), + }; + const catalog = { getVariantOrThrow: jest.fn(async () => ({ id: 'v1', rashWeightGrams: '10' })) }; + const service = new OrdersService( + prisma as never, + catalog as never, + {} as never, + { orderStatusReached: jest.fn() } as never, + wageStub as never, + smsStub as never, + ); + return { service, created }; + } + const items = [{ productVariantId: 'v1', quantity: 1, wagePercent: 10 }]; + + it('stores an app order as WHOLESALE even when the body omits channel', async () => { + const { service, created } = build(); + await service.createOrder({ items } as never, 'c1', { placedBy: 'customer' }); + expect(created[0].channel).toBe('WHOLESALE'); + }); + + it('ignores a RETAIL channel sent by the app', async () => { + const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined); + try { + const { service, created } = build(); + await service.createOrder({ items, channel: 'RETAIL' } as never, 'c1', { placedBy: 'customer' }); + expect(created[0].channel).toBe('WHOLESALE'); + expect(warn.mock.calls.map((c) => String(c[0])).join('\n')).toContain('forcing WHOLESALE'); + } finally { + warn.mockRestore(); + } + }); + + it('keeps an operator-supplied RETAIL channel', async () => { + const { service, created } = build(); + await service.createOrder({ items, channel: 'RETAIL' } as never, 'c1', { placedBy: 'operator' }); + expect(created[0].channel).toBe('RETAIL'); + }); + + it('defaults an operator order without channel to RETAIL', async () => { + const { service, created } = build(); + await service.createOrder({ items } as never, 'c1', { placedBy: 'operator' }); + expect(created[0].channel).toBe('RETAIL'); + }); +}); + /** * Legacy ApiMobileController::setOrder:733-766 texted three audiences on every * placed order. None of it was wired in the target until now. @@ -207,7 +297,10 @@ describe('OrdersService.createOrder — server-side making charge', () => { describe('OrdersService.createOrder — order SMS', () => { function build(opts: { overLimit?: boolean; agentId?: string | null; agentMobile?: string | null } = {}) { const sent: { to: string; body: string }[] = []; - const tx = { order: { create: jest.fn(async ({ data }: { data: object }) => ({ id: 'o1', ...data })) } }; + const tx = { + order: { create: jest.fn(async ({ data }: { data: object }) => ({ id: 'o1', ...data })) }, + $queryRaw: nextOrderNumberRaw(), + }; const prisma = { customer: { findUnique: jest.fn(async () => ({ @@ -241,7 +334,7 @@ describe('OrdersService.createOrder — order SMS', () => { await service.createOrder(dto, 'c1', { placedBy: 'customer' }); await settle(); const toCustomer = sent.find((s) => s.to === '09120000001'); - expect(toCustomer?.body).toContain('به ثبت رسید. زرتاج'); + expect(toCustomer?.body).toContain('به ثبت رسید. تهران تاج'); expect(toCustomer?.body).not.toContain('معلق'); }); diff --git a/apps/api/src/modules/orders/orders.service.ts b/apps/api/src/modules/orders/orders.service.ts index 716f214..a25fc23 100644 --- a/apps/api/src/modules/orders/orders.service.ts +++ b/apps/api/src/modules/orders/orders.service.ts @@ -135,13 +135,29 @@ export class OrdersService { .gt(customer.goldCreditLimitGrams); const initialStatus = isSuspendedOverCreditLimit ? OrderStatus.SUSPENDED : OrderStatus.PLACED; + // App (B2B) orders are always WHOLESALE. Legacy wrote them to `orders`, + // not `orders_orders` (RETAIL). The DTO default was RETAIL and the apps + // never sent `channel`, so every app order landed on the retail list. + // Operators still pick the channel (admin MakeOrder forces WHOLESALE; + // the retail list builder can pass RETAIL). A client-supplied channel on + // the customer path is ignored — same posture as MON-08 pricing. + const channel = + opts.placedBy === 'customer' + ? OrderChannel.WHOLESALE + : (dto.channel ?? OrderChannel.RETAIL); + if (opts.placedBy === 'customer' && dto.channel && dto.channel !== OrderChannel.WHOLESALE) { + this.logger.warn( + `Order body carried channel=${dto.channel} from the app; forcing WHOLESALE.`, + ); + } + const placed = await this.prisma.$transaction(async (tx) => { const order = await tx.order.create({ data: { - orderNumber: this.generateOrderNumber(), + orderNumber: await this.nextOrderNumber(tx), customerId, branchId: dto.branchId, - channel: dto.channel ?? OrderChannel.RETAIL, + channel, status: initialStatus, totalGoldWeightGrams, wageWeightGrams, @@ -360,8 +376,21 @@ export class OrdersService { return order; } - private generateOrderNumber(): string { - const random = Math.random().toString(36).slice(2, 8).toUpperCase(); - return `ORD-${Date.now()}-${random}`; + /** + * Short global sequential number for the customer, SMS and search + * (`100`, `101`, …). Allocated from Postgres `order_number_seq` inside the + * placement transaction so concurrent creates cannot collide. Legacy used + * the auto-increment order id the same way; the old `ORD-{ts}-{rand}` form + * was too long for SMS and the list UI. + */ + private async nextOrderNumber(tx: Prisma.TransactionClient): Promise { + const rows = await tx.$queryRaw>` + SELECT nextval('order_number_seq') AS n + `; + const n = rows[0]?.n; + if (n === undefined || n === null) { + throw new Error('order_number_seq returned no value'); + } + return String(n); } } diff --git a/apps/api/src/modules/sms/sms.service.spec.ts b/apps/api/src/modules/sms/sms.service.spec.ts index f339725..403ef0c 100644 --- a/apps/api/src/modules/sms/sms.service.spec.ts +++ b/apps/api/src/modules/sms/sms.service.spec.ts @@ -310,18 +310,18 @@ describe('SmsService.send', () => { /** * The OTP text keeps the reference's shape (ApiMobileController.php:47) with - * the brand زرتاج — the first line and the trailing token are the Android SMS + * the brand تهران تاج — the first line and the trailing token are the Android SMS * Retriever hash, and autofill breaks if either changes. */ describe('OTP message template', () => { const build = (code: string) => `<#> code is ${code}\n` + - `رمز عبور یکبار مصرف شما جهت ورود به اپلیکیشن زرتاج ${code} می باشد\n` + + `رمز عبور یکبار مصرف شما جهت ورود به اپلیکیشن تهران تاج ${code} می باشد\n` + `aMo6iFyUQaY`; it('matches the expected text byte for byte', () => { expect(build('1234')).toBe( - '<#> code is 1234\nرمز عبور یکبار مصرف شما جهت ورود به اپلیکیشن زرتاج 1234 می باشد\naMo6iFyUQaY', + '<#> code is 1234\nرمز عبور یکبار مصرف شما جهت ورود به اپلیکیشن تهران تاج 1234 می باشد\naMo6iFyUQaY', ); }); diff --git a/apps/user-app-v2/src/api/cachedReads.ts b/apps/user-app-v2/src/api/cachedReads.ts index 55b238d..bff36aa 100644 --- a/apps/user-app-v2/src/api/cachedReads.ts +++ b/apps/user-app-v2/src/api/cachedReads.ts @@ -7,7 +7,10 @@ export function useCachedReads() { const cache = useQueryCache() return { me: (o?: FetchOptions) => cache.fetch(KEYS.profile, api.getMe, o), - myOrders: (o?: FetchOptions) => cache.fetch(KEYS.orders, api.getMyOrders, o), + // Orders are paged (infinite scroll). The list screen calls api.getMyOrders + // directly; this helper only covers a rare full-first-page peek/invalidate. + myOrders: (o?: FetchOptions) => + cache.fetch(KEYS.orders, () => api.getMyOrders({ page: 1 }).then((p) => p.result), o), accountingBalance: (o?: FetchOptions) => cache.fetch(KEYS.balance, api.getAccountingBalance, o), myLedgerEntries: (o?: FetchOptions) => cache.fetch(KEYS.ledger, api.getMyLedgerEntries, o), goldPrice: (o?: FetchOptions) => cache.fetch(KEYS.goldPrice, () => api.getLatestGoldPrice(), o), diff --git a/apps/user-app-v2/src/api/client.ts b/apps/user-app-v2/src/api/client.ts index 65a4e83..eeaab67 100644 --- a/apps/user-app-v2/src/api/client.ts +++ b/apps/user-app-v2/src/api/client.ts @@ -226,6 +226,14 @@ export interface OrderItemDetail { } | null; } +export interface OrderStatusEvent { + id: string; + fromStatus: string | null; + toStatus: string; + note: string | null; + createdAt: string; +} + export interface OrderDetail { id: string; orderNumber: string; @@ -237,6 +245,25 @@ export interface OrderDetail { deliveredAt: string | null; createdAt: string; items: OrderItemDetail[]; + /** Chronological status changes (oldest first). May be empty on older rows. */ + statusEvents?: OrderStatusEvent[]; +} + +/** One page of GET /customer/me/orders — same envelope as the report endpoints. */ +export interface OrdersPage { + result: OrderDetail[]; + count: number; + page: number; + limit: number; +} + +export interface OrdersQuery { + page?: number; + limit?: number; + /** OrderStatus, or omit / ALL for every status. */ + status?: string; + /** Substring of orderNumber. */ + q?: string; } export interface CustomerDetail { @@ -509,7 +536,15 @@ export const api = { getMe: () => request('/customer/me'), /** D27 §12 — this customer's balance, read from the accounting software on every call. */ getAccountingBalance: () => request('/customer/me/accounting-balance'), - getMyOrders: () => request('/customer/me/orders'), + getMyOrders: (query: OrdersQuery = {}) => { + const params = new URLSearchParams(); + if (query.page) params.set('page', String(query.page)); + if (query.limit) params.set('limit', String(query.limit)); + if (query.status && query.status !== 'ALL') params.set('status', query.status); + if (query.q?.trim()) params.set('q', query.q.trim()); + const qs = params.toString(); + return request(`/customer/me/orders${qs ? `?${qs}` : ''}`); + }, getMyLedgerEntries: () => request('/customer/me/ledger-entries'), /** The making charge the server will charge for these variants (MON-08). */ getWageQuotes: (variantIds: string[]) => diff --git a/apps/user-app-v2/src/components/home/ProductCarousel.vue b/apps/user-app-v2/src/components/home/ProductCarousel.vue index 3d5de74..2a87703 100644 --- a/apps/user-app-v2/src/components/home/ProductCarousel.vue +++ b/apps/user-app-v2/src/components/home/ProductCarousel.vue @@ -35,7 +35,7 @@ diff --git a/apps/user-app-v2/src/components/home/RateStrip.vue b/apps/user-app-v2/src/components/home/RateStrip.vue index be4afad..1650ad1 100644 --- a/apps/user-app-v2/src/components/home/RateStrip.vue +++ b/apps/user-app-v2/src/components/home/RateStrip.vue @@ -108,7 +108,7 @@ const perGram = computed(() => (props.quote ? props.quote.pricePerGramRial.split align-items: center; gap: 7px; font-size: 12px; - color: rgba(255, 255, 255, 0.68); + color: #fff; white-space: nowrap; } @@ -141,12 +141,13 @@ const perGram = computed(() => (props.quote ? props.quote.pricePerGramRial.split .rate-strip__unit { font-size: 10.5px; - color: rgba(255, 255, 255, 0.55); + /* Tailwind gray-100 */ + color: #f3f4f6; } .rate-strip__time { font-size: 10.5px; - color: rgba(255, 255, 255, 0.42); + color: #fff; padding-inline-start: 10px; border-inline-start: 1px solid rgba(255, 255, 255, 0.14); white-space: nowrap; diff --git a/apps/user-app-v2/src/components/home/SectionHead.vue b/apps/user-app-v2/src/components/home/SectionHead.vue index d68150e..756180a 100644 --- a/apps/user-app-v2/src/components/home/SectionHead.vue +++ b/apps/user-app-v2/src/components/home/SectionHead.vue @@ -7,7 +7,7 @@ @@ -30,8 +30,8 @@ const emit = defineEmits<{ (e: 'action'): void }>() .sec__title { margin: 0; - font-size: 15px; - font-weight: 800; + font-size: 13.5px; + font-weight: 600; color: @ink; letter-spacing: -0.1px; } diff --git a/apps/user-app-v2/src/components/input/SingleOtpInput.vue b/apps/user-app-v2/src/components/input/SingleOtpInput.vue index 5502202..c86dedc 100644 --- a/apps/user-app-v2/src/components/input/SingleOtpInput.vue +++ b/apps/user-app-v2/src/components/input/SingleOtpInput.vue @@ -25,7 +25,7 @@ diff --git a/apps/user-app-v2/src/components/product/OrderItemCard.vue b/apps/user-app-v2/src/components/product/OrderItemCard.vue index 8cfe750..893d6f7 100644 --- a/apps/user-app-v2/src/components/product/OrderItemCard.vue +++ b/apps/user-app-v2/src/components/product/OrderItemCard.vue @@ -15,11 +15,17 @@ --> + + diff --git a/apps/user-app-v2/src/components/search/Filters.vue b/apps/user-app-v2/src/components/search/Filters.vue index 88d60c4..0882098 100644 --- a/apps/user-app-v2/src/components/search/Filters.vue +++ b/apps/user-app-v2/src/components/search/Filters.vue @@ -151,24 +151,39 @@ import { Slider } from 'ant-design-vue' import Icon from '../icon/Icon.vue' import DropDown from '../ui/DropDown.vue' import type { FiltersQuery } from '../../stores/filtersStore' -import type { ProductSummary } from '../../api/client' +import type { CatalogFacets, ProductSummary } from '../../api/client' -const props = defineProps<{ show: boolean; modelValue: FiltersQuery; filterKeys: string[]; products: ProductSummary[] }>() +const props = defineProps<{ + show: boolean + modelValue: FiltersQuery + filterKeys: string[] + /** @deprecated Prefer `facets`. */ + products?: ProductSummary[] + facets?: CatalogFacets | null +}>() const emit = defineEmits<{ (e: 'update:show', value: boolean): void (e: 'update:filterKeys', value: string[]): void }>() const availableBrands = computed(() => { + if (props.facets?.brands?.length) return props.facets.brands const seen = new Map() - for (const p of props.products) { + for (const p of props.products ?? []) { if (p.brand) seen.set(p.brand.id, p.brand) } return [...seen.values()] }) const gramBounds = computed((): [number, number] => { - const weights = props.products.flatMap((p) => p.variants.map((v) => parseFloat(v.rashWeightGrams))) + if (props.facets?.gramRange) { + const min = parseFloat(props.facets.gramRange.min) + const max = parseFloat(props.facets.gramRange.max) + if (Number.isFinite(min) && Number.isFinite(max) && max >= min) { + return [Math.floor(min), Math.ceil(max)] + } + } + const weights = (props.products ?? []).flatMap((p) => p.variants.map((v) => parseFloat(v.rashWeightGrams))) if (weights.length === 0) return [0, 100] return [Math.floor(Math.min(...weights)), Math.ceil(Math.max(...weights))] }) diff --git a/apps/user-app-v2/src/components/ui/AppPullRefresh.vue b/apps/user-app-v2/src/components/ui/AppPullRefresh.vue new file mode 100644 index 0000000..f212abd --- /dev/null +++ b/apps/user-app-v2/src/components/ui/AppPullRefresh.vue @@ -0,0 +1,82 @@ + + + + + + diff --git a/apps/user-app-v2/src/components/ui/Navigation.vue b/apps/user-app-v2/src/components/ui/Navigation.vue index 3deae35..64b23f4 100644 --- a/apps/user-app-v2/src/components/ui/Navigation.vue +++ b/apps/user-app-v2/src/components/ui/Navigation.vue @@ -2,6 +2,8 @@ Bottom nav — a compact rounded bar of square, icon-only tiles (Home, Search, Cart, Orders, Profile). A single square indicator slides behind the active tile; labels live in aria-label/title instead of under the icon. + + On tab switch the newly active icon gets a short spring pop (CSS only). --> diff --git a/apps/user-app-v2/src/composables/usePagedProducts.ts b/apps/user-app-v2/src/composables/usePagedProducts.ts new file mode 100644 index 0000000..14d7ff1 --- /dev/null +++ b/apps/user-app-v2/src/composables/usePagedProducts.ts @@ -0,0 +1,65 @@ +// Server-side catalogue paging (STUB-03 step 4 / docs/CATALOG-PAGINATION-PLAN.md). +// Replaces "download everything, slice in the browser" for list screens. +import { computed, ref } from 'vue' +import { api, type ProductQuery, type ProductSummary } from '../api/client' + +export const CATALOG_PAGE_SIZE = 20 + +export function usePagedProducts() { + const rows = ref([]) + const total = ref(0) + const page = ref(0) + const loading = ref(false) + const loadingMore = ref(false) + + const hasMore = computed(() => rows.value.length < total.value) + + /** + * @param opts.preserveRows — pull-to-refresh: keep the current list on screen + * until the new page arrives (avoids an empty flash / stuck full-page spinner). + */ + async function resetAndLoad(query: ProductQuery, opts: { preserveRows?: boolean } = {}) { + const showFullSpinner = !opts.preserveRows || rows.value.length === 0 + if (showFullSpinner) loading.value = true + page.value = 1 + try { + const res = await api.listProductsPaged({ + ...query, + page: 1, + limit: query.limit ?? CATALOG_PAGE_SIZE, + }) + rows.value = res.rows + total.value = res.total + } finally { + loading.value = false + } + } + + async function loadMore(query: ProductQuery) { + if (loading.value || loadingMore.value || !hasMore.value) return + loadingMore.value = true + const next = page.value + 1 + try { + const res = await api.listProductsPaged({ + ...query, + page: next, + limit: query.limit ?? CATALOG_PAGE_SIZE, + }) + page.value = next + // Append; drop accidental duplicates if the catalogue shifted between pages. + const seen = new Set(rows.value.map((p) => p.id)) + rows.value = [...rows.value, ...res.rows.filter((p) => !seen.has(p.id))] + total.value = res.total + } finally { + loadingMore.value = false + } + } + + function clear() { + rows.value = [] + total.value = 0 + page.value = 0 + } + + return { rows, total, page, loading, loadingMore, hasMore, resetAndLoad, loadMore, clear } +} diff --git a/apps/user-app-v2/src/composables/useProducts.ts b/apps/user-app-v2/src/composables/useProducts.ts index 01e99f0..421e528 100644 --- a/apps/user-app-v2/src/composables/useProducts.ts +++ b/apps/user-app-v2/src/composables/useProducts.ts @@ -1,13 +1,9 @@ -// Adapted from legacy henza_pwa/src/Mixins/getProducts.ts. Legacy paginated -// against POST /get-products (page/limit/count). The new API's GET -// /catalog/products returns the full active catalog in one flat response with -// no pagination — so `isPull` (legacy's infinite-scroll "next page" trigger) -// is a no-op here, and `totalPages` is always 0. A caller that wants new -// products at the end of the list passes `force` to refresh the cached -// catalogue instead of fabricating fake pages. -import { reactive, watch } from 'vue' +// Newest / list screens: true server paging (STUB-03 step 4). +// Legacy paginated against POST /get-products; the early Nest port returned the +// whole catalogue and treated `@fetch` as a no-op. This restores real pages. +import { reactive } from 'vue' import type { ProductSummary } from '../api/client' -import { useCatalogStore } from '../stores/catalogStore' +import { usePagedProducts, CATALOG_PAGE_SIZE } from './usePagedProducts' export interface ProductsData { totalPages: number @@ -16,28 +12,38 @@ export interface ProductsData { } export function useProducts() { - const catalog = useCatalogStore() + const paged = usePagedProducts() const productsData: ProductsData = reactive({ totalPages: 0, - productList: catalog.products, + productList: [] as ProductSummary[], flag: false, }) - // A refresh from another screen (pull-to-refresh) lands here too. - watch( - () => catalog.products, - (list) => { - productsData.productList = list - productsData.flag = list.length < 1 - }, - ) + function sync() { + productsData.productList = paged.rows.value + productsData.totalPages = Math.ceil(paged.total.value / CATALOG_PAGE_SIZE) || 0 + productsData.flag = paged.total.value < 1 && !paged.loading.value + } const getProducts = async (loading: { value: boolean }, isPull = false, force = false) => { - if (isPull) return // no further pages exist server-side yet - loading.value = force || !catalog.loaded + void force + if (isPull) { + if (!paged.hasMore.value || paged.loadingMore.value) return + loading.value = true + try { + await paged.loadMore({ sort: 'newest' }) + sync() + } catch (e) { + console.log(e) + } finally { + loading.value = false + } + return + } + loading.value = true try { - productsData.productList = await catalog.load(force) - productsData.flag = productsData.productList.length < 1 + await paged.resetAndLoad({ sort: 'newest' }) + sync() } catch (e) { console.log(e) } finally { @@ -45,5 +51,5 @@ export function useProducts() { } } - return { productsData, getProducts } + return { productsData, getProducts, hasMore: paged.hasMore } } diff --git a/apps/user-app-v2/src/stores/catalogStore.ts b/apps/user-app-v2/src/stores/catalogStore.ts index cde0ce2..eba8f20 100644 --- a/apps/user-app-v2/src/stores/catalogStore.ts +++ b/apps/user-app-v2/src/stores/catalogStore.ts @@ -1,9 +1,5 @@ -// The storefront catalogue (GET /catalog/products), shared by every screen. -// -// The endpoint returns the whole active catalogue in one response, so one copy -// serves home, search, collections, favourites, the product page, the cart -// re-sync and the order-item lookup. Reads go through queryCache and keep the -// copy until pull-to-refresh, the end of the newest-products list, or an order. +// The storefront catalogue slice shared by home rails and leftover callers. +// List/search screens page via api.listProductsPaged (STUB-03 step 4). import { defineStore } from 'pinia' import { computed, ref, shallowRef } from 'vue' import { api, type ProductSummary } from '../api/client' @@ -11,10 +7,8 @@ import { KEYS, useQueryCache } from './queryCache' export const useCatalogStore = defineStore('catalog', () => { const cache = useQueryCache() - // shallowRef: the list is replaced wholesale, never mutated in place. const cached = cache.peek(KEYS.catalog) const products = shallowRef(cached ?? []) - /** True once a response has arrived — an empty catalogue is still "loaded". */ const loaded = ref(cached !== undefined) const categories = computed(() => { @@ -25,27 +19,32 @@ export const useCatalogStore = defineStore('catalog', () => { return [...seen.values()] }) - // `variantLookup` lived here: a variantId -> {productTitle, variantTitle, - // imageUrl} map built over the whole catalogue, used only to label order - // rows. The server now includes those fields with the order itself - // (customers.service.ts getOrders), so the map is gone rather than left as a - // reason to keep loading the full catalogue. - async function load(force = false): Promise { - const list = await cache.fetch(KEYS.catalog, api.listProducts, { force }) + const list = await cache.fetch( + KEYS.catalog, + async () => { + const page = await api.listProductsPaged({ page: 1, limit: 100, sort: 'newest' }) + return page.rows + }, + { force }, + ) products.value = list loaded.value = true return list } + function replace(list: ProductSummary[]) { + products.value = list + loaded.value = true + } + function byId(id: string): ProductSummary | null { return products.value.find((p) => p.id === id) ?? null } - /** Drops the cached copy; the next load() goes to the server. */ function invalidate() { cache.invalidate(KEYS.catalog) } - return { products, loaded, categories, load, byId, invalidate } + return { products, loaded, categories, load, replace, byId, invalidate } }) diff --git a/apps/user-app-v2/src/utils/filters.ts b/apps/user-app-v2/src/utils/filters.ts index 0fdd613..587b894 100644 --- a/apps/user-app-v2/src/utils/filters.ts +++ b/apps/user-app-v2/src/utils/filters.ts @@ -20,3 +20,19 @@ export function jdate(val: string | undefined | null): string { } return val as any } + +/** Jalali date + time for status timelines and similar detail surfaces. */ +export function jdatetime(val: string | undefined | null): string { + if (val) { + return moment(val).format('jYYYY/jMM/jDD HH:mm') + } + return val as any +} + +/** Compact timeline stamp — e.g. «25 مهر، 11:19» (reference card style). */ +export function jtimelineWhen(val: string | undefined | null): string { + if (val) { + return moment(val).format('jD jMMMM، HH:mm') + } + return val as any +} diff --git a/apps/user-app-v2/src/utils/orderStatus.ts b/apps/user-app-v2/src/utils/orderStatus.ts index ff66d1a..5da663e 100644 --- a/apps/user-app-v2/src/utils/orderStatus.ts +++ b/apps/user-app-v2/src/utils/orderStatus.ts @@ -1,59 +1,96 @@ -// Persian labels ported from the legacy Laravel Order::$STS enum (henza_server -// app/Models/Order.php) — the authoritative source, not henza_pwa's own -// Orders.vue filter-tab labels, which used a slightly different, inconsistent -// set (its key=0 was labeled "لغو شده"/cancelled, while the actual backend -// model's sts=0 means "معلق"/suspended — legacy itself was inconsistent here). -// Our OrderStatus enum cleanly separates SUSPENDED and CANCELLED, so mapped -// directly, one label per real status. +// Persian labels for the real OrderStatus enum (apps/api prisma schema). +// One label per status — shared by filter chips, list cards, and detail. export const ORDER_STATUS_LABELS: Record = { SUSPENDED: 'معلق', PLACED: 'ثبت سفارش', - ADMIN_APPROVED: 'تایید مدیر سیستم', - SOURCING: 'درحال تامین', - PREPARING: 'درحال آماده سازی', - DELIVERED_TO_AGENT: 'تحویل مسئول فروش', + ADMIN_APPROVED: 'تایید مدیر', + SOURCING: 'در حال تامین', + PREPARING: 'آماده‌سازی', + DELIVERED_TO_AGENT: 'تحویل نماینده', DELIVERED_TO_CUSTOMER: 'تحویل مشتری', CANCELLED: 'لغو شده', -}; +} + +/** Short blurbs under timeline titles (reference card style). */ +export const ORDER_STATUS_BLURBS: Record = { + SUSPENDED: 'سفارش به‌دلیل سقف اعتبار معلق شد', + PLACED: 'سفارش ثبت و دریافت شد', + ADMIN_APPROVED: 'سفارش توسط مدیر تایید شد', + SOURCING: 'کالا در حال تامین است', + PREPARING: 'سفارش در حال آماده‌سازی است', + DELIVERED_TO_AGENT: 'تحویل به نماینده فروش انجام شد', + DELIVERED_TO_CUSTOMER: 'سفارش به مشتری تحویل شد', + CANCELLED: 'سفارش لغو شده است', +} + +/** Line icons for the timeline nodes (vuesax-linear set). */ +export const ORDER_STATUS_ICONS: Record = { + SUSPENDED: 'vuesax-linear:bag-timer', + PLACED: 'vuesax-linear:bag-tick', + ADMIN_APPROVED: 'vuesax-linear:verify', + SOURCING: 'vuesax-linear:box-search', + PREPARING: 'vuesax-linear:box-1', + DELIVERED_TO_AGENT: 'vuesax-linear:truck-time', + DELIVERED_TO_CUSTOMER: 'vuesax-linear:location-tick', + CANCELLED: 'vuesax-linear:bag-cross', +} + +/** Happy-path pipeline shown in the detail stepper (excludes terminal SUSPENDED / CANCELLED). */ +export const ORDER_PIPELINE = [ + 'PLACED', + 'ADMIN_APPROVED', + 'SOURCING', + 'PREPARING', + 'DELIVERED_TO_AGENT', + 'DELIVERED_TO_CUSTOMER', +] as const + +export type OrderPipelineStatus = (typeof ORDER_PIPELINE)[number] + +/** Step titles = system status labels, in pipeline order. */ +export const ORDER_STEPS = ORDER_PIPELINE.map((status) => ({ + status, + title: ORDER_STATUS_LABELS[status]!, +})) -// Ported from legacy order.vue/Orders.vue's setColor() — legacy only ever -// colored sts 1,3,4,5,6 (never sts=0 or sts=2), an existing gap preserved here -// for the directly-analogous statuses (ADMIN_APPROVED, SUSPENDED, CANCELLED -// stay uncolored, same as legacy's untouched sts=0/2). -// -// Backgrounds are the font colour at 12% alpha (the trailing 1F), not the -// opaque pale tints they used to be: a translucent wash reads the same on a -// white card and stays a soft tint on a dark one, where #EFF6FF & co. were -// glaring light patches. export const ORDER_STATUS_COLORS: Record = { + SUSPENDED: { font: '#A16207', background: '#A162071F' }, PLACED: { font: '#3B82F6', background: '#3B82F61F' }, + ADMIN_APPROVED: { font: '#6366F1', background: '#6366F11F' }, SOURCING: { font: '#EAB308', background: '#EAB3081F' }, PREPARING: { font: '#F97316', background: '#F973161F' }, DELIVERED_TO_AGENT: { font: '#06B6D4', background: '#06B6D41F' }, DELIVERED_TO_CUSTOMER: { font: '#22C55E', background: '#22C55E1F' }, -}; + CANCELLED: { font: '#DC2626', background: '#DC26261F' }, +} -// Ported from legacy Orders.vue's 4-step tracker (`steps`/`setCurrentStep`). -// Legacy's ambiguous sts=0 (suspended vs cancelled) is resolved here since our -// enum keeps them distinct; SUSPENDED is grouped with the first step (not yet -// progressing), matching the spirit of legacy's grouping of early statuses. -export const ORDER_STEPS = ['ثبت سفارش', 'آماده سازی', 'تحویل مشتری', 'لغو شده']; +const FALLBACK_COLOR = { font: '#64748B', background: '#64748B1F' } +const FALLBACK_ICON = 'vuesax-linear:receipt-text' +export function orderStatusColor(status: string) { + return ORDER_STATUS_COLORS[status] ?? FALLBACK_COLOR +} + +export function orderStatusLabel(status: string) { + return ORDER_STATUS_LABELS[status] ?? status +} + +export function orderStatusBlurb(status: string) { + return ORDER_STATUS_BLURBS[status] ?? '' +} + +export function orderStatusIcon(status: string) { + return ORDER_STATUS_ICONS[status] ?? FALLBACK_ICON +} + +/** + * Index into ORDER_PIPELINE for the active step. + * CANCELLED → -1 (no pipeline progress). + * SUSPENDED → 0 (stuck at placement / first step). + */ export function orderStepIndex(status: string): number { - switch (status) { - case 'PLACED': - case 'ADMIN_APPROVED': - case 'SUSPENDED': - return 0; - case 'SOURCING': - case 'PREPARING': - return 1; - case 'DELIVERED_TO_AGENT': - case 'DELIVERED_TO_CUSTOMER': - return 2; - case 'CANCELLED': - return 3; - default: - return 0; - } + if (status === 'CANCELLED') return -1 + if (status === 'SUSPENDED') return 0 + const i = (ORDER_PIPELINE as readonly string[]).indexOf(status) + return i >= 0 ? i : 0 } diff --git a/apps/user-app-v2/src/views/CartView.vue b/apps/user-app-v2/src/views/CartView.vue index d3593e1..6c86f79 100644 --- a/apps/user-app-v2/src/views/CartView.vue +++ b/apps/user-app-v2/src/views/CartView.vue @@ -27,16 +27,12 @@ -->