Enhance pagination and ordering capabilities in API responses
- Updated pagination-response and read-write-controllers documentation to include `orderBy` and `orderType` parameters for sorting results. - Introduced new `order-clause` module to handle ordering logic, including validation for order types and columns. - Enhanced `PaginationQueryDto` to support ordering fields in API requests. - Updated various repository and service classes to implement ordering in database queries. - Added unit tests for new ordering functionality and ensured existing tests cover the updated behavior. - Refactored related DTOs to include user and code relations for better data representation in responses.
This commit is contained in:
@@ -15,7 +15,12 @@ import {
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { PaginationQueryDto } from '../../../../common/http/response';
|
||||
import {
|
||||
CodeRelationDto,
|
||||
DefaultRelationDto,
|
||||
PaginationQueryDto,
|
||||
UserRelationDto,
|
||||
} from '../../../../common/http/response';
|
||||
import {
|
||||
DOCUMENT_ADDRESS_MAX_LENGTH,
|
||||
DOCUMENT_CODE_MAX_LENGTH,
|
||||
@@ -217,14 +222,12 @@ export class PackingSlipDto {
|
||||
id!: string;
|
||||
@ApiProperty()
|
||||
code!: string;
|
||||
@ApiPropertyOptional({ nullable: true, format: 'uuid' })
|
||||
salesOrderId!: string | null;
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
salesOrderNumber!: string | null;
|
||||
@ApiPropertyOptional({ type: CodeRelationDto, nullable: true })
|
||||
salesOrder!: CodeRelationDto | null;
|
||||
@ApiProperty()
|
||||
date!: number;
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
customerId!: string;
|
||||
@ApiProperty({ type: DefaultRelationDto, nullable: true })
|
||||
customer!: DefaultRelationDto | null;
|
||||
@ApiProperty()
|
||||
address!: string;
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
@@ -239,8 +242,8 @@ export class PackingSlipDto {
|
||||
createdAt!: number;
|
||||
@ApiProperty()
|
||||
updatedAt!: number;
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
createdBy!: string;
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
updatedBy!: string;
|
||||
@ApiProperty({ type: UserRelationDto })
|
||||
createdBy!: UserRelationDto;
|
||||
@ApiProperty({ type: UserRelationDto })
|
||||
updatedBy!: UserRelationDto;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import type {
|
||||
CodeRelation,
|
||||
DefaultRelation,
|
||||
UserRelation,
|
||||
} from '../../../common/http/response';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
@@ -5,6 +10,7 @@ import { Status } from '../../../common/value-objects/status/status';
|
||||
export type PackingSlipLine = {
|
||||
readonly id: string;
|
||||
readonly productId: string;
|
||||
readonly product: DefaultRelation | null;
|
||||
readonly quantity: Decimal;
|
||||
readonly price: Decimal;
|
||||
};
|
||||
@@ -26,6 +32,10 @@ export type PackingSlip = {
|
||||
readonly updatedAt: DateTime;
|
||||
readonly createdBy: string;
|
||||
readonly updatedBy: string;
|
||||
readonly salesOrder: CodeRelation | null;
|
||||
readonly customer: DefaultRelation | null;
|
||||
readonly createdByUser: UserRelation;
|
||||
readonly updatedByUser: UserRelation;
|
||||
};
|
||||
|
||||
export type PackingSlipLineInput = {
|
||||
@@ -69,6 +79,8 @@ export type ListPackingSlipsFilters = {
|
||||
readonly customerId?: string;
|
||||
readonly salesOrderId?: string;
|
||||
readonly search?: string;
|
||||
readonly orderBy?: string;
|
||||
readonly orderType?: string;
|
||||
readonly limit: number;
|
||||
readonly offset: number;
|
||||
};
|
||||
|
||||
@@ -4,7 +4,16 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { and, asc, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm';
|
||||
import { and, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm';
|
||||
import { toOrderClauses } from '../../../common/http/response';
|
||||
import {
|
||||
catalogRelationFromMap,
|
||||
codeRelationFromMap,
|
||||
loadCustomerRelationMap,
|
||||
loadProductRelationMap,
|
||||
loadSalesOrderRelationMap,
|
||||
} from '../../../database/load-catalog-refs';
|
||||
import { attachAuditUsers } from '../../../database/load-user-refs';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
@@ -26,6 +35,15 @@ import type {
|
||||
UpdatePackingSlipInput,
|
||||
} from './packing-slip';
|
||||
|
||||
const PACKING_SLIP_ORDER_COLUMNS = {
|
||||
id: packingSlips.id,
|
||||
code: packingSlips.code,
|
||||
date: packingSlips.date,
|
||||
status: packingSlips.status,
|
||||
createdAt: packingSlips.createdAt,
|
||||
updatedAt: packingSlips.updatedAt,
|
||||
};
|
||||
|
||||
type QueryExecutor = Pick<DrizzleDB, 'select' | 'insert' | 'delete'>;
|
||||
|
||||
@Injectable()
|
||||
@@ -47,11 +65,15 @@ export class PackingSlipsRepository {
|
||||
.select()
|
||||
.from(packingSlips)
|
||||
.where(where)
|
||||
.orderBy(asc(packingSlips.code))
|
||||
.orderBy(
|
||||
...toOrderClauses(PACKING_SLIP_ORDER_COLUMNS, filters, [
|
||||
{ column: 'code', type: 'ASC' },
|
||||
]),
|
||||
)
|
||||
.limit(filters.limit)
|
||||
.offset(filters.offset);
|
||||
return {
|
||||
data: rows.map((row) => this.toDomain(row, [])),
|
||||
data: await this.hydrate(rows.map((row) => this.toDomain(row, []))),
|
||||
total: Number(totalRows[0]?.total ?? 0),
|
||||
};
|
||||
}
|
||||
@@ -67,7 +89,7 @@ export class PackingSlipsRepository {
|
||||
return null;
|
||||
}
|
||||
const products = await this.selectProducts(this.db, id);
|
||||
return this.toDomain(row, products);
|
||||
return this.hydrateOne(row, products);
|
||||
}
|
||||
|
||||
async create(input: CreatePackingSlipInput): Promise<PackingSlip> {
|
||||
@@ -90,7 +112,7 @@ export class PackingSlipsRepository {
|
||||
const row = inserted[0];
|
||||
await this.replaceProducts(tx, row.id, input.products);
|
||||
const products = await this.selectProducts(tx, row.id);
|
||||
return this.toDomain(row, products);
|
||||
return this.hydrateOne(row, products);
|
||||
});
|
||||
} catch (error) {
|
||||
this.rethrowConstraintViolation(error);
|
||||
@@ -153,7 +175,7 @@ export class PackingSlipsRepository {
|
||||
await this.replaceProducts(tx, id, input.products);
|
||||
}
|
||||
const products = await this.selectProducts(tx, id);
|
||||
return this.toDomain(row, products);
|
||||
return this.hydrateOne(row, products);
|
||||
});
|
||||
} catch (error) {
|
||||
this.rethrowConstraintViolation(error);
|
||||
@@ -180,7 +202,7 @@ export class PackingSlipsRepository {
|
||||
throw new NotFoundException('Packing slip not found');
|
||||
}
|
||||
const products = await this.selectProducts(this.db, id);
|
||||
return this.toDomain(row, products);
|
||||
return this.hydrateOne(row, products);
|
||||
}
|
||||
|
||||
async bulkUpdateStatus(
|
||||
@@ -329,6 +351,7 @@ export class PackingSlipsRepository {
|
||||
products: productRows.map((line) => ({
|
||||
id: line.id,
|
||||
productId: line.productId,
|
||||
product: null,
|
||||
quantity: Decimal.create(line.quantity),
|
||||
price: Decimal.create(line.price),
|
||||
})),
|
||||
@@ -337,9 +360,52 @@ export class PackingSlipsRepository {
|
||||
updatedAt: DateTime.fromUnixMs(row.updatedAt),
|
||||
createdBy: row.createdBy,
|
||||
updatedBy: row.updatedBy,
|
||||
salesOrder: null,
|
||||
customer: null,
|
||||
createdByUser: { id: row.createdBy, username: '' },
|
||||
updatedByUser: { id: row.updatedBy, username: '' },
|
||||
};
|
||||
}
|
||||
|
||||
private async hydrate(items: PackingSlip[]): Promise<PackingSlip[]> {
|
||||
const withAudit = await attachAuditUsers(this.db, items);
|
||||
const [customers, orders, products] = await Promise.all([
|
||||
loadCustomerRelationMap(
|
||||
this.db,
|
||||
withAudit.map((item) => item.customerId),
|
||||
),
|
||||
loadSalesOrderRelationMap(
|
||||
this.db,
|
||||
withAudit
|
||||
.map((item) => item.salesOrderId)
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
),
|
||||
loadProductRelationMap(
|
||||
this.db,
|
||||
withAudit.flatMap((item) =>
|
||||
item.products.map((line) => line.productId),
|
||||
),
|
||||
),
|
||||
]);
|
||||
return withAudit.map((item) => ({
|
||||
...item,
|
||||
customer: catalogRelationFromMap(customers, item.customerId),
|
||||
salesOrder: codeRelationFromMap(orders, item.salesOrderId),
|
||||
products: item.products.map((line) => ({
|
||||
...line,
|
||||
product: catalogRelationFromMap(products, line.productId),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
private async hydrateOne(
|
||||
row: PackingSlipRow,
|
||||
products: PackingSlipProductRow[],
|
||||
): Promise<PackingSlip> {
|
||||
const [item] = await this.hydrate([this.toDomain(row, products)]);
|
||||
return item;
|
||||
}
|
||||
|
||||
private rethrowConstraintViolation(error: unknown): never {
|
||||
if (error instanceof NotFoundException) {
|
||||
throw error;
|
||||
|
||||
@@ -57,6 +57,7 @@ describe('PackingSlipsService', () => {
|
||||
{
|
||||
id: 'line-1',
|
||||
productId: 'prd-1',
|
||||
product: { id: 'prd-1', code: 'P1', name: 'Fuel' },
|
||||
quantity: Decimal.create('2'),
|
||||
price: Decimal.create('12500'),
|
||||
},
|
||||
@@ -66,6 +67,10 @@ describe('PackingSlipsService', () => {
|
||||
updatedAt: now,
|
||||
createdBy: 'user-1',
|
||||
updatedBy: 'user-1',
|
||||
salesOrder: null,
|
||||
customer: { id: 'cus-1', code: 'C1', name: 'Acme' },
|
||||
createdByUser: { id: 'user-1', username: 'admin' },
|
||||
updatedByUser: { id: 'user-1', username: 'admin' },
|
||||
};
|
||||
|
||||
const createBody = {
|
||||
|
||||
@@ -4,7 +4,13 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type { PaginationResponse } from '../../../common/http/response';
|
||||
import { toListPage } from '../../../common/http/response';
|
||||
import {
|
||||
DEFAULT_RELATION_FIELDS,
|
||||
pickCodeRelation,
|
||||
pickRelation,
|
||||
pickUserRelation,
|
||||
toListPage,
|
||||
} from '../../../common/http/response';
|
||||
import { InvalidDateTimeError } from '../../../common/value-objects/date-time/invalid-date-time.error';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||
@@ -42,6 +48,8 @@ export type ListPackingSlipsQuery = {
|
||||
readonly customerId?: string;
|
||||
readonly salesOrderId?: string;
|
||||
readonly search?: string;
|
||||
readonly orderBy?: string;
|
||||
readonly orderType?: string;
|
||||
readonly page?: number;
|
||||
readonly limit?: number;
|
||||
readonly offset?: number;
|
||||
@@ -70,6 +78,8 @@ export class PackingSlipsService {
|
||||
customerId: query.customerId,
|
||||
salesOrderId: query.salesOrderId,
|
||||
search: query.search,
|
||||
orderBy: query.orderBy,
|
||||
orderType: query.orderType,
|
||||
limit: page.limit,
|
||||
offset: page.offset,
|
||||
});
|
||||
@@ -272,10 +282,9 @@ export class PackingSlipsService {
|
||||
return {
|
||||
id: item.id,
|
||||
code: item.code,
|
||||
salesOrderId: item.salesOrderId,
|
||||
salesOrderNumber: item.salesOrderNumber,
|
||||
salesOrder: pickCodeRelation(item.salesOrder),
|
||||
date: item.date.value,
|
||||
customerId: item.customerId,
|
||||
customer: pickRelation(item.customer, DEFAULT_RELATION_FIELDS),
|
||||
address: item.address,
|
||||
latitude: item.latitude,
|
||||
longitude: item.longitude,
|
||||
@@ -283,8 +292,8 @@ export class PackingSlipsService {
|
||||
status: item.status.value,
|
||||
createdAt: item.createdAt.value,
|
||||
updatedAt: item.updatedAt.value,
|
||||
createdBy: item.createdBy,
|
||||
updatedBy: item.updatedBy,
|
||||
createdBy: pickUserRelation(item.createdByUser),
|
||||
updatedBy: pickUserRelation(item.updatedByUser),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -293,7 +302,7 @@ export class PackingSlipsService {
|
||||
...this.toListItem(item),
|
||||
products: item.products.map((line) => ({
|
||||
id: line.id,
|
||||
productId: line.productId,
|
||||
product: pickRelation(line.product, DEFAULT_RELATION_FIELDS),
|
||||
quantity: line.quantity.value,
|
||||
price: line.price.value,
|
||||
})),
|
||||
@@ -331,7 +340,7 @@ export class PackingSlipsService {
|
||||
salesOrderId: input.salesOrderId,
|
||||
salesOrderNumber: source.code,
|
||||
date: input.date ?? DateTime.fromUnixMs(source.date).format(),
|
||||
customerId: input.customerId ?? source.customerId,
|
||||
customerId: input.customerId ?? source.customer?.id ?? '',
|
||||
address: input.address ?? source.address,
|
||||
latitude: input.latitude !== undefined ? input.latitude : source.latitude,
|
||||
longitude:
|
||||
@@ -340,7 +349,7 @@ export class PackingSlipsService {
|
||||
products:
|
||||
input.products ??
|
||||
source.products.map((line) => ({
|
||||
productId: line.productId,
|
||||
productId: line.product?.id ?? '',
|
||||
quantity: line.quantity,
|
||||
price: line.price,
|
||||
})),
|
||||
|
||||
@@ -12,7 +12,12 @@ import {
|
||||
MaxLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { PaginationQueryDto } from '../../../../common/http/response';
|
||||
import {
|
||||
CodeRelationDto,
|
||||
DefaultRelationDto,
|
||||
PaginationQueryDto,
|
||||
UserRelationDto,
|
||||
} from '../../../../common/http/response';
|
||||
import {
|
||||
DOCUMENT_CODE_MAX_LENGTH,
|
||||
DOCUMENT_CODE_PATTERN,
|
||||
@@ -232,24 +237,20 @@ export class SalesInvoiceDto {
|
||||
id!: string;
|
||||
@ApiProperty()
|
||||
code!: string;
|
||||
@ApiPropertyOptional({ nullable: true, format: 'uuid' })
|
||||
salesOrderId!: string | null;
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
salesOrderCode!: string | null;
|
||||
@ApiPropertyOptional({ nullable: true, format: 'uuid' })
|
||||
packingSlipId!: string | null;
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
packingSlipCode!: string | null;
|
||||
@ApiPropertyOptional({ type: CodeRelationDto, nullable: true })
|
||||
salesOrder!: CodeRelationDto | null;
|
||||
@ApiPropertyOptional({ type: CodeRelationDto, nullable: true })
|
||||
packingSlip!: CodeRelationDto | null;
|
||||
@ApiProperty()
|
||||
date!: number;
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
salesPersonId!: string;
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
branchId!: string;
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
divisionId!: string;
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
customerId!: string;
|
||||
@ApiProperty({ type: DefaultRelationDto, nullable: true })
|
||||
salesPerson!: DefaultRelationDto | null;
|
||||
@ApiProperty({ type: DefaultRelationDto, nullable: true })
|
||||
branch!: DefaultRelationDto | null;
|
||||
@ApiProperty({ type: DefaultRelationDto, nullable: true })
|
||||
division!: DefaultRelationDto | null;
|
||||
@ApiProperty({ type: DefaultRelationDto, nullable: true })
|
||||
customer!: DefaultRelationDto | null;
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
notes!: string | null;
|
||||
@ApiProperty()
|
||||
@@ -260,8 +261,8 @@ export class SalesInvoiceDto {
|
||||
createdAt!: number;
|
||||
@ApiProperty()
|
||||
updatedAt!: number;
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
createdBy!: string;
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
updatedBy!: string;
|
||||
@ApiProperty({ type: UserRelationDto })
|
||||
createdBy!: UserRelationDto;
|
||||
@ApiProperty({ type: UserRelationDto })
|
||||
updatedBy!: UserRelationDto;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import type {
|
||||
CodeRelation,
|
||||
DefaultRelation,
|
||||
UserRelation,
|
||||
} from '../../../common/http/response';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
@@ -5,6 +10,7 @@ import { Status } from '../../../common/value-objects/status/status';
|
||||
export type SalesInvoiceLine = {
|
||||
readonly id: string;
|
||||
readonly productId: string;
|
||||
readonly product: DefaultRelation | null;
|
||||
readonly quantity: Decimal;
|
||||
readonly price: Decimal;
|
||||
};
|
||||
@@ -29,6 +35,14 @@ export type SalesInvoice = {
|
||||
readonly updatedAt: DateTime;
|
||||
readonly createdBy: string;
|
||||
readonly updatedBy: string;
|
||||
readonly salesOrder: CodeRelation | null;
|
||||
readonly packingSlip: CodeRelation | null;
|
||||
readonly salesPerson: DefaultRelation | null;
|
||||
readonly branch: DefaultRelation | null;
|
||||
readonly division: DefaultRelation | null;
|
||||
readonly customer: DefaultRelation | null;
|
||||
readonly createdByUser: UserRelation;
|
||||
readonly updatedByUser: UserRelation;
|
||||
};
|
||||
|
||||
export type SalesInvoiceLineInput = {
|
||||
@@ -80,6 +94,8 @@ export type ListSalesInvoicesFilters = {
|
||||
readonly salesOrderId?: string;
|
||||
readonly packingSlipId?: string;
|
||||
readonly search?: string;
|
||||
readonly orderBy?: string;
|
||||
readonly orderType?: string;
|
||||
readonly limit: number;
|
||||
readonly offset: number;
|
||||
};
|
||||
|
||||
@@ -5,6 +5,15 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { and, asc, count, eq, ilike, inArray, or, SQL, sum } from 'drizzle-orm';
|
||||
import { toOrderClauses } from '../../../common/http/response';
|
||||
import {
|
||||
catalogRelationFromMap,
|
||||
codeRelationFromMap,
|
||||
loadPackingSlipRelationMap,
|
||||
loadProductRelationMap,
|
||||
loadSalesOrderRelationMap,
|
||||
} from '../../../database/load-catalog-refs';
|
||||
import { attachSalesHeaderRelations } from '../shared/attach-sales-relations';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
@@ -30,6 +39,15 @@ import type {
|
||||
UpdateSalesInvoiceInput,
|
||||
} from './sales-invoice';
|
||||
|
||||
const SALES_INVOICE_ORDER_COLUMNS = {
|
||||
id: salesInvoices.id,
|
||||
code: salesInvoices.code,
|
||||
date: salesInvoices.date,
|
||||
status: salesInvoices.status,
|
||||
createdAt: salesInvoices.createdAt,
|
||||
updatedAt: salesInvoices.updatedAt,
|
||||
};
|
||||
|
||||
type QueryExecutor = Pick<DrizzleDB, 'select' | 'insert' | 'delete' | 'update'>;
|
||||
|
||||
export type InvoiceTotals = {
|
||||
@@ -57,11 +75,15 @@ export class SalesInvoicesRepository {
|
||||
.select()
|
||||
.from(salesInvoices)
|
||||
.where(where)
|
||||
.orderBy(asc(salesInvoices.code))
|
||||
.orderBy(
|
||||
...toOrderClauses(SALES_INVOICE_ORDER_COLUMNS, filters, [
|
||||
{ column: 'code', type: 'ASC' },
|
||||
]),
|
||||
)
|
||||
.limit(filters.limit)
|
||||
.offset(filters.offset);
|
||||
return {
|
||||
data: rows.map((row) => this.toDomain(row, [])),
|
||||
data: await this.hydrate(rows.map((row) => this.toDomain(row, []))),
|
||||
total: Number(totalRows[0]?.total ?? 0),
|
||||
};
|
||||
}
|
||||
@@ -77,7 +99,7 @@ export class SalesInvoicesRepository {
|
||||
return null;
|
||||
}
|
||||
const products = await this.selectProducts(this.db, id);
|
||||
return this.toDomain(row, products);
|
||||
return this.hydrateOne(row, products);
|
||||
}
|
||||
|
||||
async create(input: CreateSalesInvoiceInput): Promise<SalesInvoice> {
|
||||
@@ -101,7 +123,7 @@ export class SalesInvoicesRepository {
|
||||
await this.replaceProducts(tx, row.id, input.products);
|
||||
const products = await this.selectProducts(tx, row.id);
|
||||
const withBalance = await this.refreshBalance(tx, row.id, products);
|
||||
return this.toDomain(withBalance, products);
|
||||
return this.hydrateOne(withBalance, products);
|
||||
});
|
||||
} catch (error) {
|
||||
this.rethrowConstraintViolation(error);
|
||||
@@ -169,7 +191,7 @@ export class SalesInvoicesRepository {
|
||||
}
|
||||
const products = await this.selectProducts(tx, id);
|
||||
const withBalance = await this.refreshBalance(tx, id, products);
|
||||
return this.toDomain(withBalance, products);
|
||||
return this.hydrateOne(withBalance, products);
|
||||
});
|
||||
} catch (error) {
|
||||
this.rethrowConstraintViolation(error);
|
||||
@@ -196,7 +218,7 @@ export class SalesInvoicesRepository {
|
||||
throw new NotFoundException('Sales invoice not found');
|
||||
}
|
||||
const products = await this.selectProducts(this.db, id);
|
||||
return this.toDomain(row, products);
|
||||
return this.hydrateOne(row, products);
|
||||
}
|
||||
|
||||
async bulkUpdateStatus(
|
||||
@@ -249,7 +271,7 @@ export class SalesInvoicesRepository {
|
||||
async refreshStoredBalance(invoiceId: string): Promise<SalesInvoice> {
|
||||
const products = await this.selectProducts(this.db, invoiceId);
|
||||
const row = await this.refreshBalance(this.db, invoiceId, products);
|
||||
return this.toDomain(row, products);
|
||||
return this.hydrateOne(row, products);
|
||||
}
|
||||
|
||||
private async totalsFrom(
|
||||
@@ -419,6 +441,7 @@ export class SalesInvoicesRepository {
|
||||
products: productRows.map((line) => ({
|
||||
id: line.id,
|
||||
productId: line.productId,
|
||||
product: null,
|
||||
quantity: Decimal.create(line.quantity),
|
||||
price: Decimal.create(line.price),
|
||||
})),
|
||||
@@ -427,9 +450,58 @@ export class SalesInvoicesRepository {
|
||||
updatedAt: DateTime.fromUnixMs(row.updatedAt),
|
||||
createdBy: row.createdBy,
|
||||
updatedBy: row.updatedBy,
|
||||
salesOrder: null,
|
||||
packingSlip: null,
|
||||
salesPerson: null,
|
||||
branch: null,
|
||||
division: null,
|
||||
customer: null,
|
||||
createdByUser: { id: row.createdBy, username: '' },
|
||||
updatedByUser: { id: row.updatedBy, username: '' },
|
||||
};
|
||||
}
|
||||
|
||||
private async hydrate(items: SalesInvoice[]): Promise<SalesInvoice[]> {
|
||||
const withHeader = await attachSalesHeaderRelations(this.db, items);
|
||||
const [orders, packing, products] = await Promise.all([
|
||||
loadSalesOrderRelationMap(
|
||||
this.db,
|
||||
withHeader
|
||||
.map((item) => item.salesOrderId)
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
),
|
||||
loadPackingSlipRelationMap(
|
||||
this.db,
|
||||
withHeader
|
||||
.map((item) => item.packingSlipId)
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
),
|
||||
loadProductRelationMap(
|
||||
this.db,
|
||||
withHeader.flatMap((item) =>
|
||||
item.products.map((line) => line.productId),
|
||||
),
|
||||
),
|
||||
]);
|
||||
return withHeader.map((item) => ({
|
||||
...item,
|
||||
salesOrder: codeRelationFromMap(orders, item.salesOrderId),
|
||||
packingSlip: codeRelationFromMap(packing, item.packingSlipId),
|
||||
products: item.products.map((line) => ({
|
||||
...line,
|
||||
product: catalogRelationFromMap(products, line.productId),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
private async hydrateOne(
|
||||
row: SalesInvoiceRow,
|
||||
products: SalesInvoiceProductRow[],
|
||||
): Promise<SalesInvoice> {
|
||||
const [item] = await this.hydrate([this.toDomain(row, products)]);
|
||||
return item;
|
||||
}
|
||||
|
||||
private rethrowConstraintViolation(error: unknown): never {
|
||||
if (error instanceof NotFoundException) {
|
||||
throw error;
|
||||
|
||||
@@ -72,6 +72,7 @@ describe('SalesInvoicesService', () => {
|
||||
{
|
||||
id: 'line-1',
|
||||
productId: 'prd-1',
|
||||
product: { id: 'prd-1', code: 'P1', name: 'Fuel' },
|
||||
quantity: Decimal.create('2'),
|
||||
price: Decimal.create('12500'),
|
||||
},
|
||||
@@ -81,6 +82,14 @@ describe('SalesInvoicesService', () => {
|
||||
updatedAt: now,
|
||||
createdBy: 'user-1',
|
||||
updatedBy: 'user-1',
|
||||
salesOrder: null,
|
||||
packingSlip: null,
|
||||
salesPerson: { id: 'emp-1', code: 'E1', name: 'Ada' },
|
||||
branch: { id: 'br-1', code: 'B1', name: 'Jakarta' },
|
||||
division: { id: 'div-1', code: 'FIN', name: 'Finance' },
|
||||
customer: { id: 'cus-1', code: 'C1', name: 'Acme' },
|
||||
createdByUser: { id: 'user-1', username: 'admin' },
|
||||
updatedByUser: { id: 'user-1', username: 'admin' },
|
||||
};
|
||||
|
||||
const createBody = {
|
||||
@@ -134,15 +143,15 @@ describe('SalesInvoicesService', () => {
|
||||
id: 'so-1',
|
||||
code: 'SO-1',
|
||||
date: now.value,
|
||||
salesPersonId: 'emp-1',
|
||||
branchId: 'br-1',
|
||||
divisionId: 'div-1',
|
||||
customerId: 'cus-1',
|
||||
salesPerson: { id: 'emp-1', code: 'E1', name: 'Ada' },
|
||||
branch: { id: 'br-1', code: 'B1', name: 'Jakarta' },
|
||||
division: { id: 'div-1', code: 'FIN', name: 'Finance' },
|
||||
customer: { id: 'cus-1', code: 'C1', name: 'Acme' },
|
||||
notes: 'from order',
|
||||
products: [
|
||||
{
|
||||
id: 'ol-1',
|
||||
productId: 'prd-1',
|
||||
product: { id: 'prd-1', code: 'P1', name: 'Fuel' },
|
||||
quantity: '2.0000',
|
||||
price: '12500.0000',
|
||||
},
|
||||
|
||||
@@ -4,7 +4,13 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type { PaginationResponse } from '../../../common/http/response';
|
||||
import { toListPage } from '../../../common/http/response';
|
||||
import {
|
||||
DEFAULT_RELATION_FIELDS,
|
||||
pickCodeRelation,
|
||||
pickRelation,
|
||||
pickUserRelation,
|
||||
toListPage,
|
||||
} from '../../../common/http/response';
|
||||
import { InvalidDateTimeError } from '../../../common/value-objects/date-time/invalid-date-time.error';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||
@@ -47,6 +53,8 @@ export type ListSalesInvoicesQuery = {
|
||||
readonly salesOrderId?: string;
|
||||
readonly packingSlipId?: string;
|
||||
readonly search?: string;
|
||||
readonly orderBy?: string;
|
||||
readonly orderType?: string;
|
||||
readonly page?: number;
|
||||
readonly limit?: number;
|
||||
readonly offset?: number;
|
||||
@@ -89,6 +97,8 @@ export class SalesInvoicesService {
|
||||
salesOrderId: query.salesOrderId,
|
||||
packingSlipId: query.packingSlipId,
|
||||
search: query.search,
|
||||
orderBy: query.orderBy,
|
||||
orderType: query.orderType,
|
||||
limit: page.limit,
|
||||
offset: page.offset,
|
||||
});
|
||||
@@ -316,22 +326,20 @@ export class SalesInvoicesService {
|
||||
return {
|
||||
id: item.id,
|
||||
code: item.code,
|
||||
salesOrderId: item.salesOrderId,
|
||||
salesOrderCode: item.salesOrderCode,
|
||||
packingSlipId: item.packingSlipId,
|
||||
packingSlipCode: item.packingSlipCode,
|
||||
salesOrder: pickCodeRelation(item.salesOrder),
|
||||
packingSlip: pickCodeRelation(item.packingSlip),
|
||||
date: item.date.value,
|
||||
salesPersonId: item.salesPersonId,
|
||||
branchId: item.branchId,
|
||||
divisionId: item.divisionId,
|
||||
customerId: item.customerId,
|
||||
salesPerson: pickRelation(item.salesPerson, DEFAULT_RELATION_FIELDS),
|
||||
branch: pickRelation(item.branch, DEFAULT_RELATION_FIELDS),
|
||||
division: pickRelation(item.division, DEFAULT_RELATION_FIELDS),
|
||||
customer: pickRelation(item.customer, DEFAULT_RELATION_FIELDS),
|
||||
notes: item.notes,
|
||||
balance: item.balance.value,
|
||||
status: item.status.value,
|
||||
createdAt: item.createdAt.value,
|
||||
updatedAt: item.updatedAt.value,
|
||||
createdBy: item.createdBy,
|
||||
updatedBy: item.updatedBy,
|
||||
createdBy: pickUserRelation(item.createdByUser),
|
||||
updatedBy: pickUserRelation(item.updatedByUser),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -340,7 +348,7 @@ export class SalesInvoicesService {
|
||||
...this.toListItem(item),
|
||||
products: item.products.map((line) => ({
|
||||
id: line.id,
|
||||
productId: line.productId,
|
||||
product: pickRelation(line.product, DEFAULT_RELATION_FIELDS),
|
||||
quantity: line.quantity.value,
|
||||
price: line.price.value,
|
||||
})),
|
||||
@@ -374,15 +382,15 @@ export class SalesInvoicesService {
|
||||
const order = await this.salesOrdersService.findById(input.salesOrderId);
|
||||
salesOrderCode = order.code;
|
||||
date = date || DateTime.fromUnixMs(order.date).format();
|
||||
salesPersonId = salesPersonId || order.salesPersonId;
|
||||
branchId = branchId || order.branchId;
|
||||
divisionId = divisionId || order.divisionId;
|
||||
customerId = customerId || order.customerId;
|
||||
salesPersonId = salesPersonId || order.salesPerson?.id || '';
|
||||
branchId = branchId || order.branch?.id || '';
|
||||
divisionId = divisionId || order.division?.id || '';
|
||||
customerId = customerId || order.customer?.id || '';
|
||||
notes = notes !== undefined ? notes : order.notes;
|
||||
products =
|
||||
products ??
|
||||
order.products.map((line) => ({
|
||||
productId: line.productId,
|
||||
productId: line.product?.id ?? '',
|
||||
quantity: line.quantity,
|
||||
price: line.price,
|
||||
}));
|
||||
@@ -391,13 +399,13 @@ export class SalesInvoicesService {
|
||||
const slip = await this.packingSlipsService.findById(input.packingSlipId);
|
||||
packingSlipCode = slip.code;
|
||||
date = date || DateTime.fromUnixMs(slip.date).format();
|
||||
customerId = customerId || slip.customerId;
|
||||
customerId = customerId || slip.customer?.id || '';
|
||||
notes = notes !== undefined ? notes : slip.notes;
|
||||
products =
|
||||
input.products ??
|
||||
products ??
|
||||
slip.products.map((line) => ({
|
||||
productId: line.productId,
|
||||
productId: line.product?.id ?? '',
|
||||
quantity: line.quantity,
|
||||
price: line.price,
|
||||
}));
|
||||
|
||||
@@ -15,7 +15,12 @@ import {
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { PaginationQueryDto } from '../../../../common/http/response';
|
||||
import {
|
||||
CodeRelationDto,
|
||||
DefaultRelationDto,
|
||||
PaginationQueryDto,
|
||||
UserRelationDto,
|
||||
} from '../../../../common/http/response';
|
||||
import {
|
||||
DOCUMENT_ADDRESS_MAX_LENGTH,
|
||||
DOCUMENT_CODE_MAX_LENGTH,
|
||||
@@ -272,18 +277,18 @@ export class SalesOrderDto {
|
||||
id!: string;
|
||||
@ApiProperty()
|
||||
code!: string;
|
||||
@ApiPropertyOptional({ nullable: true, format: 'uuid' })
|
||||
salesRequestId!: string | null;
|
||||
@ApiPropertyOptional({ type: CodeRelationDto, nullable: true })
|
||||
salesRequest!: CodeRelationDto | null;
|
||||
@ApiProperty()
|
||||
date!: number;
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
salesPersonId!: string;
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
branchId!: string;
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
divisionId!: string;
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
customerId!: string;
|
||||
@ApiProperty({ type: DefaultRelationDto, nullable: true })
|
||||
salesPerson!: DefaultRelationDto | null;
|
||||
@ApiProperty({ type: DefaultRelationDto, nullable: true })
|
||||
branch!: DefaultRelationDto | null;
|
||||
@ApiProperty({ type: DefaultRelationDto, nullable: true })
|
||||
division!: DefaultRelationDto | null;
|
||||
@ApiProperty({ type: DefaultRelationDto, nullable: true })
|
||||
customer!: DefaultRelationDto | null;
|
||||
@ApiProperty()
|
||||
address!: string;
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
@@ -298,8 +303,8 @@ export class SalesOrderDto {
|
||||
createdAt!: number;
|
||||
@ApiProperty()
|
||||
updatedAt!: number;
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
createdBy!: string;
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
updatedBy!: string;
|
||||
@ApiProperty({ type: UserRelationDto })
|
||||
createdBy!: UserRelationDto;
|
||||
@ApiProperty({ type: UserRelationDto })
|
||||
updatedBy!: UserRelationDto;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import type {
|
||||
CodeRelation,
|
||||
DefaultRelation,
|
||||
UserRelation,
|
||||
} from '../../../common/http/response';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
@@ -5,6 +10,7 @@ import { Status } from '../../../common/value-objects/status/status';
|
||||
export type SalesOrderLine = {
|
||||
readonly id: string;
|
||||
readonly productId: string;
|
||||
readonly product: DefaultRelation | null;
|
||||
readonly quantity: Decimal;
|
||||
readonly price: Decimal;
|
||||
};
|
||||
@@ -35,6 +41,13 @@ export type SalesOrder = {
|
||||
readonly updatedAt: DateTime;
|
||||
readonly createdBy: string;
|
||||
readonly updatedBy: string;
|
||||
readonly salesRequest: CodeRelation | null;
|
||||
readonly salesPerson: DefaultRelation | null;
|
||||
readonly branch: DefaultRelation | null;
|
||||
readonly division: DefaultRelation | null;
|
||||
readonly customer: DefaultRelation | null;
|
||||
readonly createdByUser: UserRelation;
|
||||
readonly updatedByUser: UserRelation;
|
||||
};
|
||||
|
||||
export type SalesOrderLineInput = {
|
||||
@@ -90,6 +103,8 @@ export type ListSalesOrdersFilters = {
|
||||
readonly branchId?: string;
|
||||
readonly divisionId?: string;
|
||||
readonly search?: string;
|
||||
readonly orderBy?: string;
|
||||
readonly orderType?: string;
|
||||
readonly limit: number;
|
||||
readonly offset: number;
|
||||
};
|
||||
|
||||
@@ -4,7 +4,15 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { and, asc, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm';
|
||||
import { and, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm';
|
||||
import { toOrderClauses } from '../../../common/http/response';
|
||||
import {
|
||||
catalogRelationFromMap,
|
||||
codeRelationFromMap,
|
||||
loadProductRelationMap,
|
||||
loadSalesRequestRelationMap,
|
||||
} from '../../../database/load-catalog-refs';
|
||||
import { attachSalesHeaderRelations } from '../shared/attach-sales-relations';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
@@ -29,6 +37,15 @@ import type {
|
||||
UpdateSalesOrderInput,
|
||||
} from './sales-order';
|
||||
|
||||
const SALES_ORDER_ORDER_COLUMNS = {
|
||||
id: salesOrders.id,
|
||||
code: salesOrders.code,
|
||||
date: salesOrders.date,
|
||||
status: salesOrders.status,
|
||||
createdAt: salesOrders.createdAt,
|
||||
updatedAt: salesOrders.updatedAt,
|
||||
};
|
||||
|
||||
type QueryExecutor = Pick<DrizzleDB, 'select' | 'insert' | 'delete'>;
|
||||
|
||||
@Injectable()
|
||||
@@ -50,11 +67,15 @@ export class SalesOrdersRepository {
|
||||
.select()
|
||||
.from(salesOrders)
|
||||
.where(where)
|
||||
.orderBy(asc(salesOrders.code))
|
||||
.orderBy(
|
||||
...toOrderClauses(SALES_ORDER_ORDER_COLUMNS, filters, [
|
||||
{ column: 'code', type: 'ASC' },
|
||||
]),
|
||||
)
|
||||
.limit(filters.limit)
|
||||
.offset(filters.offset);
|
||||
return {
|
||||
data: rows.map((row) => this.toDomain(row, [], [])),
|
||||
data: await this.hydrate(rows.map((row) => this.toDomain(row, [], []))),
|
||||
total: Number(totalRows[0]?.total ?? 0),
|
||||
};
|
||||
}
|
||||
@@ -71,7 +92,7 @@ export class SalesOrdersRepository {
|
||||
}
|
||||
const products = await this.selectProducts(this.db, id);
|
||||
const images = await this.selectImages(this.db, id);
|
||||
return this.toDomain(row, products, images);
|
||||
return this.hydrateOne(row, products, images);
|
||||
}
|
||||
|
||||
async create(input: CreateSalesOrderInput): Promise<SalesOrder> {
|
||||
@@ -95,7 +116,7 @@ export class SalesOrdersRepository {
|
||||
await this.replaceImages(tx, row.id, input.images ?? []);
|
||||
const products = await this.selectProducts(tx, row.id);
|
||||
const images = await this.selectImages(tx, row.id);
|
||||
return this.toDomain(row, products, images);
|
||||
return this.hydrateOne(row, products, images);
|
||||
});
|
||||
} catch (error) {
|
||||
this.rethrowConstraintViolation(error);
|
||||
@@ -154,7 +175,7 @@ export class SalesOrdersRepository {
|
||||
}
|
||||
const products = await this.selectProducts(tx, id);
|
||||
const images = await this.selectImages(tx, id);
|
||||
return this.toDomain(row, products, images);
|
||||
return this.hydrateOne(row, products, images);
|
||||
});
|
||||
} catch (error) {
|
||||
this.rethrowConstraintViolation(error);
|
||||
@@ -182,7 +203,7 @@ export class SalesOrdersRepository {
|
||||
}
|
||||
const products = await this.selectProducts(this.db, id);
|
||||
const images = await this.selectImages(this.db, id);
|
||||
return this.toDomain(row, products, images);
|
||||
return this.hydrateOne(row, products, images);
|
||||
}
|
||||
|
||||
async bulkUpdateStatus(
|
||||
@@ -372,6 +393,7 @@ export class SalesOrdersRepository {
|
||||
products: productRows.map((line) => ({
|
||||
id: line.id,
|
||||
productId: line.productId,
|
||||
product: null,
|
||||
quantity: Decimal.create(line.quantity),
|
||||
price: Decimal.create(line.price),
|
||||
})),
|
||||
@@ -385,9 +407,47 @@ export class SalesOrdersRepository {
|
||||
updatedAt: DateTime.fromUnixMs(row.updatedAt),
|
||||
createdBy: row.createdBy,
|
||||
updatedBy: row.updatedBy,
|
||||
salesRequest: null,
|
||||
salesPerson: null,
|
||||
branch: null,
|
||||
division: null,
|
||||
customer: null,
|
||||
createdByUser: { id: row.createdBy, username: '' },
|
||||
updatedByUser: { id: row.updatedBy, username: '' },
|
||||
};
|
||||
}
|
||||
|
||||
private async hydrate(items: SalesOrder[]): Promise<SalesOrder[]> {
|
||||
const withHeader = await attachSalesHeaderRelations(this.db, items);
|
||||
const productIds = withHeader.flatMap((item) =>
|
||||
item.products.map((line) => line.productId),
|
||||
);
|
||||
const requestIds = withHeader
|
||||
.map((item) => item.salesRequestId)
|
||||
.filter((id): id is string => Boolean(id));
|
||||
const [products, requests] = await Promise.all([
|
||||
loadProductRelationMap(this.db, productIds),
|
||||
loadSalesRequestRelationMap(this.db, requestIds),
|
||||
]);
|
||||
return withHeader.map((item) => ({
|
||||
...item,
|
||||
salesRequest: codeRelationFromMap(requests, item.salesRequestId),
|
||||
products: item.products.map((line) => ({
|
||||
...line,
|
||||
product: catalogRelationFromMap(products, line.productId),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
private async hydrateOne(
|
||||
row: SalesOrderRow,
|
||||
products: SalesOrderProductRow[],
|
||||
images: SalesOrderImageRow[],
|
||||
): Promise<SalesOrder> {
|
||||
const [item] = await this.hydrate([this.toDomain(row, products, images)]);
|
||||
return item;
|
||||
}
|
||||
|
||||
private rethrowConstraintViolation(error: unknown): never {
|
||||
if (error instanceof NotFoundException) {
|
||||
throw error;
|
||||
|
||||
@@ -65,6 +65,7 @@ describe('SalesOrdersService', () => {
|
||||
{
|
||||
id: 'line-1',
|
||||
productId: 'prd-1',
|
||||
product: { id: 'prd-1', code: 'P1', name: 'Fuel' },
|
||||
quantity: Decimal.create('2'),
|
||||
price: Decimal.create('12500'),
|
||||
},
|
||||
@@ -75,6 +76,13 @@ describe('SalesOrdersService', () => {
|
||||
updatedAt: now,
|
||||
createdBy: 'user-1',
|
||||
updatedBy: 'user-1',
|
||||
salesRequest: null,
|
||||
salesPerson: { id: 'emp-1', code: 'E1', name: 'Ada' },
|
||||
branch: { id: 'br-1', code: 'B1', name: 'Jakarta' },
|
||||
division: { id: 'div-1', code: 'FIN', name: 'Finance' },
|
||||
customer: { id: 'cus-1', code: 'C1', name: 'Acme' },
|
||||
createdByUser: { id: 'user-1', username: 'admin' },
|
||||
updatedByUser: { id: 'user-1', username: 'admin' },
|
||||
};
|
||||
|
||||
const createBody = {
|
||||
|
||||
@@ -4,7 +4,13 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type { PaginationResponse } from '../../../common/http/response';
|
||||
import { toListPage } from '../../../common/http/response';
|
||||
import {
|
||||
DEFAULT_RELATION_FIELDS,
|
||||
pickCodeRelation,
|
||||
pickRelation,
|
||||
pickUserRelation,
|
||||
toListPage,
|
||||
} from '../../../common/http/response';
|
||||
import { InvalidDateTimeError } from '../../../common/value-objects/date-time/invalid-date-time.error';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||
@@ -55,6 +61,8 @@ export type ListSalesOrdersQuery = {
|
||||
readonly branchId?: string;
|
||||
readonly divisionId?: string;
|
||||
readonly search?: string;
|
||||
readonly orderBy?: string;
|
||||
readonly orderType?: string;
|
||||
readonly page?: number;
|
||||
readonly limit?: number;
|
||||
readonly offset?: number;
|
||||
@@ -93,6 +101,8 @@ export class SalesOrdersService {
|
||||
branchId: query.branchId,
|
||||
divisionId: query.divisionId,
|
||||
search: query.search,
|
||||
orderBy: query.orderBy,
|
||||
orderType: query.orderType,
|
||||
limit: page.limit,
|
||||
offset: page.offset,
|
||||
});
|
||||
@@ -303,12 +313,12 @@ export class SalesOrdersService {
|
||||
return {
|
||||
id: item.id,
|
||||
code: item.code,
|
||||
salesRequestId: item.salesRequestId,
|
||||
salesRequest: pickCodeRelation(item.salesRequest),
|
||||
date: item.date.value,
|
||||
salesPersonId: item.salesPersonId,
|
||||
branchId: item.branchId,
|
||||
divisionId: item.divisionId,
|
||||
customerId: item.customerId,
|
||||
salesPerson: pickRelation(item.salesPerson, DEFAULT_RELATION_FIELDS),
|
||||
branch: pickRelation(item.branch, DEFAULT_RELATION_FIELDS),
|
||||
division: pickRelation(item.division, DEFAULT_RELATION_FIELDS),
|
||||
customer: pickRelation(item.customer, DEFAULT_RELATION_FIELDS),
|
||||
address: item.address,
|
||||
latitude: item.latitude,
|
||||
longitude: item.longitude,
|
||||
@@ -316,8 +326,8 @@ export class SalesOrdersService {
|
||||
status: item.status.value,
|
||||
createdAt: item.createdAt.value,
|
||||
updatedAt: item.updatedAt.value,
|
||||
createdBy: item.createdBy,
|
||||
updatedBy: item.updatedBy,
|
||||
createdBy: pickUserRelation(item.createdByUser),
|
||||
updatedBy: pickUserRelation(item.updatedByUser),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -326,7 +336,7 @@ export class SalesOrdersService {
|
||||
...this.toListItem(item),
|
||||
products: item.products.map((line) => ({
|
||||
id: line.id,
|
||||
productId: line.productId,
|
||||
product: pickRelation(line.product, DEFAULT_RELATION_FIELDS),
|
||||
quantity: line.quantity.value,
|
||||
price: line.price.value,
|
||||
})),
|
||||
@@ -373,10 +383,10 @@ export class SalesOrdersService {
|
||||
return {
|
||||
...input,
|
||||
date: input.date ?? DateTime.fromUnixMs(source.date).format(),
|
||||
salesPersonId: input.salesPersonId ?? source.salesPersonId,
|
||||
branchId: input.branchId ?? source.branchId,
|
||||
divisionId: input.divisionId ?? source.divisionId,
|
||||
customerId: input.customerId ?? source.customerId,
|
||||
salesPersonId: input.salesPersonId ?? source.salesPerson?.id ?? '',
|
||||
branchId: input.branchId ?? source.branch?.id ?? '',
|
||||
divisionId: input.divisionId ?? source.division?.id ?? '',
|
||||
customerId: input.customerId ?? source.customer?.id ?? '',
|
||||
address: input.address ?? source.address,
|
||||
latitude: input.latitude !== undefined ? input.latitude : source.latitude,
|
||||
longitude:
|
||||
@@ -385,7 +395,7 @@ export class SalesOrdersService {
|
||||
products:
|
||||
input.products ??
|
||||
source.products.map((line) => ({
|
||||
productId: line.productId,
|
||||
productId: line.product?.id ?? '',
|
||||
quantity: line.quantity,
|
||||
price: line.price,
|
||||
})),
|
||||
|
||||
@@ -12,7 +12,10 @@ import {
|
||||
MaxLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { PaginationQueryDto } from '../../../../common/http/response';
|
||||
import {
|
||||
PaginationQueryDto,
|
||||
UserRelationDto,
|
||||
} from '../../../../common/http/response';
|
||||
import {
|
||||
DOCUMENT_CODE_MAX_LENGTH,
|
||||
DOCUMENT_CODE_PATTERN,
|
||||
@@ -177,8 +180,8 @@ export class SalesPaymentDto {
|
||||
createdAt!: number;
|
||||
@ApiProperty()
|
||||
updatedAt!: number;
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
createdBy!: string;
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
updatedBy!: string;
|
||||
@ApiProperty({ type: UserRelationDto })
|
||||
createdBy!: UserRelationDto;
|
||||
@ApiProperty({ type: UserRelationDto })
|
||||
updatedBy!: UserRelationDto;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { CodeRelation, UserRelation } from '../../../common/http/response';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
@@ -5,6 +6,7 @@ import { Status } from '../../../common/value-objects/status/status';
|
||||
export type SalesPaymentAllocation = {
|
||||
readonly id: string;
|
||||
readonly invoiceId: string;
|
||||
readonly invoice: CodeRelation | null;
|
||||
readonly amount: Decimal;
|
||||
};
|
||||
|
||||
@@ -26,6 +28,8 @@ export type SalesPayment = {
|
||||
readonly updatedAt: DateTime;
|
||||
readonly createdBy: string;
|
||||
readonly updatedBy: string;
|
||||
readonly createdByUser: UserRelation;
|
||||
readonly updatedByUser: UserRelation;
|
||||
};
|
||||
|
||||
export type SalesPaymentAllocationInput = {
|
||||
@@ -61,6 +65,8 @@ export type ListSalesPaymentsFilters = {
|
||||
readonly code?: string;
|
||||
readonly status?: string;
|
||||
readonly search?: string;
|
||||
readonly orderBy?: string;
|
||||
readonly orderType?: string;
|
||||
readonly limit: number;
|
||||
readonly offset: number;
|
||||
};
|
||||
|
||||
@@ -4,7 +4,13 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { and, asc, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm';
|
||||
import { and, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm';
|
||||
import { toOrderClauses } from '../../../common/http/response';
|
||||
import {
|
||||
codeRelationFromMap,
|
||||
loadSalesInvoiceRelationMap,
|
||||
} from '../../../database/load-catalog-refs';
|
||||
import { attachAuditUsers } from '../../../database/load-user-refs';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
@@ -29,6 +35,15 @@ import type {
|
||||
UpdateSalesPaymentInput,
|
||||
} from './sales-payment';
|
||||
|
||||
const SALES_PAYMENT_ORDER_COLUMNS = {
|
||||
id: salesPayments.id,
|
||||
code: salesPayments.code,
|
||||
date: salesPayments.date,
|
||||
status: salesPayments.status,
|
||||
createdAt: salesPayments.createdAt,
|
||||
updatedAt: salesPayments.updatedAt,
|
||||
};
|
||||
|
||||
type QueryExecutor = Pick<DrizzleDB, 'select' | 'insert' | 'delete'>;
|
||||
|
||||
@Injectable()
|
||||
@@ -50,11 +65,15 @@ export class SalesPaymentsRepository {
|
||||
.select()
|
||||
.from(salesPayments)
|
||||
.where(where)
|
||||
.orderBy(asc(salesPayments.code))
|
||||
.orderBy(
|
||||
...toOrderClauses(SALES_PAYMENT_ORDER_COLUMNS, filters, [
|
||||
{ column: 'code', type: 'ASC' },
|
||||
]),
|
||||
)
|
||||
.limit(filters.limit)
|
||||
.offset(filters.offset);
|
||||
return {
|
||||
data: rows.map((row) => this.toDomain(row, [], [])),
|
||||
data: await this.hydrate(rows.map((row) => this.toDomain(row, [], []))),
|
||||
total: Number(totalRows[0]?.total ?? 0),
|
||||
};
|
||||
}
|
||||
@@ -71,7 +90,7 @@ export class SalesPaymentsRepository {
|
||||
}
|
||||
const invoices = await this.selectAllocations(this.db, id);
|
||||
const images = await this.selectImages(this.db, id);
|
||||
return this.toDomain(row, invoices, images);
|
||||
return this.hydrateOne(row, invoices, images);
|
||||
}
|
||||
|
||||
async create(input: CreateSalesPaymentInput): Promise<SalesPayment> {
|
||||
@@ -96,7 +115,7 @@ export class SalesPaymentsRepository {
|
||||
await this.replaceImages(tx, row.id, input.images ?? []);
|
||||
const invoices = await this.selectAllocations(tx, row.id);
|
||||
const images = await this.selectImages(tx, row.id);
|
||||
return this.toDomain(row, invoices, images);
|
||||
return this.hydrateOne(row, invoices, images);
|
||||
});
|
||||
} catch (error) {
|
||||
this.rethrowConstraintViolation(error);
|
||||
@@ -147,7 +166,7 @@ export class SalesPaymentsRepository {
|
||||
}
|
||||
const invoices = await this.selectAllocations(tx, id);
|
||||
const images = await this.selectImages(tx, id);
|
||||
return this.toDomain(row, invoices, images);
|
||||
return this.hydrateOne(row, invoices, images);
|
||||
});
|
||||
} catch (error) {
|
||||
this.rethrowConstraintViolation(error);
|
||||
@@ -175,7 +194,7 @@ export class SalesPaymentsRepository {
|
||||
}
|
||||
const invoices = await this.selectAllocations(this.db, id);
|
||||
const images = await this.selectImages(this.db, id);
|
||||
return this.toDomain(row, invoices, images);
|
||||
return this.hydrateOne(row, invoices, images);
|
||||
}
|
||||
|
||||
async bulkUpdateStatus(
|
||||
@@ -335,6 +354,7 @@ export class SalesPaymentsRepository {
|
||||
invoices: allocationRows.map((line) => ({
|
||||
id: line.id,
|
||||
invoiceId: line.salesInvoiceId,
|
||||
invoice: null,
|
||||
amount: Decimal.create(line.amount),
|
||||
})),
|
||||
images: imageRows.map((image) => ({
|
||||
@@ -347,9 +367,35 @@ export class SalesPaymentsRepository {
|
||||
updatedAt: DateTime.fromUnixMs(row.updatedAt),
|
||||
createdBy: row.createdBy,
|
||||
updatedBy: row.updatedBy,
|
||||
createdByUser: { id: row.createdBy, username: '' },
|
||||
updatedByUser: { id: row.updatedBy, username: '' },
|
||||
};
|
||||
}
|
||||
|
||||
private async hydrate(items: SalesPayment[]): Promise<SalesPayment[]> {
|
||||
const withAudit = await attachAuditUsers(this.db, items);
|
||||
const invoices = await loadSalesInvoiceRelationMap(
|
||||
this.db,
|
||||
withAudit.flatMap((item) => item.invoices.map((line) => line.invoiceId)),
|
||||
);
|
||||
return withAudit.map((item) => ({
|
||||
...item,
|
||||
invoices: item.invoices.map((line) => ({
|
||||
...line,
|
||||
invoice: codeRelationFromMap(invoices, line.invoiceId),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
private async hydrateOne(
|
||||
row: SalesPaymentRow,
|
||||
invoices: SalesPaymentInvoiceRow[],
|
||||
images: SalesPaymentImageRow[],
|
||||
): Promise<SalesPayment> {
|
||||
const [item] = await this.hydrate([this.toDomain(row, invoices, images)]);
|
||||
return item;
|
||||
}
|
||||
|
||||
private rethrowConstraintViolation(error: unknown): never {
|
||||
if (error instanceof NotFoundException) {
|
||||
throw error;
|
||||
|
||||
@@ -51,6 +51,7 @@ describe('SalesPaymentsService', () => {
|
||||
{
|
||||
id: 'alloc-1',
|
||||
invoiceId: 'si-1',
|
||||
invoice: { id: 'si-1', code: 'SI-1' },
|
||||
amount: Decimal.create('10000'),
|
||||
},
|
||||
],
|
||||
@@ -60,6 +61,8 @@ describe('SalesPaymentsService', () => {
|
||||
updatedAt: now,
|
||||
createdBy: 'user-1',
|
||||
updatedBy: 'user-1',
|
||||
createdByUser: { id: 'user-1', username: 'admin' },
|
||||
updatedByUser: { id: 'user-1', username: 'admin' },
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
|
||||
@@ -4,7 +4,11 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type { PaginationResponse } from '../../../common/http/response';
|
||||
import { toListPage } from '../../../common/http/response';
|
||||
import {
|
||||
pickCodeRelation,
|
||||
pickUserRelation,
|
||||
toListPage,
|
||||
} from '../../../common/http/response';
|
||||
import { InvalidDateTimeError } from '../../../common/value-objects/date-time/invalid-date-time.error';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||
@@ -42,6 +46,8 @@ export type ListSalesPaymentsQuery = {
|
||||
readonly code?: string;
|
||||
readonly status?: string;
|
||||
readonly search?: string;
|
||||
readonly orderBy?: string;
|
||||
readonly orderType?: string;
|
||||
readonly page?: number;
|
||||
readonly limit?: number;
|
||||
readonly offset?: number;
|
||||
@@ -66,6 +72,8 @@ export class SalesPaymentsService {
|
||||
code: query.code,
|
||||
status: query.status,
|
||||
search: query.search,
|
||||
orderBy: query.orderBy,
|
||||
orderType: query.orderType,
|
||||
limit: page.limit,
|
||||
offset: page.offset,
|
||||
});
|
||||
@@ -253,8 +261,8 @@ export class SalesPaymentsService {
|
||||
status: item.status.value,
|
||||
createdAt: item.createdAt.value,
|
||||
updatedAt: item.updatedAt.value,
|
||||
createdBy: item.createdBy,
|
||||
updatedBy: item.updatedBy,
|
||||
createdBy: pickUserRelation(item.createdByUser),
|
||||
updatedBy: pickUserRelation(item.updatedByUser),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -263,7 +271,7 @@ export class SalesPaymentsService {
|
||||
...this.toListItem(item),
|
||||
invoices: item.invoices.map((line) => ({
|
||||
id: line.id,
|
||||
invoiceId: line.invoiceId,
|
||||
invoice: pickCodeRelation(line.invoice),
|
||||
amount: line.amount.value,
|
||||
})),
|
||||
images: item.images.map((image) => ({
|
||||
|
||||
@@ -15,7 +15,11 @@ import {
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { PaginationQueryDto } from '../../../../common/http/response';
|
||||
import {
|
||||
DefaultRelationDto,
|
||||
PaginationQueryDto,
|
||||
UserRelationDto,
|
||||
} from '../../../../common/http/response';
|
||||
import {
|
||||
DOCUMENT_ADDRESS_MAX_LENGTH,
|
||||
DOCUMENT_CODE_MAX_LENGTH,
|
||||
@@ -269,14 +273,14 @@ export class SalesRequestDto {
|
||||
code!: string;
|
||||
@ApiProperty()
|
||||
date!: number;
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
salesPersonId!: string;
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
branchId!: string;
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
divisionId!: string;
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
customerId!: string;
|
||||
@ApiProperty({ type: DefaultRelationDto, nullable: true })
|
||||
salesPerson!: DefaultRelationDto | null;
|
||||
@ApiProperty({ type: DefaultRelationDto, nullable: true })
|
||||
branch!: DefaultRelationDto | null;
|
||||
@ApiProperty({ type: DefaultRelationDto, nullable: true })
|
||||
division!: DefaultRelationDto | null;
|
||||
@ApiProperty({ type: DefaultRelationDto, nullable: true })
|
||||
customer!: DefaultRelationDto | null;
|
||||
@ApiProperty()
|
||||
address!: string;
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
@@ -291,8 +295,8 @@ export class SalesRequestDto {
|
||||
createdAt!: number;
|
||||
@ApiProperty()
|
||||
updatedAt!: number;
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
createdBy!: string;
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
updatedBy!: string;
|
||||
@ApiProperty({ type: UserRelationDto })
|
||||
createdBy!: UserRelationDto;
|
||||
@ApiProperty({ type: UserRelationDto })
|
||||
updatedBy!: UserRelationDto;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import type {
|
||||
DefaultRelation,
|
||||
UserRelation,
|
||||
} from '../../../common/http/response';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
@@ -5,6 +9,7 @@ import { Status } from '../../../common/value-objects/status/status';
|
||||
export type SalesRequestLine = {
|
||||
readonly id: string;
|
||||
readonly productId: string;
|
||||
readonly product: DefaultRelation | null;
|
||||
readonly quantity: Decimal;
|
||||
readonly price: Decimal;
|
||||
};
|
||||
@@ -34,6 +39,12 @@ export type SalesRequest = {
|
||||
readonly updatedAt: DateTime;
|
||||
readonly createdBy: string;
|
||||
readonly updatedBy: string;
|
||||
readonly salesPerson: DefaultRelation | null;
|
||||
readonly branch: DefaultRelation | null;
|
||||
readonly division: DefaultRelation | null;
|
||||
readonly customer: DefaultRelation | null;
|
||||
readonly createdByUser: UserRelation;
|
||||
readonly updatedByUser: UserRelation;
|
||||
};
|
||||
|
||||
export type SalesRequestLineInput = {
|
||||
@@ -88,6 +99,8 @@ export type ListSalesRequestsFilters = {
|
||||
readonly branchId?: string;
|
||||
readonly divisionId?: string;
|
||||
readonly search?: string;
|
||||
readonly orderBy?: string;
|
||||
readonly orderType?: string;
|
||||
readonly limit: number;
|
||||
readonly offset: number;
|
||||
};
|
||||
|
||||
@@ -4,7 +4,13 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { and, asc, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm';
|
||||
import { and, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm';
|
||||
import { toOrderClauses } from '../../../common/http/response';
|
||||
import {
|
||||
catalogRelationFromMap,
|
||||
loadProductRelationMap,
|
||||
} from '../../../database/load-catalog-refs';
|
||||
import { attachSalesHeaderRelations } from '../shared/attach-sales-relations';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
@@ -29,6 +35,15 @@ import type {
|
||||
UpdateSalesRequestInput,
|
||||
} from './sales-request';
|
||||
|
||||
const SALES_REQUEST_ORDER_COLUMNS = {
|
||||
id: salesRequests.id,
|
||||
code: salesRequests.code,
|
||||
date: salesRequests.date,
|
||||
status: salesRequests.status,
|
||||
createdAt: salesRequests.createdAt,
|
||||
updatedAt: salesRequests.updatedAt,
|
||||
};
|
||||
|
||||
type QueryExecutor = Pick<DrizzleDB, 'select' | 'insert' | 'delete'>;
|
||||
|
||||
@Injectable()
|
||||
@@ -50,11 +65,15 @@ export class SalesRequestsRepository {
|
||||
.select()
|
||||
.from(salesRequests)
|
||||
.where(where)
|
||||
.orderBy(asc(salesRequests.code))
|
||||
.orderBy(
|
||||
...toOrderClauses(SALES_REQUEST_ORDER_COLUMNS, filters, [
|
||||
{ column: 'code', type: 'ASC' },
|
||||
]),
|
||||
)
|
||||
.limit(filters.limit)
|
||||
.offset(filters.offset);
|
||||
return {
|
||||
data: rows.map((row) => this.toDomain(row, [], [])),
|
||||
data: await this.hydrate(rows.map((row) => this.toDomain(row, [], []))),
|
||||
total: Number(totalRows[0]?.total ?? 0),
|
||||
};
|
||||
}
|
||||
@@ -71,7 +90,7 @@ export class SalesRequestsRepository {
|
||||
}
|
||||
const products = await this.selectProducts(this.db, id);
|
||||
const images = await this.selectImages(this.db, id);
|
||||
return this.toDomain(row, products, images);
|
||||
return this.hydrateOne(row, products, images);
|
||||
}
|
||||
|
||||
async create(input: CreateSalesRequestInput): Promise<SalesRequest> {
|
||||
@@ -96,7 +115,7 @@ export class SalesRequestsRepository {
|
||||
await this.replaceImages(tx, row.id, input.images ?? []);
|
||||
const products = await this.selectProducts(tx, row.id);
|
||||
const images = await this.selectImages(tx, row.id);
|
||||
return this.toDomain(row, products, images);
|
||||
return this.hydrateOne(row, products, images);
|
||||
});
|
||||
} catch (error) {
|
||||
this.rethrowConstraintViolation(error);
|
||||
@@ -158,7 +177,7 @@ export class SalesRequestsRepository {
|
||||
}
|
||||
const products = await this.selectProducts(tx, id);
|
||||
const images = await this.selectImages(tx, id);
|
||||
return this.toDomain(row, products, images);
|
||||
return this.hydrateOne(row, products, images);
|
||||
});
|
||||
} catch (error) {
|
||||
this.rethrowConstraintViolation(error);
|
||||
@@ -186,7 +205,7 @@ export class SalesRequestsRepository {
|
||||
}
|
||||
const products = await this.selectProducts(this.db, id);
|
||||
const images = await this.selectImages(this.db, id);
|
||||
return this.toDomain(row, products, images);
|
||||
return this.hydrateOne(row, products, images);
|
||||
}
|
||||
|
||||
async bulkUpdateStatus(
|
||||
@@ -374,6 +393,7 @@ export class SalesRequestsRepository {
|
||||
products: productRows.map((line) => ({
|
||||
id: line.id,
|
||||
productId: line.productId,
|
||||
product: null,
|
||||
quantity: Decimal.create(line.quantity),
|
||||
price: Decimal.create(line.price),
|
||||
})),
|
||||
@@ -387,9 +407,39 @@ export class SalesRequestsRepository {
|
||||
updatedAt: DateTime.fromUnixMs(row.updatedAt),
|
||||
createdBy: row.createdBy,
|
||||
updatedBy: row.updatedBy,
|
||||
salesPerson: null,
|
||||
branch: null,
|
||||
division: null,
|
||||
customer: null,
|
||||
createdByUser: { id: row.createdBy, username: '' },
|
||||
updatedByUser: { id: row.updatedBy, username: '' },
|
||||
};
|
||||
}
|
||||
|
||||
private async hydrate(items: SalesRequest[]): Promise<SalesRequest[]> {
|
||||
const withHeader = await attachSalesHeaderRelations(this.db, items);
|
||||
const productIds = withHeader.flatMap((item) =>
|
||||
item.products.map((line) => line.productId),
|
||||
);
|
||||
const products = await loadProductRelationMap(this.db, productIds);
|
||||
return withHeader.map((item) => ({
|
||||
...item,
|
||||
products: item.products.map((line) => ({
|
||||
...line,
|
||||
product: catalogRelationFromMap(products, line.productId),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
private async hydrateOne(
|
||||
row: SalesRequestRow,
|
||||
products: SalesRequestProductRow[],
|
||||
images: SalesRequestImageRow[],
|
||||
): Promise<SalesRequest> {
|
||||
const [item] = await this.hydrate([this.toDomain(row, products, images)]);
|
||||
return item;
|
||||
}
|
||||
|
||||
private rethrowConstraintViolation(error: unknown): never {
|
||||
if (error instanceof NotFoundException) {
|
||||
throw error;
|
||||
|
||||
@@ -62,6 +62,7 @@ describe('SalesRequestsService', () => {
|
||||
{
|
||||
id: 'line-1',
|
||||
productId: 'prd-1',
|
||||
product: { id: 'prd-1', code: 'P1', name: 'Fuel' },
|
||||
quantity: Decimal.create('2'),
|
||||
price: Decimal.create('12500'),
|
||||
},
|
||||
@@ -72,6 +73,12 @@ describe('SalesRequestsService', () => {
|
||||
updatedAt: now,
|
||||
createdBy: 'user-1',
|
||||
updatedBy: 'user-1',
|
||||
salesPerson: { id: 'emp-1', code: 'E1', name: 'Ada' },
|
||||
branch: { id: 'br-1', code: 'B1', name: 'Jakarta' },
|
||||
division: { id: 'div-1', code: 'FIN', name: 'Finance' },
|
||||
customer: { id: 'cus-1', code: 'C1', name: 'Acme' },
|
||||
createdByUser: { id: 'user-1', username: 'admin' },
|
||||
updatedByUser: { id: 'user-1', username: 'admin' },
|
||||
};
|
||||
|
||||
const createBody = {
|
||||
|
||||
@@ -4,7 +4,12 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type { PaginationResponse } from '../../../common/http/response';
|
||||
import { toListPage } from '../../../common/http/response';
|
||||
import {
|
||||
DEFAULT_RELATION_FIELDS,
|
||||
pickRelation,
|
||||
pickUserRelation,
|
||||
toListPage,
|
||||
} from '../../../common/http/response';
|
||||
import { InvalidDateTimeError } from '../../../common/value-objects/date-time/invalid-date-time.error';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||
@@ -54,6 +59,8 @@ export type ListSalesRequestsQuery = {
|
||||
readonly branchId?: string;
|
||||
readonly divisionId?: string;
|
||||
readonly search?: string;
|
||||
readonly orderBy?: string;
|
||||
readonly orderType?: string;
|
||||
readonly page?: number;
|
||||
readonly limit?: number;
|
||||
readonly offset?: number;
|
||||
@@ -93,6 +100,8 @@ export class SalesRequestsService {
|
||||
branchId: query.branchId,
|
||||
divisionId: query.divisionId,
|
||||
search: query.search,
|
||||
orderBy: query.orderBy,
|
||||
orderType: query.orderType,
|
||||
limit: page.limit,
|
||||
offset: page.offset,
|
||||
});
|
||||
@@ -302,10 +311,10 @@ export class SalesRequestsService {
|
||||
id: item.id,
|
||||
code: item.code,
|
||||
date: item.date.value,
|
||||
salesPersonId: item.salesPersonId,
|
||||
branchId: item.branchId,
|
||||
divisionId: item.divisionId,
|
||||
customerId: item.customerId,
|
||||
salesPerson: pickRelation(item.salesPerson, DEFAULT_RELATION_FIELDS),
|
||||
branch: pickRelation(item.branch, DEFAULT_RELATION_FIELDS),
|
||||
division: pickRelation(item.division, DEFAULT_RELATION_FIELDS),
|
||||
customer: pickRelation(item.customer, DEFAULT_RELATION_FIELDS),
|
||||
address: item.address,
|
||||
latitude: item.latitude,
|
||||
longitude: item.longitude,
|
||||
@@ -313,8 +322,8 @@ export class SalesRequestsService {
|
||||
status: item.status.value,
|
||||
createdAt: item.createdAt.value,
|
||||
updatedAt: item.updatedAt.value,
|
||||
createdBy: item.createdBy,
|
||||
updatedBy: item.updatedBy,
|
||||
createdBy: pickUserRelation(item.createdByUser),
|
||||
updatedBy: pickUserRelation(item.updatedByUser),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -323,7 +332,7 @@ export class SalesRequestsService {
|
||||
...this.toListItem(item),
|
||||
products: item.products.map((line) => ({
|
||||
id: line.id,
|
||||
productId: line.productId,
|
||||
product: pickRelation(line.product, DEFAULT_RELATION_FIELDS),
|
||||
quantity: line.quantity.value,
|
||||
price: line.price.value,
|
||||
})),
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import type {
|
||||
DefaultRelation,
|
||||
UserRelation,
|
||||
} from '../../../common/http/response';
|
||||
import type { DrizzleDB } from '../../../database/database.module';
|
||||
import {
|
||||
catalogRelationFromMap,
|
||||
loadBranchRelationMap,
|
||||
loadCustomerRelationMap,
|
||||
loadDivisionRelationMap,
|
||||
loadEmployeeRelationMap,
|
||||
loadPackingSlipRelationMap,
|
||||
loadProductRelationMap,
|
||||
loadSalesInvoiceRelationMap,
|
||||
loadSalesOrderRelationMap,
|
||||
loadSalesRequestRelationMap,
|
||||
} from '../../../database/load-catalog-refs';
|
||||
import {
|
||||
loadUserRelationMap,
|
||||
userRelationFromMap,
|
||||
} from '../../../database/load-user-refs';
|
||||
|
||||
export type SalesHeaderRelations = {
|
||||
readonly salesPerson: DefaultRelation | null;
|
||||
readonly branch: DefaultRelation | null;
|
||||
readonly division: DefaultRelation | null;
|
||||
readonly customer: DefaultRelation | null;
|
||||
readonly createdByUser: UserRelation;
|
||||
readonly updatedByUser: UserRelation;
|
||||
};
|
||||
|
||||
export async function attachSalesHeaderRelations<
|
||||
T extends {
|
||||
salesPersonId: string;
|
||||
branchId: string;
|
||||
divisionId: string;
|
||||
customerId: string;
|
||||
createdBy: string;
|
||||
updatedBy: string;
|
||||
},
|
||||
>(db: DrizzleDB, items: T[]): Promise<Array<T & SalesHeaderRelations>> {
|
||||
const [employees, branches, divisions, customers, users] = await Promise.all([
|
||||
loadEmployeeRelationMap(
|
||||
db,
|
||||
items.map((item) => item.salesPersonId),
|
||||
),
|
||||
loadBranchRelationMap(
|
||||
db,
|
||||
items.map((item) => item.branchId),
|
||||
),
|
||||
loadDivisionRelationMap(
|
||||
db,
|
||||
items.map((item) => item.divisionId),
|
||||
),
|
||||
loadCustomerRelationMap(
|
||||
db,
|
||||
items.map((item) => item.customerId),
|
||||
),
|
||||
loadUserRelationMap(
|
||||
db,
|
||||
items.flatMap((item) => [item.createdBy, item.updatedBy]),
|
||||
),
|
||||
]);
|
||||
return items.map((item) => ({
|
||||
...item,
|
||||
salesPerson: catalogRelationFromMap(employees, item.salesPersonId),
|
||||
branch: catalogRelationFromMap(branches, item.branchId),
|
||||
division: catalogRelationFromMap(divisions, item.divisionId),
|
||||
customer: catalogRelationFromMap(customers, item.customerId),
|
||||
createdByUser: userRelationFromMap(users, item.createdBy),
|
||||
updatedByUser: userRelationFromMap(users, item.updatedBy),
|
||||
}));
|
||||
}
|
||||
|
||||
export {
|
||||
catalogRelationFromMap,
|
||||
loadPackingSlipRelationMap,
|
||||
loadProductRelationMap,
|
||||
loadSalesInvoiceRelationMap,
|
||||
loadSalesOrderRelationMap,
|
||||
loadSalesRequestRelationMap,
|
||||
};
|
||||
Reference in New Issue
Block a user