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:
shancheas
2026-08-27 13:09:41 +07:00
parent 790725e227
commit 4c45a4371e
85 changed files with 1824 additions and 365 deletions
@@ -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,
};