Add NestJS fabric trade API with Prisma and Docker

Provide backend for admin and user apps with Postgres, migrations, seed data, and docker-compose for LiteSpeed reverse-proxy deployment.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-05 20:26:39 +03:30
co-authored by Cursor
commit 9f0422c1db
86 changed files with 18369 additions and 0 deletions
+232
View File
@@ -0,0 +1,232 @@
import {
ArrayMinSize,
IsArray,
IsDateString,
IsEnum,
IsNumber,
IsOptional,
IsString,
Min,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
import {
CheckRegistrationMode,
OrderStatus,
SettlementType,
} from '@prisma/client';
export class SubmitOrderItemDto {
@IsString()
productId!: string;
@Type(() => Number)
@IsNumber()
@Min(0.001)
meterage!: number;
@IsOptional()
@IsString()
notes?: string;
}
export class SubmitOrderCheckDto {
@Type(() => Number)
@IsNumber()
@Min(1)
amount!: number;
@IsDateString()
dueDate!: string;
@IsOptional()
@IsString()
sayadId?: string;
@IsOptional()
@IsString()
checkNumber?: string;
@IsOptional()
@IsString()
ownerNationalId?: string;
@IsOptional()
@IsString()
ownerFirstName?: string;
@IsOptional()
@IsString()
ownerLastName?: string;
@IsOptional()
@IsString()
bankName?: string;
@IsOptional()
@IsString()
branchName?: string;
@IsOptional()
@IsString()
frontImage?: string;
@IsOptional()
@IsString()
backImage?: string;
@IsOptional()
@IsString()
notes?: string;
@IsOptional()
@IsEnum(CheckRegistrationMode)
mode?: CheckRegistrationMode;
}
export class SubmitOrderDto {
@IsString()
companyId!: string;
@IsEnum(SettlementType)
settlementType!: SettlementType;
@IsOptional()
@IsEnum(CheckRegistrationMode)
checkMode?: CheckRegistrationMode;
@IsOptional()
@IsString()
notes?: string;
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => SubmitOrderItemDto)
items!: SubmitOrderItemDto[];
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => SubmitOrderCheckDto)
checks?: SubmitOrderCheckDto[];
}
export class AdminCreateOrderDto {
@IsOptional()
@IsString()
userId?: string;
@IsOptional()
@IsString()
customerPhone?: string;
@IsOptional()
@IsString()
customerName?: string;
@IsString()
companyId!: string;
@IsOptional()
@IsEnum(SettlementType)
settlementType?: SettlementType;
/** Alias for admin UI */
@IsOptional()
@IsEnum(SettlementType)
settlementMethod?: SettlementType;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
meterage?: number;
@IsOptional()
@IsString()
notes?: string;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => SubmitOrderItemDto)
items?: SubmitOrderItemDto[];
}
export class OrderItemPricingDto {
@IsString()
id!: string;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
confirmedMeterage?: number;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
unitPrice?: number;
/** Alias from admin UI */
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
supplyPrice?: number;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
unitDiscount?: number;
}
export class UpdateOrderPricingDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => OrderItemPricingDto)
items!: OrderItemPricingDto[];
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
shippingCost?: number;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
totalDiscount?: number;
@IsOptional()
@IsString()
note?: string;
}
export class UpdateOrderStatusDto {
@IsEnum(OrderStatus)
status!: OrderStatus;
@IsOptional()
@IsString()
note?: string;
@IsOptional()
@IsString()
reason?: string;
}
export class UpdateOrderChecksDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => SubmitOrderCheckDto)
checks!: SubmitOrderCheckDto[];
@IsOptional()
@IsEnum(CheckRegistrationMode)
checkMode?: CheckRegistrationMode;
}
+101
View File
@@ -0,0 +1,101 @@
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
import { OrdersService } from './orders.service';
import { AuthType } from '../common/decorators/auth.decorators';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import type { AuthPrincipal } from '../common/decorators/current-user.decorator';
import {
AdminCreateOrderDto,
SubmitOrderDto,
UpdateOrderChecksDto,
UpdateOrderPricingDto,
UpdateOrderStatusDto,
} from './dto/order.dto';
@Controller('orders')
export class CustomerOrdersController {
constructor(private readonly ordersService: OrdersService) {}
@AuthType('customer')
@Get()
findMine(@CurrentUser() user: AuthPrincipal) {
return this.ordersService.findMyOrders(user.id);
}
@AuthType('customer')
@Get(':id')
findOne(
@CurrentUser() user: AuthPrincipal,
@Param('id') id: string,
) {
return this.ordersService.findMyOrder(user.id, id);
}
@AuthType('customer')
@Post()
submit(
@CurrentUser() user: AuthPrincipal,
@Body() dto: SubmitOrderDto,
) {
return this.ordersService.submitOrder(user.id, dto);
}
@AuthType('customer')
@Patch(':id/checks')
updateChecks(
@CurrentUser() user: AuthPrincipal,
@Param('id') id: string,
@Body() dto: UpdateOrderChecksDto,
) {
return this.ordersService.updateChecks(user.id, id, dto);
}
}
@Controller('admin/orders')
@AuthType('admin')
export class AdminOrdersController {
constructor(private readonly ordersService: OrdersService) {}
@Get()
findAll(@Query('status') status?: string, @Query('q') q?: string) {
return this.ordersService.findAdminOrders({ status, q });
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.ordersService.findAdminOrder(id);
}
@Post()
create(
@CurrentUser() user: AuthPrincipal,
@Body() dto: AdminCreateOrderDto,
) {
return this.ordersService.adminCreate(user.id, dto);
}
@Patch(':id/pricing')
updatePricing(
@CurrentUser() user: AuthPrincipal,
@Param('id') id: string,
@Body() dto: UpdateOrderPricingDto,
) {
return this.ordersService.updatePricing(id, user.id, dto);
}
@Patch(':id/status')
updateStatus(
@CurrentUser() user: AuthPrincipal,
@Param('id') id: string,
@Body() dto: UpdateOrderStatusDto,
) {
return this.ordersService.updateStatus(id, user.id, dto);
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import {
AdminOrdersController,
CustomerOrdersController,
} from './orders.controller';
import { OrdersService } from './orders.service';
@Module({
controllers: [CustomerOrdersController, AdminOrdersController],
providers: [OrdersService],
exports: [OrdersService],
})
export class OrdersModule {}
+643
View File
@@ -0,0 +1,643 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import {
CheckRegistrationMode,
OrderStatus,
Prisma,
SettlementType,
} from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import {
AdminCreateOrderDto,
SubmitOrderCheckDto,
SubmitOrderDto,
UpdateOrderChecksDto,
UpdateOrderPricingDto,
UpdateOrderStatusDto,
} from './dto/order.dto';
import { generateOrderNumber } from '../common/utils/order-number';
import { calculateCheckRass } from '../common/utils/check-rass';
import { computeCashInvoiceExpiry } from '../common/utils/cash-expiry';
import {
resolveAdminStatusFilter,
toAdminStatus,
} from '../common/utils/order-status';
import { joinFullName, splitFullName } from '../common/utils/name';
import {
isValidPhone,
normalizePhone,
toEnglishDigits,
} from '../common/utils/phone';
const orderInclude = {
items: true,
checks: { orderBy: { row: 'asc' as const } },
cashReceipt: true,
statusHistory: { orderBy: { changedAt: 'asc' as const } },
shipment: true,
user: true,
company: true,
} satisfies Prisma.OrderInclude;
type OrderFull = Prisma.OrderGetPayload<{ include: typeof orderInclude }>;
@Injectable()
export class OrdersService {
constructor(private readonly prisma: PrismaService) {}
async findMyOrders(userId: string) {
const orders = await this.prisma.order.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
include: orderInclude,
});
return orders.map((o) => this.mapOrder(o));
}
async findMyOrder(userId: string, id: string) {
const order = await this.getOrder(id);
if (order.userId !== userId) {
throw new ForbiddenException('دسترسی به این سفارش مجاز نیست');
}
return this.mapOrder(order);
}
async findAdminOrders(filters: { status?: string; q?: string }) {
const statuses = resolveAdminStatusFilter(filters.status);
const q = filters.q?.trim();
const orders = await this.prisma.order.findMany({
where: {
...(statuses ? { status: { in: statuses } } : {}),
...(q
? {
OR: [
{ orderNumber: { contains: q, mode: 'insensitive' } },
{ companyName: { contains: q, mode: 'insensitive' } },
{
user: {
OR: [
{ firstName: { contains: q, mode: 'insensitive' } },
{ lastName: { contains: q, mode: 'insensitive' } },
{ phone: { contains: toEnglishDigits(q) } },
],
},
},
],
}
: {}),
},
orderBy: { createdAt: 'desc' },
include: orderInclude,
});
return orders.map((o) => this.mapOrder(o, true));
}
async findAdminOrder(id: string) {
const order = await this.getOrder(id);
return this.mapOrder(order, true);
}
async submitOrder(userId: string, dto: SubmitOrderDto) {
const company = await this.prisma.company.findUnique({
where: { id: dto.companyId },
});
if (!company || !company.isActive) {
throw new NotFoundException('شرکت یافت نشد یا غیرفعال است');
}
const productIds = dto.items.map((i) => i.productId);
const products = await this.prisma.product.findMany({
where: { id: { in: productIds }, companyId: company.id, isActive: true },
include: { catalog: true },
});
if (products.length !== productIds.length) {
throw new BadRequestException('یک یا چند محصول نامعتبر است');
}
const productMap = new Map(products.map((p) => [p.id, p]));
let totalMeterage = 0;
const itemsData = dto.items.map((item) => {
const product = productMap.get(item.productId)!;
if (item.meterage < product.minMeterage) {
throw new BadRequestException(
`حداقل متراژ برای ${product.name} برابر ${product.minMeterage} است`,
);
}
const rem =
Math.round((item.meterage / product.orderMultiplier) * 1000) / 1000;
if (Math.abs(rem - Math.round(rem)) > 1e-6) {
throw new BadRequestException(
`متراژ ${product.name} باید مضرب ${product.orderMultiplier} باشد`,
);
}
totalMeterage += item.meterage;
return {
productId: product.id,
catalogCode: product.catalog.code,
productName: product.name,
productCode: product.code,
color: product.color,
requestedMeterage: item.meterage,
unitPrice: product.basePrice,
notes: item.notes?.trim() || null,
};
});
if (totalMeterage > company.maxMeteragePerInvoice) {
throw new BadRequestException(
`حداکثر متراژ مجاز هر فاکتور ${company.maxMeteragePerInvoice} متر است`,
);
}
const settlementType = dto.settlementType;
if (settlementType === SettlementType.check) {
if (!dto.checks?.length) {
throw new BadRequestException('برای تسویه چکی حداقل یک چک لازم است');
}
}
const checkMode =
settlementType === SettlementType.check
? (dto.checkMode ?? CheckRegistrationMode.full)
: null;
const rass =
settlementType === SettlementType.check && dto.checks?.length
? calculateCheckRass(
dto.checks.map((c) => ({
amount: c.amount,
dueDate: c.dueDate,
})),
)
: null;
const cashInvoiceExpiresAt =
settlementType === SettlementType.cash
? computeCashInvoiceExpiry(company)
: null;
const orderNumber = await this.nextOrderNumber();
const order = await this.prisma.$transaction(async (tx) => {
const created = await tx.order.create({
data: {
orderNumber,
userId,
companyId: company.id,
companyName: company.name,
settlementType,
checkMode,
status: OrderStatus.awaiting_review,
totalMeterage,
notes: dto.notes?.trim() || null,
checkRassDays: rass?.rassDays ?? null,
checkRassDate: rass?.rassDate ? new Date(rass.rassDate) : null,
cashInvoiceExpiresAt,
items: { create: itemsData },
checks:
settlementType === SettlementType.check && dto.checks
? {
create: dto.checks.map((c, index) =>
this.mapCheckCreate(c, index + 1, checkMode!),
),
}
: undefined,
statusHistory: {
create: [
{
fromStatus: null,
toStatus: OrderStatus.submitted,
changedBy: 'customer',
note: 'سفارش ثبت شد',
},
{
fromStatus: OrderStatus.submitted,
toStatus: OrderStatus.awaiting_review,
changedBy: 'customer',
note: 'ارسال برای بررسی مدیریت',
},
],
},
},
include: orderInclude,
});
// Clear cart items for this company after successful submit
const cart = await tx.cart.findFirst({ where: { userId } });
if (cart) {
await tx.cartItem.deleteMany({
where: { cartId: cart.id, companyId: company.id },
});
}
return created;
});
return this.mapOrder(order);
}
async adminCreate(adminId: string, dto: AdminCreateOrderDto) {
const settlementType = dto.settlementType ?? dto.settlementMethod;
if (!settlementType) {
throw new BadRequestException('نوع تسویه الزامی است');
}
const company = await this.prisma.company.findUnique({
where: { id: dto.companyId },
});
if (!company) throw new NotFoundException('شرکت یافت نشد');
let userId = dto.userId;
if (!userId && dto.customerPhone) {
const phone = normalizePhone(toEnglishDigits(dto.customerPhone));
if (!isValidPhone(phone)) {
throw new BadRequestException('شماره موبایل نامعتبر است');
}
let customer = await this.prisma.customer.findUnique({ where: { phone } });
if (!customer) {
const names = splitFullName(dto.customerName || 'مشتری جدید');
customer = await this.prisma.customer.create({
data: {
phone,
firstName: names.firstName,
lastName: names.lastName || 'جدید',
},
});
}
userId = customer.id;
}
if (!userId) {
throw new BadRequestException('شناسه یا شماره مشتری الزامی است');
}
const orderNumber = await this.nextOrderNumber();
const totalMeterage = dto.meterage ?? 0;
const order = await this.prisma.order.create({
data: {
orderNumber,
userId,
companyId: company.id,
companyName: company.name,
settlementType,
status: OrderStatus.awaiting_review,
totalMeterage,
notes: dto.notes?.trim() || null,
statusHistory: {
create: {
fromStatus: null,
toStatus: OrderStatus.awaiting_review,
changedBy: `admin:${adminId}`,
note: 'سفارش توسط ادمین ثبت شد',
},
},
},
include: orderInclude,
});
return this.mapOrder(order, true);
}
async updatePricing(
id: string,
adminId: string,
dto: UpdateOrderPricingDto,
) {
const order = await this.getOrder(id);
await this.prisma.$transaction(async (tx) => {
let totalAmount = 0;
let totalMeterage = 0;
for (const item of dto.items) {
const existing = order.items.find((i) => i.id === item.id);
if (!existing) {
throw new BadRequestException(`آیتم ${item.id} یافت نشد`);
}
const unitPrice =
item.unitPrice !== undefined
? item.unitPrice
: item.supplyPrice !== undefined
? item.supplyPrice
: (existing.unitPrice ?? 0);
const unitDiscount = item.unitDiscount ?? existing.unitDiscount ?? 0;
const confirmed =
item.confirmedMeterage ??
existing.confirmedMeterage ??
existing.requestedMeterage;
const lineTotal = Math.max(0, (unitPrice - unitDiscount) * confirmed);
totalAmount += lineTotal;
totalMeterage += confirmed;
await tx.orderItem.update({
where: { id: item.id },
data: {
confirmedMeterage: confirmed,
unitPrice,
unitDiscount,
lineTotal,
},
});
}
const shippingCost = dto.shippingCost ?? order.shippingCost ?? 0;
const totalDiscount = dto.totalDiscount ?? order.totalDiscount ?? 0;
const finalAmount = Math.max(0, totalAmount + shippingCost - totalDiscount);
const nextStatus =
order.status === OrderStatus.awaiting_review ||
order.status === OrderStatus.pricing
? OrderStatus.pricing
: order.status;
await tx.order.update({
where: { id },
data: {
totalAmount,
totalMeterage,
shippingCost,
totalDiscount,
finalAmount,
status: nextStatus,
},
});
if (nextStatus !== order.status) {
await tx.orderStatusHistory.create({
data: {
orderId: id,
fromStatus: order.status,
toStatus: nextStatus,
changedBy: `admin:${adminId}`,
note: dto.note || 'قیمت‌گذاری انجام شد',
},
});
}
});
return this.findAdminOrder(id);
}
async updateStatus(
id: string,
adminId: string,
dto: UpdateOrderStatusDto,
) {
const order = await this.getOrder(id);
if (order.status === dto.status) {
return this.mapOrder(order, true);
}
await this.prisma.$transaction(async (tx) => {
await tx.order.update({
where: { id },
data: { status: dto.status },
});
await tx.orderStatusHistory.create({
data: {
orderId: id,
fromStatus: order.status,
toStatus: dto.status,
changedBy: `admin:${adminId}`,
note: dto.note || null,
reason: dto.reason || null,
},
});
// Side effects for common transitions
if (
dto.status === OrderStatus.cash_invoice_issued ||
dto.status === OrderStatus.awaiting_cash_payment
) {
if (!order.cashInvoiceExpiresAt) {
await tx.order.update({
where: { id },
data: {
cashInvoiceExpiresAt: computeCashInvoiceExpiry(order.company),
},
});
}
}
});
return this.findAdminOrder(id);
}
async updateChecks(userId: string, id: string, dto: UpdateOrderChecksDto) {
const order = await this.getOrder(id);
if (order.userId !== userId) {
throw new ForbiddenException('دسترسی به این سفارش مجاز نیست');
}
if (order.settlementType !== SettlementType.check) {
throw new BadRequestException('این سفارش تسویه چکی نیست');
}
const checkMode = dto.checkMode ?? order.checkMode ?? CheckRegistrationMode.full;
const rass = calculateCheckRass(
dto.checks.map((c) => ({ amount: c.amount, dueDate: c.dueDate })),
order.createdAt,
);
await this.prisma.$transaction(async (tx) => {
await tx.check.deleteMany({ where: { orderId: id } });
await tx.check.createMany({
data: dto.checks.map((c, index) => ({
orderId: id,
...this.mapCheckCreate(c, index + 1, checkMode),
})),
});
await tx.order.update({
where: { id },
data: {
checkMode,
checkRassDays: rass.rassDays,
checkRassDate: rass.rassDate ? new Date(rass.rassDate) : null,
status:
order.status === OrderStatus.check_needs_fix
? OrderStatus.awaiting_check_review
: order.status,
},
});
if (order.status === OrderStatus.check_needs_fix) {
await tx.orderStatusHistory.create({
data: {
orderId: id,
fromStatus: order.status,
toStatus: OrderStatus.awaiting_check_review,
changedBy: 'customer',
note: 'چک‌ها اصلاح و مجدداً ارسال شد',
},
});
}
});
return this.findMyOrder(userId, id);
}
private mapCheckCreate(
c: SubmitOrderCheckDto,
row: number,
defaultMode: CheckRegistrationMode,
) {
return {
row,
amount: c.amount,
dueDate: new Date(c.dueDate),
sayadId: c.sayadId?.trim() || null,
checkNumber: c.checkNumber?.trim() || null,
ownerNationalId: c.ownerNationalId?.trim() || null,
ownerFirstName: c.ownerFirstName?.trim() || null,
ownerLastName: c.ownerLastName?.trim() || null,
bankName: c.bankName?.trim() || null,
branchName: c.branchName?.trim() || null,
frontImage: c.frontImage || null,
backImage: c.backImage || null,
notes: c.notes?.trim() || null,
mode: c.mode ?? defaultMode,
};
}
private async nextOrderNumber(): Promise<string> {
for (let i = 0; i < 5; i++) {
const orderNumber = generateOrderNumber();
const exists = await this.prisma.order.findUnique({
where: { orderNumber },
});
if (!exists) return orderNumber;
}
return `${generateOrderNumber()}-${Date.now().toString().slice(-3)}`;
}
private async getOrder(id: string): Promise<OrderFull> {
const order = await this.prisma.order.findUnique({
where: { id },
include: orderInclude,
});
if (!order) throw new NotFoundException('سفارش یافت نشد');
return order;
}
mapOrder(order: OrderFull, adminView = false) {
const customerName = joinFullName(
order.user.firstName,
order.user.lastName,
);
const base = {
id: order.id,
orderNumber: order.orderNumber,
userId: order.userId,
companyId: order.companyId,
companyName: order.companyName,
settlementType: order.settlementType,
settlementMethod: order.settlementType,
checkMode: order.checkMode ?? undefined,
status: order.status,
adminStatus: toAdminStatus(order.status),
totalMeterage: order.totalMeterage,
meterage: order.totalMeterage,
totalAmount: order.totalAmount,
shippingCost: order.shippingCost,
totalDiscount: order.totalDiscount,
finalAmount: order.finalAmount,
notes: order.notes ?? undefined,
checkRassDays: order.checkRassDays ?? undefined,
checkRassDate: order.checkRassDate?.toISOString(),
cashInvoiceExpiresAt: order.cashInvoiceExpiresAt?.toISOString(),
createdAt: order.createdAt.toISOString(),
updatedAt: order.updatedAt.toISOString(),
customerName,
customerPhone: order.user.phone,
customerNationalId: order.user.nationalId ?? undefined,
customerType: order.user.customerType,
items: order.items.map((item) => ({
id: item.id,
productId: item.productId ?? undefined,
catalogCode: item.catalogCode,
productName: item.productName,
productCode: item.productCode ?? undefined,
color: item.color,
requestedMeterage: item.requestedMeterage,
confirmedMeterage: item.confirmedMeterage,
unitPrice: item.unitPrice,
supplyPrice: item.unitPrice,
unitDiscount: item.unitDiscount ?? 0,
discount: item.unitDiscount ?? 0,
lineTotal: item.lineTotal,
totalAmount: item.lineTotal,
notes: item.notes ?? undefined,
})),
checks: order.checks.map((c) => ({
id: c.id,
orderId: c.orderId,
row: c.row,
amount: c.amount,
dueDate: c.dueDate.toISOString(),
sayadId: c.sayadId ?? undefined,
checkNumber: c.checkNumber ?? undefined,
ownerNationalId: c.ownerNationalId ?? undefined,
ownerFirstName: c.ownerFirstName ?? undefined,
ownerLastName: c.ownerLastName ?? undefined,
bankName: c.bankName ?? '',
branchName: c.branchName ?? undefined,
frontImage: c.frontImage ?? undefined,
backImage: c.backImage ?? undefined,
notes: c.notes ?? undefined,
mode: c.mode,
status: c.status,
createdAt: c.createdAt.toISOString(),
updatedAt: c.updatedAt.toISOString(),
})),
cashReceipt: order.cashReceipt
? {
id: order.cashReceipt.id,
amount: order.cashReceipt.amount,
paidAmount: order.cashReceipt.paidAmount,
transferDate: order.cashReceipt.transferDate?.toISOString() ?? null,
transferTime: order.cashReceipt.transferTime ?? undefined,
trackingNumber: order.cashReceipt.trackingNumber ?? undefined,
trackingCode: order.cashReceipt.trackingNumber ?? undefined,
destinationAccountId:
order.cashReceipt.destinationAccountId ?? undefined,
receiptImage: order.cashReceipt.receiptImage ?? undefined,
notes: order.cashReceipt.notes ?? undefined,
expiresAt:
order.cashReceipt.expiresAt?.toISOString() ??
order.cashInvoiceExpiresAt?.toISOString(),
status: order.cashReceipt.status,
submittedAt: order.cashReceipt.submittedAt?.toISOString(),
createdAt: order.cashReceipt.createdAt.toISOString(),
}
: undefined,
shipment: order.shipment
? {
id: order.shipment.id,
orderId: order.shipment.orderId,
carrier: order.shipment.carrier,
trackingNumber: order.shipment.trackingNumber ?? '',
meterage: order.shipment.meterage,
status: order.shipment.status,
shippedAt: order.shipment.shippedAt?.toISOString() ?? null,
deliveredAt: order.shipment.deliveredAt?.toISOString() ?? null,
}
: undefined,
statusHistory: order.statusHistory.map((h) => ({
id: h.id,
fromStatus: h.fromStatus,
toStatus: h.toStatus,
changedBy: h.changedBy,
note: h.note ?? undefined,
reason: h.reason ?? undefined,
changedAt: h.changedAt.toISOString(),
})),
};
if (adminView) {
return base;
}
return base;
}
}