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
+16 -1
View File
@@ -23,12 +23,27 @@ export {
PAGINATION_MAX_LIMIT,
} from './pagination.constants';
export {
CODE_RELATION_FIELDS,
DEFAULT_RELATION_FIELDS,
fallbackUserRelation,
pickCodeRelation,
pickDefaultRelation,
pickRelation,
pickUserRelation,
USER_RELATION_FIELDS,
type CodeRelation,
type DefaultRelation,
type UserRelation,
} from './relation-fields';
export { DefaultRelationDto, UserRelationDto } from './relation.dto';
export {
CodeRelationDto,
DefaultRelationDto,
UserRelationDto,
} from './relation.dto';
export {
ORDER_TYPES,
toOrderClauses,
type ListOrderQuery,
type OrderDefault,
type OrderType,
} from './order-clause';
@@ -0,0 +1,71 @@
import { BadRequestException } from '@nestjs/common';
import { asc, desc } from 'drizzle-orm';
import { integer, pgTable } from 'drizzle-orm/pg-core';
import { toOrderClauses } from './order-clause';
const sample = pgTable('sample', {
code: integer('code'),
name: integer('name'),
createdAt: integer('created_at'),
});
const columns = {
code: sample.code,
name: sample.name,
createdAt: sample.createdAt,
};
describe('toOrderClauses', () => {
it('uses default columns when orderBy is omitted', () => {
expect(
toOrderClauses(columns, {}, [{ column: 'code', type: 'ASC' }]),
).toEqual([asc(sample.code)]);
});
it('applies multiple defaults when orderBy is omitted', () => {
expect(
toOrderClauses(columns, {}, [
{ column: 'createdAt', type: 'ASC' },
{ column: 'code', type: 'ASC' },
]),
).toEqual([asc(sample.createdAt), asc(sample.code)]);
});
it('uses a single client column and DESC', () => {
expect(
toOrderClauses(columns, { orderBy: 'name', orderType: 'DESC' }, [
{ column: 'code', type: 'ASC' },
]),
).toEqual([desc(sample.name)]);
});
it('applies orderType to defaults when orderBy is omitted', () => {
expect(
toOrderClauses(columns, { orderType: 'DESC' }, [
{ column: 'code', type: 'ASC' },
]),
).toEqual([desc(sample.code)]);
});
it('rejects unknown orderBy without echoing the raw value as SQL', () => {
expect(() =>
toOrderClauses(columns, { orderBy: 'drop table' }, [{ column: 'code' }]),
).toThrow(BadRequestException);
try {
toOrderClauses(columns, { orderBy: 'drop table' }, [{ column: 'code' }]);
} catch (error) {
expect((error as BadRequestException).message).toContain(
'code, name, createdAt',
);
expect((error as BadRequestException).message).not.toContain(
'drop table',
);
}
});
it('rejects invalid orderType', () => {
expect(() =>
toOrderClauses(columns, { orderType: 'SIDEWAYS' }, [{ column: 'code' }]),
).toThrow(BadRequestException);
});
});
+57
View File
@@ -0,0 +1,57 @@
import { BadRequestException } from '@nestjs/common';
import { asc, desc, type SQL } from 'drizzle-orm';
export const ORDER_TYPES = ['ASC', 'DESC'] as const;
export type OrderType = (typeof ORDER_TYPES)[number];
export type ListOrderQuery = {
readonly orderBy?: string;
readonly orderType?: string;
};
export type OrderDefault = {
readonly column?: string;
readonly type?: OrderType;
};
export function toOrderClauses(
columns: Record<string, Parameters<typeof asc>[0]>,
query: ListOrderQuery,
defaults: readonly OrderDefault[],
): SQL[] {
if (query.orderBy) {
return [toSql(columns, query.orderBy, normalizeOrderType(query.orderType))];
}
const typeOverride =
query.orderType != null && query.orderType !== ''
? normalizeOrderType(query.orderType)
: undefined;
return defaults.map((entry) =>
toSql(columns, entry.column ?? '', typeOverride ?? entry.type ?? 'ASC'),
);
}
function toSql(
columns: Record<string, Parameters<typeof asc>[0]>,
column: string,
type: OrderType,
): SQL {
const selected = columns[column];
if (selected == null) {
throw new BadRequestException(
`Invalid orderBy. Allowed: ${Object.keys(columns).join(', ')}`,
);
}
return type === 'DESC' ? desc(selected) : asc(selected);
}
function normalizeOrderType(raw?: string): OrderType {
if (raw == null || raw === '') {
return 'ASC';
}
const normalized = raw.toUpperCase();
if (normalized === 'ASC' || normalized === 'DESC') {
return normalized;
}
throw new BadRequestException('Invalid orderType. Allowed: ASC, DESC');
}
@@ -1,5 +1,7 @@
import { Type } from 'class-transformer';
import { IsInt, IsOptional, Max, Min } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import { IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
import { ORDER_TYPES } from './order-clause';
import { PAGINATION_MAX_LIMIT } from './pagination.constants';
export class PaginationQueryDto {
@@ -21,4 +23,19 @@ export class PaginationQueryDto {
@IsInt()
@Min(0)
offset?: number;
@ApiPropertyOptional({
description: 'Column to order by (resource response field name)',
})
@IsOptional()
@IsString()
orderBy?: string;
@ApiPropertyOptional({ enum: ORDER_TYPES, default: 'ASC' })
@IsOptional()
@Transform(({ value }: { value: unknown }) =>
typeof value === 'string' ? value.toUpperCase() : value,
)
@IsIn([...ORDER_TYPES])
orderType?: (typeof ORDER_TYPES)[number];
}
@@ -1,5 +1,7 @@
import {
CODE_RELATION_FIELDS,
DEFAULT_RELATION_FIELDS,
pickCodeRelation,
pickDefaultRelation,
pickRelation,
pickUserRelation,
@@ -60,6 +62,19 @@ describe('pickRelation', () => {
});
});
describe('pickCodeRelation', () => {
it('picks id and code only', () => {
expect(
pickCodeRelation({
id: 'so-1',
code: 'SO-001',
}),
).toEqual({ id: 'so-1', code: 'SO-001' });
expect(CODE_RELATION_FIELDS).toEqual(['id', 'code']);
expect(pickCodeRelation(null)).toBeNull();
});
});
describe('pickDefaultRelation / pickUserRelation', () => {
it('maps catalog and user sources without leaking extra fields', () => {
expect(
@@ -1,5 +1,6 @@
export const DEFAULT_RELATION_FIELDS = ['id', 'code', 'name'] as const;
export const USER_RELATION_FIELDS = ['id', 'username'] as const;
export const CODE_RELATION_FIELDS = ['id', 'code'] as const;
export type DefaultRelation = {
readonly id: string;
@@ -12,6 +13,11 @@ export type UserRelation = {
readonly username: string;
};
export type CodeRelation = {
readonly id: string;
readonly code: string;
};
export function pickRelation<T, K extends keyof NonNullable<T>>(
source: T | null | undefined,
fields: readonly K[],
@@ -40,3 +46,16 @@ export function pickUserRelation(source: UserRelation): UserRelation {
}
);
}
export function pickCodeRelation(
source: CodeRelation | null | undefined,
): CodeRelation | null {
return pickRelation(source, CODE_RELATION_FIELDS);
}
export function fallbackUserRelation(
source: UserRelation | null | undefined,
fallbackId: string,
): UserRelation {
return pickUserRelation(source ?? { id: fallbackId, username: '' });
}
+8
View File
@@ -18,3 +18,11 @@ export class UserRelationDto {
@ApiProperty()
username!: string;
}
export class CodeRelationDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty()
code!: string;
}
+3 -1
View File
@@ -14,7 +14,9 @@ export const employees = pgTable(
name: varchar('name', { length: 64 }).notNull(),
phone: text('phone').notNull(),
position: text('position').notNull(),
userId: uuid('user_id').references(() => users.id, { onDelete: 'set null' }),
userId: uuid('user_id').references(() => users.id, {
onDelete: 'set null',
}),
...primaryEntityColumns(users),
},
(t) => [
+129
View File
@@ -0,0 +1,129 @@
import { inArray } from 'drizzle-orm';
import type { CodeRelation, DefaultRelation } from '../common/http/response';
import type { DrizzleDB } from './database.module';
import {
branches,
customers,
divisions,
employees,
packingSlips,
products,
salesInvoices,
salesOrders,
salesRequests,
} from './schema';
type CatalogTable =
| typeof employees
| typeof branches
| typeof divisions
| typeof customers
| typeof products;
type CodeTable =
| typeof salesOrders
| typeof salesRequests
| typeof packingSlips
| typeof salesInvoices;
async function loadDefaultMap(
db: DrizzleDB,
table: CatalogTable,
ids: readonly string[],
): Promise<Map<string, DefaultRelation>> {
const unique = [...new Set(ids.filter((id) => id.length > 0))];
if (unique.length === 0) {
return new Map();
}
const rows = await db
.select({ id: table.id, code: table.code, name: table.name })
.from(table)
.where(inArray(table.id, unique));
return new Map(
rows.map((row) => [row.id, { id: row.id, code: row.code, name: row.name }]),
);
}
async function loadCodeMap(
db: DrizzleDB,
table: CodeTable,
ids: readonly string[],
): Promise<Map<string, CodeRelation>> {
const unique = [...new Set(ids.filter((id) => id.length > 0))];
if (unique.length === 0) {
return new Map();
}
const rows = await db
.select({ id: table.id, code: table.code })
.from(table)
.where(inArray(table.id, unique));
return new Map(rows.map((row) => [row.id, { id: row.id, code: row.code }]));
}
export function loadEmployeeRelationMap(db: DrizzleDB, ids: readonly string[]) {
return loadDefaultMap(db, employees, ids);
}
export function loadBranchRelationMap(db: DrizzleDB, ids: readonly string[]) {
return loadDefaultMap(db, branches, ids);
}
export function loadDivisionRelationMap(db: DrizzleDB, ids: readonly string[]) {
return loadDefaultMap(db, divisions, ids);
}
export function loadCustomerRelationMap(db: DrizzleDB, ids: readonly string[]) {
return loadDefaultMap(db, customers, ids);
}
export function loadProductRelationMap(db: DrizzleDB, ids: readonly string[]) {
return loadDefaultMap(db, products, ids);
}
export function loadSalesRequestRelationMap(
db: DrizzleDB,
ids: readonly string[],
) {
return loadCodeMap(db, salesRequests, ids);
}
export function loadSalesOrderRelationMap(
db: DrizzleDB,
ids: readonly string[],
) {
return loadCodeMap(db, salesOrders, ids);
}
export function loadPackingSlipRelationMap(
db: DrizzleDB,
ids: readonly string[],
) {
return loadCodeMap(db, packingSlips, ids);
}
export function loadSalesInvoiceRelationMap(
db: DrizzleDB,
ids: readonly string[],
) {
return loadCodeMap(db, salesInvoices, ids);
}
export function catalogRelationFromMap(
map: Map<string, DefaultRelation>,
id: string | null | undefined,
): DefaultRelation | null {
if (!id) {
return null;
}
return map.get(id) ?? null;
}
export function codeRelationFromMap(
map: Map<string, CodeRelation>,
id: string | null | undefined,
): CodeRelation | null {
if (!id) {
return null;
}
return map.get(id) ?? null;
}
+47
View File
@@ -0,0 +1,47 @@
import { inArray } from 'drizzle-orm';
import type { UserRelation } from '../common/http/response';
import type { DrizzleDB } from './database.module';
import { users } from './schema';
export async function loadUserRelationMap(
db: DrizzleDB,
ids: readonly string[],
): Promise<Map<string, UserRelation>> {
const unique = [...new Set(ids.filter((id) => id.length > 0))];
if (unique.length === 0) {
return new Map();
}
const rows = await db
.select({ id: users.id, username: users.username })
.from(users)
.where(inArray(users.id, unique));
return new Map(
rows.map((row) => [row.id, { id: row.id, username: row.username }]),
);
}
export function userRelationFromMap(
map: Map<string, UserRelation>,
id: string,
): UserRelation {
return map.get(id) ?? { id, username: '' };
}
export async function attachAuditUsers<
T extends { createdBy: string; updatedBy: string },
>(
db: DrizzleDB,
items: T[],
): Promise<
Array<T & { createdByUser: UserRelation; updatedByUser: UserRelation }>
> {
const map = await loadUserRelationMap(
db,
items.flatMap((item) => [item.createdBy, item.updatedBy]),
);
return items.map((item) => ({
...item,
createdByUser: userRelationFromMap(map, item.createdBy),
updatedByUser: userRelationFromMap(map, item.updatedBy),
}));
}
@@ -82,6 +82,8 @@ export type ListBranchesFilters = {
readonly workingHoursStart?: string;
readonly workingHoursEnd?: string;
readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly limit: number;
readonly offset: number;
};
@@ -5,7 +5,8 @@ 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 { alias } from 'drizzle-orm/pg-core';
import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
@@ -24,6 +25,18 @@ import type {
UpdateBranchInput,
} from './branch';
const BRANCH_ORDER_COLUMNS = {
id: branches.id,
code: branches.code,
name: branches.name,
phone: branches.phone,
address: branches.address,
nfcId: branches.nfcId,
status: branches.status,
createdAt: branches.createdAt,
updatedAt: branches.updatedAt,
};
const createdByUsers = alias(users, 'created_by_users');
const updatedByUsers = alias(users, 'updated_by_users');
@@ -52,7 +65,11 @@ export class BranchesRepository {
qb = this.extendListQuery(qb, filters);
const rows = await qb
.where(where)
.orderBy(asc(branches.code))
.orderBy(
...toOrderClauses(BRANCH_ORDER_COLUMNS, filters, [
{ column: 'code', type: 'ASC' },
]),
)
.limit(filters.limit)
.offset(filters.offset);
@@ -4,7 +4,12 @@ import {
NotFoundException,
} from '@nestjs/common';
import type { PaginationResponse } from '../../../common/http/response';
import { toListPage } from '../../../common/http/response';
import {
pickRelation,
pickUserRelation,
DEFAULT_RELATION_FIELDS,
toListPage,
} from '../../../common/http/response';
import { InvalidPhoneNumberError } from '../../../common/value-objects/phone-number/invalid-phone-number.error';
import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
import { Status } from '../../../common/value-objects/status/status';
@@ -36,6 +41,8 @@ export type ListBranchesQuery = {
readonly workingHoursStart?: string;
readonly workingHoursEnd?: string;
readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly page?: number;
readonly limit?: number;
readonly offset?: number;
@@ -94,6 +101,8 @@ export class BranchesService {
workingHoursStart: query.workingHoursStart,
workingHoursEnd: query.workingHoursEnd,
search: query.search,
orderBy: query.orderBy,
orderType: query.orderType,
limit: page.limit,
offset: page.offset,
});
@@ -341,31 +350,15 @@ export class BranchesService {
workingHoursStart: branch.workingHoursStart,
workingHoursEnd: branch.workingHoursEnd,
nfcId: branch.nfcId,
division: this.toDivisionItem(branch.division),
division: pickRelation(branch.division, DEFAULT_RELATION_FIELDS),
status: branch.status.value,
createdAt: branch.createdAt.value,
updatedAt: branch.updatedAt.value,
createdBy: this.toUserItem(branch.createdByUser),
updatedBy: this.toUserItem(branch.updatedByUser),
createdBy: pickUserRelation(branch.createdByUser),
updatedBy: pickUserRelation(branch.updatedByUser),
};
}
private toDivisionItem(
division: { id: string; code: string; name: string } | null,
): { id: string; code: string; name: string } | null {
if (division == null) {
return null;
}
return { id: division.id, code: division.code, name: division.name };
}
private toUserItem(user: { id: string; username: string }): {
id: string;
username: string;
} {
return { id: user.id, username: user.username };
}
get visibleFields(): readonly string[] {
return VISIBLE_FIELDS;
}
@@ -1,3 +1,4 @@
import type { UserRelation } from '../../../common/http/response';
import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
import { Status } from '../../../common/value-objects/status/status';
@@ -27,6 +28,8 @@ export type Customer = {
readonly updatedAt: DateTime;
readonly createdBy: string;
readonly updatedBy: string;
readonly createdByUser: UserRelation;
readonly updatedByUser: UserRelation;
};
export type CustomerContactInput = {
@@ -79,6 +82,8 @@ export type ListCustomersFilters = {
readonly nfcId?: string;
readonly status?: string;
readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly limit: number;
readonly offset: number;
};
@@ -67,7 +67,13 @@ describe('CustomersRepository', () => {
beforeEach(async () => {
jest.clearAllMocks();
where.mockImplementation(() => ({ limit, orderBy, returning }));
where.mockImplementation(() =>
Object.assign(Promise.resolve([{ id: 'user-1', username: 'admin' }]), {
limit,
orderBy,
returning,
}),
);
orderBy.mockImplementation(() => ({ limit }));
limit.mockImplementation(() => ({ offset }));
offset.mockResolvedValue([row]);
@@ -5,6 +5,8 @@ import {
NotFoundException,
} from '@nestjs/common';
import { and, asc, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm';
import { toOrderClauses } from '../../../common/http/response';
import { attachAuditUsers } from '../../../database/load-user-refs';
import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
import { Status } from '../../../common/value-objects/status/status';
@@ -26,6 +28,18 @@ import type {
UpdateCustomerInput,
} from './customer';
const CUSTOMER_ORDER_COLUMNS = {
id: customers.id,
code: customers.code,
name: customers.name,
phone: customers.phone,
address: customers.address,
nfcId: customers.nfcId,
status: customers.status,
createdAt: customers.createdAt,
updatedAt: customers.updatedAt,
};
type QueryExecutor = Pick<DrizzleDB, 'select' | 'insert' | 'delete'>;
@Injectable()
@@ -46,12 +60,16 @@ export class CustomersRepository {
qb = this.extendListQuery(qb, filters);
const rows = await qb
.where(where)
.orderBy(asc(customers.code))
.orderBy(
...toOrderClauses(CUSTOMER_ORDER_COLUMNS, filters, [
{ column: 'code', type: 'ASC' },
]),
)
.limit(filters.limit)
.offset(filters.offset);
return {
data: rows.map((row) => this.toDomain(row, [])),
data: await Promise.all(rows.map((row) => this.hydrate(row, []))),
total: Number(totalRow?.total ?? 0),
};
}
@@ -75,7 +93,7 @@ export class CustomersRepository {
return null;
}
const contacts = await this.selectContacts(this.db, id);
return this.toDomain(row, contacts);
return this.hydrate(row, contacts);
}
async findByCode(code: string): Promise<Customer | null> {
@@ -89,7 +107,7 @@ export class CustomersRepository {
return null;
}
const contacts = await this.selectContacts(this.db, row.id);
return this.toDomain(row, contacts);
return this.hydrate(row, contacts);
}
async create(input: CreateCustomerInput): Promise<Customer> {
@@ -104,7 +122,7 @@ export class CustomersRepository {
const row = inserted[0];
await this.replaceContacts(tx, row.id, input.contacts ?? []);
const contacts = await this.selectContacts(tx, row.id);
return this.toDomain(row, contacts);
return this.hydrate(row, contacts);
});
} catch (error) {
this.rethrowConstraintViolation(error);
@@ -170,7 +188,7 @@ export class CustomersRepository {
await this.replaceContacts(tx, id, input.contacts);
}
const contacts = await this.selectContacts(tx, id);
return this.toDomain(row, contacts);
return this.hydrate(row, contacts);
});
} catch (error) {
this.rethrowConstraintViolation(error);
@@ -197,7 +215,7 @@ export class CustomersRepository {
throw new NotFoundException('Customer not found');
}
const contacts = await this.selectContacts(this.db, id);
return this.toDomain(row, contacts);
return this.hydrate(row, contacts);
}
async bulkUpdateStatus(
@@ -437,10 +455,17 @@ export class CustomersRepository {
};
}
private toDomain(
private async hydrate(
row: CustomerRow,
contactRows: CustomerContactRow[],
): Customer {
): Promise<Customer> {
const [item] = await attachAuditUsers(this.db, [
this.toDomain(row, contactRows),
]);
return item;
}
private toDomain(row: CustomerRow, contactRows: CustomerContactRow[]) {
return {
id: row.id,
code: row.code,
@@ -52,6 +52,8 @@ describe('CustomersService', () => {
createdAt: now,
updatedAt: now,
createdBy: 'user-1',
createdByUser: { id: 'user-1', username: 'admin' },
updatedByUser: { id: 'user-1', username: 'admin' },
updatedBy: 'user-1',
};
@@ -4,7 +4,7 @@ import {
NotFoundException,
} from '@nestjs/common';
import type { PaginationResponse } from '../../../common/http/response';
import { toListPage } from '../../../common/http/response';
import { pickUserRelation, toListPage } from '../../../common/http/response';
import { InvalidPhoneNumberError } from '../../../common/value-objects/phone-number/invalid-phone-number.error';
import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
import { Status } from '../../../common/value-objects/status/status';
@@ -38,6 +38,8 @@ export type ListCustomersQuery = {
readonly nfcId?: string;
readonly status?: string;
readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly page?: number;
readonly limit?: number;
readonly offset?: number;
@@ -85,6 +87,8 @@ export class CustomersService {
nfcId: query.nfcId,
status: query.status,
search: query.search,
orderBy: query.orderBy,
orderType: query.orderType,
limit: page.limit,
offset: page.offset,
});
@@ -350,8 +354,8 @@ export class CustomersService {
status: customer.status.value,
createdAt: customer.createdAt.value,
updatedAt: customer.updatedAt.value,
createdBy: customer.createdBy,
updatedBy: customer.updatedBy,
createdBy: pickUserRelation(customer.createdByUser),
updatedBy: pickUserRelation(customer.updatedByUser),
};
}
@@ -15,7 +15,10 @@ import {
Min,
ValidateNested,
} from 'class-validator';
import { PaginationQueryDto } from '../../../../common/http/response';
import {
PaginationQueryDto,
UserRelationDto,
} from '../../../../common/http/response';
import { CORE_STATUSES } from '../../../../common/value-objects/status/status';
import {
CONTACT_JOB_TITLE_MAX_LENGTH,
@@ -347,9 +350,9 @@ export class CustomerDto {
@ApiProperty({ description: 'Unix ms' })
updatedAt!: number;
@ApiProperty({ format: 'uuid' })
createdBy!: string;
@ApiProperty({ type: UserRelationDto })
createdBy!: UserRelationDto;
@ApiProperty({ format: 'uuid' })
updatedBy!: string;
@ApiProperty({ type: UserRelationDto })
updatedBy!: UserRelationDto;
}
@@ -1,3 +1,4 @@
import type { UserRelation } from '../../../common/http/response';
import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Status } from '../../../common/value-objects/status/status';
@@ -10,6 +11,8 @@ export type Division = {
readonly updatedAt: DateTime;
readonly createdBy: string;
readonly updatedBy: string;
readonly createdByUser: UserRelation;
readonly updatedByUser: UserRelation;
};
export type CreateDivisionInput = {
@@ -30,6 +33,8 @@ export type ListDivisionsFilters = {
readonly code?: string;
readonly status?: string;
readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly limit: number;
readonly offset: number;
};
@@ -43,7 +43,13 @@ describe('DivisionsRepository', () => {
beforeEach(async () => {
jest.clearAllMocks();
where.mockImplementation(() => ({ limit, orderBy }));
where.mockImplementation(() =>
Object.assign(Promise.resolve([{ id: 'user-1', username: 'admin' }]), {
limit,
orderBy,
returning,
}),
);
orderBy.mockImplementation(() => ({ limit }));
limit.mockImplementation(() => ({ offset }));
offset.mockResolvedValue([row]);
@@ -59,7 +65,6 @@ describe('DivisionsRepository', () => {
update.mockReturnValue({ set });
del.mockReturnValue({ where });
returning.mockResolvedValue([row]);
where.mockImplementation(() => ({ limit, orderBy, returning }));
const moduleRef: TestingModule = await Test.createTestingModule({
providers: [DivisionsRepository, { provide: DRIZZLE, useValue: db }],
@@ -4,10 +4,12 @@ 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 { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Status } from '../../../common/value-objects/status/status';
import { DRIZZLE, type DrizzleDB } from '../../../database/database.module';
import { attachAuditUsers } from '../../../database/load-user-refs';
import { divisions, type DivisionRow } from '../../../database/schema';
import type {
CreateDivisionInput,
@@ -16,6 +18,15 @@ import type {
UpdateDivisionInput,
} from './division';
const DIVISION_ORDER_COLUMNS = {
id: divisions.id,
name: divisions.name,
code: divisions.code,
status: divisions.status,
createdAt: divisions.createdAt,
updatedAt: divisions.updatedAt,
};
@Injectable()
export class DivisionsRepository {
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
@@ -34,12 +45,16 @@ export class DivisionsRepository {
qb = this.extendListQuery(qb, filters);
const rows = await qb
.where(where)
.orderBy(asc(divisions.code))
.orderBy(
...toOrderClauses(DIVISION_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),
total: Number(totalRow?.total ?? 0),
};
}
@@ -59,7 +74,7 @@ export class DivisionsRepository {
.where(eq(divisions.id, id))
.limit(1);
const row = rows[0];
return row ? this.toDomain(row) : null;
return row ? this.hydrateOne(row) : null;
}
async findByCode(code: string): Promise<Division | null> {
@@ -69,7 +84,7 @@ export class DivisionsRepository {
.where(eq(divisions.code, code))
.limit(1);
const row = rows[0];
return row ? this.toDomain(row) : null;
return row ? this.hydrateOne(row) : null;
}
async create(input: CreateDivisionInput): Promise<Division> {
@@ -89,7 +104,7 @@ export class DivisionsRepository {
})
.returning();
const row = inserted[0];
return this.toDomain(row);
return this.hydrateOne(row);
} catch (error) {
this.rethrowUniqueViolation(error);
}
@@ -139,7 +154,7 @@ export class DivisionsRepository {
.where(eq(divisions.id, id))
.returning();
const row = updated[0];
return this.toDomain(row);
return this.hydrateOne(row);
} catch (error) {
this.rethrowUniqueViolation(error);
}
@@ -164,7 +179,7 @@ export class DivisionsRepository {
if (!row) {
throw new NotFoundException('Division not found');
}
return this.toDomain(row);
return this.hydrateOne(row);
}
async bulkUpdateStatus(
@@ -243,17 +258,25 @@ export class DivisionsRepository {
return parts.length === 1 ? parts[0] : and(...parts);
}
private toDomain(row: DivisionRow): Division {
return {
id: row.id,
name: row.name,
code: row.code,
status: Status.create(row.status),
createdAt: DateTime.fromUnixMs(row.createdAt),
updatedAt: DateTime.fromUnixMs(row.updatedAt),
createdBy: row.createdBy,
updatedBy: row.updatedBy,
};
private async hydrate(rows: DivisionRow[]): Promise<Division[]> {
return attachAuditUsers(
this.db,
rows.map((row) => ({
id: row.id,
name: row.name,
code: row.code,
status: Status.create(row.status),
createdAt: DateTime.fromUnixMs(row.createdAt),
updatedAt: DateTime.fromUnixMs(row.updatedAt),
createdBy: row.createdBy,
updatedBy: row.updatedBy,
})),
);
}
private async hydrateOne(row: DivisionRow): Promise<Division> {
const [item] = await this.hydrate([row]);
return item;
}
private rethrowUniqueViolation(error: unknown): never {
@@ -33,6 +33,8 @@ describe('DivisionsService', () => {
updatedAt: now,
createdBy: 'user-1',
updatedBy: 'user-1',
createdByUser: { id: 'user-1', username: 'admin' },
updatedByUser: { id: 'user-1', username: 'admin' },
};
beforeEach(async () => {
@@ -68,6 +70,7 @@ describe('DivisionsService', () => {
code: 'FIN',
status: 'draft',
createdAt: now.value,
createdBy: { id: 'user-1', username: 'admin' },
});
expect(service.visibleFields).toContain('status');
});
@@ -4,7 +4,7 @@ import {
NotFoundException,
} from '@nestjs/common';
import type { PaginationResponse } from '../../../common/http/response';
import { toListPage } from '../../../common/http/response';
import { pickUserRelation, toListPage } from '../../../common/http/response';
import { Status } from '../../../common/value-objects/status/status';
import type {
CreateDivisionInput,
@@ -19,6 +19,8 @@ export type ListDivisionsQuery = {
readonly code?: string;
readonly status?: string;
readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly page?: number;
readonly limit?: number;
readonly offset?: number;
@@ -48,6 +50,8 @@ export class DivisionsService {
code: query.code,
status: query.status,
search: query.search,
orderBy: query.orderBy,
orderType: query.orderType,
limit: page.limit,
offset: page.offset,
});
@@ -221,8 +225,8 @@ export class DivisionsService {
status: division.status.value,
createdAt: division.createdAt.value,
updatedAt: division.updatedAt.value,
createdBy: division.createdBy,
updatedBy: division.updatedBy,
createdBy: pickUserRelation(division.createdByUser),
updatedBy: pickUserRelation(division.updatedByUser),
};
}
@@ -10,7 +10,10 @@ import {
Matches,
MaxLength,
} from 'class-validator';
import { PaginationQueryDto } from '../../../../common/http/response';
import {
PaginationQueryDto,
UserRelationDto,
} from '../../../../common/http/response';
import { CORE_STATUSES } from '../../../../common/value-objects/status/status';
import {
DIVISION_CODE_MAX_LENGTH,
@@ -19,6 +22,15 @@ import {
DIVISION_NAME_PATTERN,
} from '../division-fields';
export const DIVISION_ORDER_FIELDS = [
'id',
'name',
'code',
'status',
'createdAt',
'updatedAt',
] as const;
export class CreateDivisionDto {
@ApiProperty({
example: 'Human Resources',
@@ -138,9 +150,9 @@ export class DivisionDto {
@ApiProperty({ description: 'Unix ms' })
updatedAt!: number;
@ApiProperty({ format: 'uuid' })
createdBy!: string;
@ApiProperty({ type: UserRelationDto })
createdBy!: UserRelationDto;
@ApiProperty({ format: 'uuid' })
updatedBy!: string;
@ApiProperty({ type: UserRelationDto })
updatedBy!: UserRelationDto;
}
@@ -247,11 +247,11 @@ export class EmployeeDto {
@ApiProperty({ description: 'Unix ms' })
updatedAt!: number;
@ApiProperty({ format: 'uuid' })
createdBy!: string;
@ApiProperty({ type: UserRelationDto })
createdBy!: UserRelationDto;
@ApiProperty({ format: 'uuid' })
updatedBy!: string;
@ApiProperty({ type: UserRelationDto })
updatedBy!: UserRelationDto;
@ApiPropertyOptional({ type: UserRelationDto, nullable: true })
user!: UserRelationDto | null;
@@ -1,3 +1,4 @@
import type { UserRelation } from '../../../common/http/response';
import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
import { Status } from '../../../common/value-objects/status/status';
@@ -14,6 +15,8 @@ export type Employee = {
readonly updatedAt: DateTime;
readonly createdBy: string;
readonly updatedBy: string;
readonly createdByUser: UserRelation;
readonly updatedByUser: UserRelation;
readonly userId: string | null;
readonly user: { readonly id: string; readonly username: string } | null;
};
@@ -51,6 +54,8 @@ export type ListEmployeesFilters = {
readonly status?: string;
readonly userId?: string;
readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly limit: number;
readonly offset: number;
};
@@ -57,7 +57,13 @@ describe('EmployeesRepository', () => {
beforeEach(async () => {
jest.clearAllMocks();
where.mockImplementation(() => ({ limit, orderBy, returning }));
where.mockImplementation(() =>
Object.assign(Promise.resolve([{ id: 'user-1', username: 'admin' }]), {
limit,
orderBy,
returning,
}),
);
orderBy.mockImplementation(() => ({ limit }));
limit.mockImplementation(() => ({ offset }));
offset.mockResolvedValue([row]);
@@ -123,14 +129,14 @@ describe('EmployeesRepository', () => {
.mockImplementationOnce(() => ({
from: () => ({
$dynamic: () => ({
where: () => ({
orderBy: () => ({
limit: () => ({
offset: () => Promise.resolve([row]),
}),
where: () => ({
orderBy: () => ({
limit: () => ({
offset: () => Promise.resolve([row]),
}),
}),
}),
}),
}),
}));
@@ -4,7 +4,9 @@ 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 { attachAuditUsers } from '../../../database/load-user-refs';
import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
import { Status } from '../../../common/value-objects/status/status';
@@ -19,6 +21,17 @@ import type {
UpdateEmployeeInput,
} from './employee';
const EMPLOYEE_ORDER_COLUMNS = {
id: employees.id,
code: employees.code,
name: employees.name,
phone: employees.phone,
position: employees.position,
status: employees.status,
createdAt: employees.createdAt,
updatedAt: employees.updatedAt,
};
@Injectable()
export class EmployeesRepository {
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
@@ -37,16 +50,21 @@ export class EmployeesRepository {
qb = this.extendListQuery(qb, filters);
const rows = await qb
.where(where)
.orderBy(asc(employees.code))
.orderBy(
...toOrderClauses(EMPLOYEE_ORDER_COLUMNS, filters, [
{ column: 'code', type: 'ASC' },
]),
)
.limit(filters.limit)
.offset(filters.offset);
return {
data: await Promise.all(
rows.map(async (row) =>
this.toDomain(row, await this.loadAssignedUser(row.userId)),
),
const mapped = await Promise.all(
rows.map(async (row) =>
this.hydrateOne(row, await this.loadAssignedUser(row.userId)),
),
);
return {
data: mapped,
total: Number(totalRow?.total ?? 0),
};
}
@@ -67,7 +85,7 @@ export class EmployeesRepository {
.limit(1);
const row = rows[0];
return row
? this.toDomain(row, await this.loadAssignedUser(row.userId))
? this.hydrateOne(row, await this.loadAssignedUser(row.userId))
: null;
}
@@ -79,7 +97,7 @@ export class EmployeesRepository {
.limit(1);
const row = rows[0];
return row
? this.toDomain(row, await this.loadAssignedUser(row.userId))
? this.hydrateOne(row, await this.loadAssignedUser(row.userId))
: null;
}
@@ -91,7 +109,7 @@ export class EmployeesRepository {
.limit(1);
const row = rows[0];
return row
? this.toDomain(row, await this.loadAssignedUser(row.userId))
? this.hydrateOne(row, await this.loadAssignedUser(row.userId))
: null;
}
@@ -104,7 +122,7 @@ export class EmployeesRepository {
.values(this.toInsertValues(input, status, now, input.userId))
.returning();
const row = inserted[0];
return this.toDomain(row, await this.loadAssignedUser(row.userId));
return this.hydrateOne(row, await this.loadAssignedUser(row.userId));
} catch (error) {
this.rethrowUniqueViolation(error);
}
@@ -153,7 +171,7 @@ export class EmployeesRepository {
.where(eq(employees.id, id))
.returning();
const row = updated[0];
return this.toDomain(row, await this.loadAssignedUser(row.userId));
return this.hydrateOne(row, await this.loadAssignedUser(row.userId));
} catch (error) {
this.rethrowUniqueViolation(error);
}
@@ -178,7 +196,7 @@ export class EmployeesRepository {
if (!row) {
throw new NotFoundException('Employee not found');
}
return this.toDomain(row, await this.loadAssignedUser(row.userId));
return this.hydrateOne(row, await this.loadAssignedUser(row.userId));
}
async bulkUpdateStatus(
@@ -308,7 +326,7 @@ export class EmployeesRepository {
private toDomain(
row: EmployeeRow,
user: { id: string; username: string } | null,
): Employee {
) {
return {
id: row.id,
code: row.code,
@@ -325,6 +343,14 @@ export class EmployeesRepository {
};
}
private async hydrateOne(
row: EmployeeRow,
user: { id: string; username: string } | null,
): Promise<Employee> {
const [item] = await attachAuditUsers(this.db, [this.toDomain(row, user)]);
return item;
}
private rethrowUniqueViolation(error: unknown): never {
const err = this.unwrapDbError(error);
if (err.code === '23505') {
@@ -45,6 +45,8 @@ describe('EmployeesService', () => {
updatedAt: now,
createdBy: 'user-1',
updatedBy: 'user-1',
createdByUser: { id: 'user-1', username: 'admin' },
updatedByUser: { id: 'user-1', username: 'admin' },
userId: null,
user: null,
};
@@ -5,7 +5,12 @@ import {
NotFoundException,
} from '@nestjs/common';
import type { PaginationResponse } from '../../../common/http/response';
import { toListPage } from '../../../common/http/response';
import {
pickRelation,
pickUserRelation,
USER_RELATION_FIELDS,
toListPage,
} from '../../../common/http/response';
import { InvalidPhoneNumberError } from '../../../common/value-objects/phone-number/invalid-phone-number.error';
import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
import { Status } from '../../../common/value-objects/status/status';
@@ -33,6 +38,8 @@ export type ListEmployeesQuery = {
readonly status?: string;
readonly userId?: string;
readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly page?: number;
readonly limit?: number;
readonly offset?: number;
@@ -73,6 +80,8 @@ export class EmployeesService {
status: query.status,
userId: query.userId,
search: query.search,
orderBy: query.orderBy,
orderType: query.orderType,
limit: page.limit,
offset: page.offset,
});
@@ -284,9 +293,9 @@ export class EmployeesService {
status: employee.status.value,
createdAt: employee.createdAt.value,
updatedAt: employee.updatedAt.value,
createdBy: employee.createdBy,
updatedBy: employee.updatedBy,
user: employee.user,
createdBy: pickUserRelation(employee.createdByUser),
updatedBy: pickUserRelation(employee.updatedByUser),
user: pickRelation(employee.user, USER_RELATION_FIELDS),
};
}
@@ -10,7 +10,10 @@ import {
Matches,
MaxLength,
} from 'class-validator';
import { PaginationQueryDto } from '../../../../common/http/response';
import {
PaginationQueryDto,
UserRelationDto,
} from '../../../../common/http/response';
import { CORE_STATUSES } from '../../../../common/value-objects/status/status';
import {
PRODUCT_BRAND_MAX_LENGTH,
@@ -200,9 +203,9 @@ export class ProductDto {
@ApiProperty({ description: 'Unix ms' })
updatedAt!: number;
@ApiProperty({ format: 'uuid' })
createdBy!: string;
@ApiProperty({ type: UserRelationDto })
createdBy!: UserRelationDto;
@ApiProperty({ format: 'uuid' })
updatedBy!: string;
@ApiProperty({ type: UserRelationDto })
updatedBy!: UserRelationDto;
}
@@ -1,3 +1,4 @@
import type { 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';
@@ -14,6 +15,8 @@ export type Product = {
readonly updatedAt: DateTime;
readonly createdBy: string;
readonly updatedBy: string;
readonly createdByUser: UserRelation;
readonly updatedByUser: UserRelation;
};
export type CreateProductInput = {
@@ -42,6 +45,8 @@ export type ListProductsFilters = {
readonly brand?: string;
readonly status?: string;
readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly limit: number;
readonly offset: number;
};
@@ -56,7 +56,13 @@ describe('ProductsRepository', () => {
beforeEach(async () => {
jest.clearAllMocks();
where.mockImplementation(() => ({ limit, orderBy, returning }));
where.mockImplementation(() =>
Object.assign(Promise.resolve([{ id: 'user-1', username: 'admin' }]), {
limit,
orderBy,
returning,
}),
);
orderBy.mockImplementation(() => ({ limit }));
limit.mockImplementation(() => ({ offset }));
offset.mockResolvedValue([row]);
@@ -4,7 +4,9 @@ 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 { 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';
@@ -17,6 +19,17 @@ import type {
UpdateProductInput,
} from './product';
const PRODUCT_ORDER_COLUMNS = {
id: products.id,
code: products.code,
name: products.name,
unit: products.unit,
brand: products.brand,
status: products.status,
createdAt: products.createdAt,
updatedAt: products.updatedAt,
};
@Injectable()
export class ProductsRepository {
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
@@ -35,12 +48,16 @@ export class ProductsRepository {
qb = this.extendListQuery(qb, filters);
const rows = await qb
.where(where)
.orderBy(asc(products.code))
.orderBy(
...toOrderClauses(PRODUCT_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),
total: Number(totalRow?.total ?? 0),
};
}
@@ -60,7 +77,7 @@ export class ProductsRepository {
.where(eq(products.id, id))
.limit(1);
const row = rows[0];
return row ? this.toDomain(row) : null;
return row ? await this.hydrateOne(row) : null;
}
async findByCode(code: string): Promise<Product | null> {
@@ -70,7 +87,7 @@ export class ProductsRepository {
.where(eq(products.code, code))
.limit(1);
const row = rows[0];
return row ? this.toDomain(row) : null;
return row ? await this.hydrateOne(row) : null;
}
async create(input: CreateProductInput): Promise<Product> {
@@ -82,7 +99,7 @@ export class ProductsRepository {
.values(this.toInsertValues(input, status, now, input.userId))
.returning();
const row = inserted[0];
return this.toDomain(row);
return await this.hydrateOne(row);
} catch (error) {
this.rethrowConstraintViolation(error);
}
@@ -132,7 +149,7 @@ export class ProductsRepository {
.where(eq(products.id, id))
.returning();
const row = updated[0];
return this.toDomain(row);
return await this.hydrateOne(row);
} catch (error) {
this.rethrowConstraintViolation(error);
}
@@ -157,7 +174,7 @@ export class ProductsRepository {
if (!row) {
throw new NotFoundException('Product not found');
}
return this.toDomain(row);
return await this.hydrateOne(row);
}
async bulkUpdateStatus(
@@ -262,7 +279,7 @@ export class ProductsRepository {
};
}
private toDomain(row: ProductRow): Product {
private toDomain(row: ProductRow) {
return {
id: row.id,
code: row.code,
@@ -278,6 +295,18 @@ export class ProductsRepository {
};
}
private async hydrate(rows: ProductRow[]): Promise<Product[]> {
return attachAuditUsers(
this.db,
rows.map((row) => this.toDomain(row)),
);
}
private async hydrateOne(row: ProductRow): Promise<Product> {
const [item] = await this.hydrate([row]);
return item;
}
private rethrowConstraintViolation(error: unknown): never {
const err = this.unwrapDbError(error);
if (err.code === '23505') {
@@ -36,6 +36,8 @@ describe('ProductsService', () => {
createdAt: now,
updatedAt: now,
createdBy: 'user-1',
createdByUser: { id: 'user-1', username: 'admin' },
updatedByUser: { id: 'user-1', username: 'admin' },
updatedBy: 'user-1',
};
@@ -4,7 +4,7 @@ import {
NotFoundException,
} from '@nestjs/common';
import type { PaginationResponse } from '../../../common/http/response';
import { toListPage } from '../../../common/http/response';
import { pickUserRelation, toListPage } from '../../../common/http/response';
import { Decimal } from '../../../common/value-objects/decimal/decimal';
import { InvalidDecimalError } from '../../../common/value-objects/decimal/invalid-decimal.error';
import { Status } from '../../../common/value-objects/status/status';
@@ -29,6 +29,8 @@ export type ListProductsQuery = {
readonly brand?: string;
readonly status?: string;
readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly page?: number;
readonly limit?: number;
readonly offset?: number;
@@ -65,6 +67,8 @@ export class ProductsService {
brand: query.brand,
status: query.status,
search: query.search,
orderBy: query.orderBy,
orderType: query.orderType,
limit: page.limit,
offset: page.offset,
});
@@ -240,8 +244,8 @@ export class ProductsService {
status: product.status.value,
createdAt: product.createdAt.value,
updatedAt: product.updatedAt.value,
createdBy: product.createdBy,
updatedBy: product.updatedBy,
createdBy: pickUserRelation(product.createdByUser),
updatedBy: pickUserRelation(product.updatedByUser),
};
}
+12
View File
@@ -1,3 +1,7 @@
import type {
DefaultRelation,
UserRelation,
} from '../../../common/http/response';
import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Status } from '../../../common/value-objects/status/status';
import type { FieldPurpose, WeekdayName } from '../shared/field-purpose';
@@ -7,6 +11,7 @@ import type { WeekdaysInput } from '../shared/field-fields';
export type CycleDestination = {
readonly id: string;
readonly customerId: string;
readonly customer: DefaultRelation | null;
readonly sortOrder: number;
};
@@ -15,6 +20,8 @@ export type CycleWeekday = {
readonly weekday: WeekdayName;
readonly startBranchId: string;
readonly endBranchId: string;
readonly startBranch: DefaultRelation | null;
readonly endBranch: DefaultRelation | null;
readonly routeGeometry: RouteGeometry;
readonly destinations: readonly CycleDestination[];
};
@@ -22,6 +29,7 @@ export type CycleWeekday = {
export type Cycle = {
readonly id: string;
readonly employeeId: string;
readonly employee: DefaultRelation | null;
readonly purpose: FieldPurpose;
readonly cycleNumber: number;
readonly weekdays: readonly CycleWeekday[];
@@ -30,6 +38,8 @@ export type Cycle = {
readonly updatedAt: DateTime;
readonly createdBy: string;
readonly updatedBy: string;
readonly createdByUser: UserRelation;
readonly updatedByUser: UserRelation;
};
export type CreateCycleInput = {
@@ -55,6 +65,8 @@ export type ListCyclesFilters = {
readonly cycleNumber?: number;
readonly status?: string;
readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly purposes?: readonly string[];
readonly limit: number;
readonly offset: number;
+81 -6
View File
@@ -16,6 +16,14 @@ import {
sql,
SQL,
} from 'drizzle-orm';
import { toOrderClauses } from '../../../common/http/response';
import {
catalogRelationFromMap,
loadBranchRelationMap,
loadCustomerRelationMap,
loadEmployeeRelationMap,
} from '../../../database/load-catalog-refs';
import { attachAuditUsers } from '../../../database/load-user-refs';
import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Status } from '../../../common/value-objects/status/status';
import { DRIZZLE, type DrizzleDB } from '../../../database/database.module';
@@ -30,6 +38,16 @@ import {
import type { FieldPurpose, WeekdayName } from '../shared/field-purpose';
import type { Cycle, ListCyclesFilters, PersistableWeekday } from './cycle';
const CYCLE_ORDER_COLUMNS = {
id: cycles.id,
employeeId: cycles.employeeId,
purpose: cycles.purpose,
cycleNumber: cycles.cycleNumber,
status: cycles.status,
createdAt: cycles.createdAt,
updatedAt: cycles.updatedAt,
};
type QueryExecutor = Pick<DrizzleDB, 'select' | 'insert' | 'delete' | 'update'>;
@Injectable()
@@ -48,11 +66,15 @@ export class CyclesRepository {
qb = this.extendListQuery(qb, filters);
const rows = await qb
.where(where)
.orderBy(asc(cycles.cycleNumber))
.orderBy(
...toOrderClauses(CYCLE_ORDER_COLUMNS, filters, [
{ column: 'cycleNumber', 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 CyclesRepository {
this.db,
weekdays.map((weekday) => weekday.id),
);
return this.toDomain(row, weekdays, destinations);
return this.hydrateOne(row, weekdays, destinations);
}
async findLiveByKey(
@@ -101,7 +123,7 @@ export class CyclesRepository {
.where(and(...parts))
.limit(1);
const row = rows[0];
return row ? this.toDomain(row, [], []) : null;
return row ? this.hydrateOne(row, [], []) : null;
}
async listActiveByEmployeePurpose(
@@ -126,7 +148,7 @@ export class CyclesRepository {
this.db,
weekdays.map((weekday) => weekday.id),
);
result.push(this.toDomain(row, weekdays, destinations));
result.push(await this.hydrateOne(row, weekdays, destinations));
}
return result;
}
@@ -291,7 +313,7 @@ export class CyclesRepository {
executor,
weekdays.map((weekday) => weekday.id),
);
return this.toDomain(row, weekdays, destinations);
return this.hydrateOne(row, weekdays, destinations);
}
private async replaceWeekdays(
@@ -406,11 +428,14 @@ export class CyclesRepository {
weekday: weekday.weekday as WeekdayName,
startBranchId: weekday.startBranchId,
endBranchId: weekday.endBranchId,
startBranch: null,
endBranch: null,
routeGeometry: weekday.routeGeometry,
destinations: (destinationsByWeekday.get(weekday.id) ?? []).map(
(destination) => ({
id: destination.id,
customerId: destination.customerId,
customer: null,
sortOrder: destination.sortOrder,
}),
),
@@ -420,9 +445,59 @@ export class CyclesRepository {
updatedAt: DateTime.fromUnixMs(row.updatedAt),
createdBy: row.createdBy,
updatedBy: row.updatedBy,
employee: null,
createdByUser: { id: row.createdBy, username: '' },
updatedByUser: { id: row.updatedBy, username: '' },
};
}
private async hydrate(items: Cycle[]): Promise<Cycle[]> {
const withAudit = await attachAuditUsers(this.db, items);
const branchIds = withAudit.flatMap((item) =>
item.weekdays.flatMap((weekday) => [
weekday.startBranchId,
weekday.endBranchId,
]),
);
const customerIds = withAudit.flatMap((item) =>
item.weekdays.flatMap((weekday) =>
weekday.destinations.map((destination) => destination.customerId),
),
);
const [employees, branches, customers] = await Promise.all([
loadEmployeeRelationMap(
this.db,
withAudit.map((item) => item.employeeId),
),
loadBranchRelationMap(this.db, branchIds),
loadCustomerRelationMap(this.db, customerIds),
]);
return withAudit.map((item) => ({
...item,
employee: catalogRelationFromMap(employees, item.employeeId),
weekdays: item.weekdays.map((weekday) => ({
...weekday,
startBranch: catalogRelationFromMap(branches, weekday.startBranchId),
endBranch: catalogRelationFromMap(branches, weekday.endBranchId),
destinations: weekday.destinations.map((destination) => ({
...destination,
customer: catalogRelationFromMap(customers, destination.customerId),
})),
})),
}));
}
private async hydrateOne(
row: CycleRow,
weekdays: CycleWeekdayRow[],
destinations: CycleDestinationRow[],
): Promise<Cycle> {
const [item] = await this.hydrate([
this.toDomain(row, weekdays, destinations),
]);
return item;
}
private rethrowConstraintViolation(error: unknown): never {
const err = this.unwrapDbError(error);
if (err.code === '23505') {
@@ -62,8 +62,17 @@ describe('CyclesService', () => {
weekday: 'monday',
startBranchId: 'br-1',
endBranchId: 'br-2',
startBranch: { id: 'br-1', code: 'B1', name: 'Start' },
endBranch: { id: 'br-2', code: 'B2', name: 'End' },
routeGeometry: geometry,
destinations: [{ id: 'dest-1', customerId: 'cus-1', sortOrder: 0 }],
destinations: [
{
id: 'dest-1',
customerId: 'cus-1',
customer: { id: 'cus-1', code: 'C1', name: 'Acme' },
sortOrder: 0,
},
],
},
],
status: Status.create('draft'),
@@ -71,6 +80,9 @@ describe('CyclesService', () => {
updatedAt: now,
createdBy: 'user-1',
updatedBy: 'user-1',
employee: { id: 'emp-1', code: 'E1', name: 'Ada' },
createdByUser: { id: 'user-1', username: 'admin' },
updatedByUser: { id: 'user-1', username: 'admin' },
};
const located = { id: 'x', latitude: -6.2, longitude: 106.8 };
+16 -7
View File
@@ -7,7 +7,12 @@ import {
} from '@nestjs/common';
import type { AuthUser } from '../../../common/auth/auth-user';
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 { InvalidStatusError } from '../../../common/value-objects/status/invalid-status.error';
import { Status } from '../../../common/value-objects/status/status';
import { BranchesService } from '../../configuration/branches/branches.service';
@@ -40,6 +45,8 @@ export type ListCyclesQuery = {
readonly cycleNumber?: number;
readonly status?: 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 CyclesService {
cycleNumber: query.cycleNumber,
status: query.status,
search: query.search,
orderBy: query.orderBy,
orderType: query.orderType,
purposes: query.purpose ? undefined : purposes,
limit: page.limit,
offset: page.offset,
@@ -237,26 +246,26 @@ export class CyclesService {
toItem(cycle: Cycle) {
return {
id: cycle.id,
employeeId: cycle.employeeId,
employee: pickRelation(cycle.employee, DEFAULT_RELATION_FIELDS),
purpose: cycle.purpose,
cycleNumber: cycle.cycleNumber,
weekdays: cycle.weekdays.map((weekday) => ({
id: weekday.id,
weekday: weekday.weekday,
startBranchId: weekday.startBranchId,
endBranchId: weekday.endBranchId,
startBranch: pickRelation(weekday.startBranch, DEFAULT_RELATION_FIELDS),
endBranch: pickRelation(weekday.endBranch, DEFAULT_RELATION_FIELDS),
routeGeometry: weekday.routeGeometry,
destinations: weekday.destinations.map((destination) => ({
id: destination.id,
customerId: destination.customerId,
customer: pickRelation(destination.customer, DEFAULT_RELATION_FIELDS),
sortOrder: destination.sortOrder,
})),
})),
status: cycle.status.value,
createdAt: cycle.createdAt.value,
updatedAt: cycle.updatedAt.value,
createdBy: cycle.createdBy,
updatedBy: cycle.updatedBy,
createdBy: pickUserRelation(cycle.createdByUser),
updatedBy: pickUserRelation(cycle.updatedByUser),
};
}
+17 -13
View File
@@ -13,7 +13,11 @@ import {
Min,
ValidateNested,
} from 'class-validator';
import { PaginationQueryDto } from '../../../../common/http/response';
import {
DefaultRelationDto,
PaginationQueryDto,
UserRelationDto,
} from '../../../../common/http/response';
import { CORE_STATUSES } from '../../../../common/value-objects/status/status';
import { FIELD_PURPOSES, WEEKDAY_NAMES } from '../../shared/field-purpose';
import { RouteGeometryDto } from '../../shared/route-geometry.dto';
@@ -143,8 +147,8 @@ export class CycleDestinationDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty({ format: 'uuid' })
customerId!: string;
@ApiProperty({ type: DefaultRelationDto, nullable: true })
customer!: DefaultRelationDto | null;
@ApiProperty()
sortOrder!: number;
@@ -157,11 +161,11 @@ export class CycleWeekdayDto {
@ApiProperty({ enum: WEEKDAY_NAMES })
weekday!: string;
@ApiProperty({ format: 'uuid' })
startBranchId!: string;
@ApiProperty({ type: DefaultRelationDto, nullable: true })
startBranch!: DefaultRelationDto | null;
@ApiProperty({ format: 'uuid' })
endBranchId!: string;
@ApiProperty({ type: DefaultRelationDto, nullable: true })
endBranch!: DefaultRelationDto | null;
@ApiProperty({ type: RouteGeometryDto })
routeGeometry!: RouteGeometryDto;
@@ -174,8 +178,8 @@ export class CycleDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty({ format: 'uuid' })
employeeId!: string;
@ApiProperty({ type: DefaultRelationDto, nullable: true })
employee!: DefaultRelationDto | null;
@ApiProperty({ enum: FIELD_PURPOSES })
purpose!: string;
@@ -195,11 +199,11 @@ export class CycleDto {
@ApiProperty()
updatedAt!: number;
@ApiProperty({ format: 'uuid' })
createdBy!: string;
@ApiProperty({ type: UserRelationDto })
createdBy!: UserRelationDto;
@ApiProperty({ format: 'uuid' })
updatedBy!: string;
@ApiProperty({ type: UserRelationDto })
updatedBy!: UserRelationDto;
}
void ValidateNested;
+22 -17
View File
@@ -10,7 +10,12 @@ import {
IsUUID,
Matches,
} from 'class-validator';
import { PaginationQueryDto } from '../../../../common/http/response';
import {
CodeRelationDto,
DefaultRelationDto,
PaginationQueryDto,
UserRelationDto,
} from '../../../../common/http/response';
import { CORE_STATUSES } from '../../../../common/value-objects/status/status';
import { FIELD_PURPOSES } from '../../shared/field-purpose';
import { RouteGeometryDto } from '../../shared/route-geometry.dto';
@@ -199,8 +204,8 @@ export class PlanDestinationDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty({ format: 'uuid' })
customerId!: string;
@ApiProperty({ type: DefaultRelationDto, nullable: true })
customer!: DefaultRelationDto | null;
@ApiProperty()
sortOrder!: number;
@@ -210,8 +215,8 @@ export class PlanDto {
@ApiProperty({ format: 'uuid' })
id!: string;
@ApiProperty({ format: 'uuid' })
employeeId!: string;
@ApiProperty({ type: DefaultRelationDto, nullable: true })
employee!: DefaultRelationDto | null;
@ApiProperty({ enum: FIELD_PURPOSES })
purpose!: string;
@@ -219,11 +224,11 @@ export class PlanDto {
@ApiProperty()
date!: number;
@ApiProperty({ format: 'uuid' })
startBranchId!: string;
@ApiProperty({ type: DefaultRelationDto, nullable: true })
startBranch!: DefaultRelationDto | null;
@ApiProperty({ format: 'uuid' })
endBranchId!: string;
@ApiProperty({ type: DefaultRelationDto, nullable: true })
endBranch!: DefaultRelationDto | null;
@ApiProperty({ type: RouteGeometryDto })
routeGeometry!: RouteGeometryDto;
@@ -231,11 +236,11 @@ export class PlanDto {
@ApiProperty({ type: [PlanDestinationDto] })
destinations!: PlanDestinationDto[];
@ApiProperty({ type: [String] })
invoiceIds!: string[];
@ApiProperty({ type: [CodeRelationDto] })
invoices!: CodeRelationDto[];
@ApiProperty({ type: [String] })
packingSlipIds!: string[];
@ApiProperty({ type: [CodeRelationDto] })
packingSlips!: CodeRelationDto[];
@ApiProperty({ enum: CORE_STATUSES })
status!: string;
@@ -246,11 +251,11 @@ export class PlanDto {
@ApiProperty()
updatedAt!: number;
@ApiProperty({ format: 'uuid' })
createdBy!: string;
@ApiProperty({ type: UserRelationDto })
createdBy!: UserRelationDto;
@ApiProperty({ format: 'uuid' })
updatedBy!: string;
@ApiProperty({ type: UserRelationDto })
updatedBy!: UserRelationDto;
}
void Type;
+15
View File
@@ -1,3 +1,8 @@
import type {
CodeRelation,
DefaultRelation,
UserRelation,
} from '../../../common/http/response';
import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Status } from '../../../common/value-objects/status/status';
import type { FieldPurpose } from '../shared/field-purpose';
@@ -6,25 +11,33 @@ import type { RouteGeometry } from '../shared/route-line-string';
export type PlanDestination = {
readonly id: string;
readonly customerId: string;
readonly customer: DefaultRelation | null;
readonly sortOrder: number;
};
export type Plan = {
readonly id: string;
readonly employeeId: string;
readonly employee: DefaultRelation | null;
readonly purpose: FieldPurpose;
readonly date: DateTime;
readonly startBranchId: string;
readonly endBranchId: string;
readonly startBranch: DefaultRelation | null;
readonly endBranch: DefaultRelation | null;
readonly routeGeometry: RouteGeometry;
readonly destinations: readonly PlanDestination[];
readonly invoiceIds: readonly string[];
readonly packingSlipIds: readonly string[];
readonly invoices: readonly CodeRelation[];
readonly packingSlips: readonly CodeRelation[];
readonly status: Status;
readonly createdAt: DateTime;
readonly updatedAt: DateTime;
readonly createdBy: string;
readonly updatedBy: string;
readonly createdByUser: UserRelation;
readonly updatedByUser: UserRelation;
};
export type PersistablePlanDestination = {
@@ -37,6 +50,8 @@ export type ListPlansFilters = {
readonly date?: number;
readonly status?: string;
readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly purposes?: readonly string[];
readonly limit: number;
readonly offset: number;
+97 -6
View File
@@ -16,6 +16,16 @@ import {
sql,
SQL,
} from 'drizzle-orm';
import { toOrderClauses } from '../../../common/http/response';
import {
catalogRelationFromMap,
loadBranchRelationMap,
loadCustomerRelationMap,
loadEmployeeRelationMap,
loadPackingSlipRelationMap,
loadSalesInvoiceRelationMap,
} from '../../../database/load-catalog-refs';
import { attachAuditUsers } from '../../../database/load-user-refs';
import { DateTime } from '../../../common/value-objects/date-time/date-time';
import { Status } from '../../../common/value-objects/status/status';
import { DRIZZLE, type DrizzleDB } from '../../../database/database.module';
@@ -33,6 +43,16 @@ import type { FieldPurpose } from '../shared/field-purpose';
import type { RouteGeometry } from '../shared/route-line-string';
import type { ListPlansFilters, Plan } from './plan';
const PLAN_ORDER_COLUMNS = {
id: plans.id,
employeeId: plans.employeeId,
purpose: plans.purpose,
date: plans.date,
status: plans.status,
createdAt: plans.createdAt,
updatedAt: plans.updatedAt,
};
type QueryExecutor = Pick<DrizzleDB, 'select' | 'insert' | 'delete' | 'update'>;
@Injectable()
@@ -51,11 +71,17 @@ export class PlansRepository {
qb = this.extendListQuery(qb, filters);
const rows = await qb
.where(where)
.orderBy(asc(plans.date))
.orderBy(
...toOrderClauses(PLAN_ORDER_COLUMNS, filters, [
{ column: 'date', 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),
};
}
@@ -99,7 +125,7 @@ export class PlansRepository {
.where(and(...parts))
.limit(1);
const row = rows[0];
return row ? this.toDomain(row, [], [], []) : null;
return row ? this.hydrateOne(row, [], [], []) : null;
}
async create(input: {
@@ -327,7 +353,7 @@ export class PlansRepository {
.select()
.from(planPackingSlips)
.where(eq(planPackingSlips.planId, row.id));
return this.toDomain(row, destinations, invoices, packingSlips);
return this.hydrateOne(row, destinations, invoices, packingSlips);
}
private async replaceChildren(
@@ -423,21 +449,86 @@ export class PlansRepository {
startBranchId: row.startBranchId,
endBranchId: row.endBranchId,
routeGeometry: row.routeGeometry,
startBranch: null,
endBranch: null,
employee: null,
destinations: destinationRows.map((destination) => ({
id: destination.id,
customerId: destination.customerId,
customer: null,
sortOrder: destination.sortOrder,
})),
invoiceIds: invoiceRows.map((row) => row.invoiceId),
packingSlipIds: packingSlipRows.map((row) => row.packingSlipId),
invoiceIds: invoiceRows.map((invoice) => invoice.invoiceId),
packingSlipIds: packingSlipRows.map((slip) => slip.packingSlipId),
invoices: [],
packingSlips: [],
status: Status.create(row.status),
createdAt: DateTime.fromUnixMs(row.createdAt),
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: Plan[]): Promise<Plan[]> {
const withAudit = await attachAuditUsers(this.db, items);
const [employees, branches, customers, invoices, packing] =
await Promise.all([
loadEmployeeRelationMap(
this.db,
withAudit.map((item) => item.employeeId),
),
loadBranchRelationMap(
this.db,
withAudit.flatMap((item) => [item.startBranchId, item.endBranchId]),
),
loadCustomerRelationMap(
this.db,
withAudit.flatMap((item) =>
item.destinations.map((destination) => destination.customerId),
),
),
loadSalesInvoiceRelationMap(
this.db,
withAudit.flatMap((item) => item.invoiceIds),
),
loadPackingSlipRelationMap(
this.db,
withAudit.flatMap((item) => item.packingSlipIds),
),
]);
return withAudit.map((item) => ({
...item,
employee: catalogRelationFromMap(employees, item.employeeId),
startBranch: catalogRelationFromMap(branches, item.startBranchId),
endBranch: catalogRelationFromMap(branches, item.endBranchId),
destinations: item.destinations.map((destination) => ({
...destination,
customer: catalogRelationFromMap(customers, destination.customerId),
})),
invoices: item.invoiceIds
.map((id) => invoices.get(id))
.filter((value): value is NonNullable<typeof value> => Boolean(value)),
packingSlips: item.packingSlipIds
.map((id) => packing.get(id))
.filter((value): value is NonNullable<typeof value> => Boolean(value)),
}));
}
private async hydrateOne(
row: PlanRow,
destinations: PlanDestinationRow[],
invoices: PlanInvoiceRow[],
packingSlips: PlanPackingSlipRow[],
): Promise<Plan> {
const [item] = await this.hydrate([
this.toDomain(row, destinations, invoices, packingSlips),
]);
return item;
}
private rethrowConstraintViolation(error: unknown): never {
const err = this.unwrapDbError(error);
if (err.code === '23505') {
+34 -3
View File
@@ -68,8 +68,17 @@ describe('PlansService', () => {
weekday: 'monday',
startBranchId: 'br-1',
endBranchId: 'br-2',
startBranch: { id: 'br-1', code: 'B1', name: 'Start' },
endBranch: { id: 'br-2', code: 'B2', name: 'End' },
routeGeometry: geometry,
destinations: [{ id: 'cd-1', customerId: 'cus-1', sortOrder: 0 }],
destinations: [
{
id: 'cd-1',
customerId: 'cus-1',
customer: { id: 'cus-1', code: 'C1', name: 'Acme' },
sortOrder: 0,
},
],
},
],
status: Status.create('active'),
@@ -77,6 +86,9 @@ describe('PlansService', () => {
updatedAt: now,
createdBy: 'user-1',
updatedBy: 'user-1',
employee: { id: 'emp-1', code: 'E1', name: 'Ada' },
createdByUser: { id: 'user-1', username: 'admin' },
updatedByUser: { id: 'user-1', username: 'admin' },
};
const plan: Plan = {
@@ -87,14 +99,28 @@ describe('PlansService', () => {
startBranchId: 'br-1',
endBranchId: 'br-2',
routeGeometry: geometry,
destinations: [{ id: 'pd-1', customerId: 'cus-1', sortOrder: 0 }],
destinations: [
{
id: 'pd-1',
customerId: 'cus-1',
customer: { id: 'cus-1', code: 'C1', name: 'Acme' },
sortOrder: 0,
},
],
invoiceIds: [],
packingSlipIds: [],
invoices: [],
packingSlips: [],
startBranch: { id: 'br-1', code: 'B1', name: 'Start' },
endBranch: { id: 'br-2', code: 'B2', name: 'End' },
employee: { id: 'emp-1', code: 'E1', name: 'Ada' },
status: Status.create('active'),
createdAt: now,
updatedAt: now,
createdBy: 'user-1',
updatedBy: 'user-1',
createdByUser: { id: 'user-1', username: 'admin' },
updatedByUser: { id: 'user-1', username: 'admin' },
};
beforeEach(async () => {
@@ -196,7 +222,12 @@ describe('PlansService', () => {
...plan,
destinations: [
...plan.destinations,
{ id: 'pd-2', customerId: 'cus-2', sortOrder: 1 },
{
id: 'pd-2',
customerId: 'cus-2',
customer: { id: 'cus-2', code: 'C2', name: 'Beta' },
sortOrder: 1,
},
],
});
await service.addDestination('pln-1', 'cus-2', undefined, user);
+18 -9
View File
@@ -7,7 +7,12 @@ import {
} from '@nestjs/common';
import type { AuthUser } from '../../../common/auth/auth-user';
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 { InvalidStatusError } from '../../../common/value-objects/status/invalid-status.error';
@@ -40,6 +45,8 @@ export type ListPlansQuery = {
readonly date?: string;
readonly status?: string;
readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly page?: number;
readonly limit?: number;
readonly offset?: number;
@@ -96,6 +103,8 @@ export class PlansService {
: undefined,
status: query.status,
search: query.search,
orderBy: query.orderBy,
orderType: query.orderType,
purposes: query.purpose ? undefined : purposes,
limit: page.limit,
offset: page.offset,
@@ -390,24 +399,24 @@ export class PlansService {
toItem(plan: Plan) {
return {
id: plan.id,
employeeId: plan.employeeId,
employee: pickRelation(plan.employee, DEFAULT_RELATION_FIELDS),
purpose: plan.purpose,
date: plan.date.value,
startBranchId: plan.startBranchId,
endBranchId: plan.endBranchId,
startBranch: pickRelation(plan.startBranch, DEFAULT_RELATION_FIELDS),
endBranch: pickRelation(plan.endBranch, DEFAULT_RELATION_FIELDS),
routeGeometry: plan.routeGeometry,
destinations: plan.destinations.map((destination) => ({
id: destination.id,
customerId: destination.customerId,
customer: pickRelation(destination.customer, DEFAULT_RELATION_FIELDS),
sortOrder: destination.sortOrder,
})),
invoiceIds: [...plan.invoiceIds],
packingSlipIds: [...plan.packingSlipIds],
invoices: [...plan.invoices],
packingSlips: [...plan.packingSlips],
status: plan.status.value,
createdAt: plan.createdAt.value,
updatedAt: plan.updatedAt.value,
createdBy: plan.createdBy,
updatedBy: plan.updatedBy,
createdBy: pickUserRelation(plan.createdByUser),
updatedBy: pickUserRelation(plan.updatedByUser),
};
}
+8 -5
View File
@@ -12,7 +12,10 @@ import {
MaxLength,
ValidateNested,
} from 'class-validator';
import { PaginationQueryDto } from '../../../common/http/response';
import {
PaginationQueryDto,
UserRelationDto,
} from '../../../common/http/response';
import { CORE_STATUSES } from '../../../common/value-objects/status/status';
import { PRIVILEGE_ACTIONS } from '../privilege-action';
@@ -178,11 +181,11 @@ export class PrivilegeDto {
@ApiProperty({ description: 'Unix ms' })
updatedAt!: number;
@ApiProperty({ format: 'uuid' })
createdBy!: string;
@ApiProperty({ type: UserRelationDto })
createdBy!: UserRelationDto;
@ApiProperty({ format: 'uuid' })
updatedBy!: string;
@ApiProperty({ type: UserRelationDto })
updatedBy!: UserRelationDto;
}
export class PrivilegeDetailResponseDto extends PrivilegeDto {
+7
View File
@@ -1,3 +1,4 @@
import type { UserRelation } from '../../common/http/response';
import { DateTime } from '../../common/value-objects/date-time/date-time';
import { Status } from '../../common/value-objects/status/status';
import type { PrivilegeAction } from './privilege-action';
@@ -21,6 +22,8 @@ export type Privilege = {
readonly updatedAt: DateTime;
readonly createdBy: string;
readonly updatedBy: string;
readonly createdByUser: UserRelation;
readonly updatedByUser: UserRelation;
};
export type PrivilegeWithDetails = Privilege & {
@@ -60,12 +63,16 @@ export type ListPrivilegesFilters = {
readonly code?: string;
readonly status?: string;
readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly limit: number;
readonly offset: number;
};
export type ListPrivilegeKeysFilters = {
readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly limit: number;
readonly offset: number;
};
@@ -5,6 +5,8 @@ import {
NotFoundException,
} from '@nestjs/common';
import { and, asc, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm';
import { toOrderClauses } from '../../common/http/response';
import { attachAuditUsers } from '../../database/load-user-refs';
import { DateTime } from '../../common/value-objects/date-time/date-time';
import { Status } from '../../common/value-objects/status/status';
import { DRIZZLE, type DrizzleDB } from '../../database/database.module';
@@ -30,6 +32,22 @@ import type {
UpdatePrivilegeInput,
} from './privilege';
const PRIVILEGE_ORDER_COLUMNS = {
id: privileges.id,
name: privileges.name,
code: privileges.code,
status: privileges.status,
createdAt: privileges.createdAt,
updatedAt: privileges.updatedAt,
};
const PRIVILEGE_KEY_ORDER_COLUMNS = {
id: privilegeKeys.id,
code: privilegeKeys.code,
label: privilegeKeys.label,
sortOrder: privilegeKeys.sortOrder,
};
@Injectable()
export class PrivilegesRepository {
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
@@ -47,12 +65,16 @@ export class PrivilegesRepository {
qb = this.extendListQuery(qb, filters);
const rows = await qb
.where(where)
.orderBy(asc(privileges.code))
.orderBy(
...toOrderClauses(PRIVILEGE_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),
total: Number(totalRow?.total ?? 0),
};
}
@@ -75,7 +97,8 @@ export class PrivilegesRepository {
return null;
}
const details = await this.loadDetails(id);
return { ...this.toDomain(row), details };
const [base] = await this.hydrate([row]);
return { ...base, details };
}
async findByCode(code: string): Promise<Privilege | null> {
@@ -84,7 +107,7 @@ export class PrivilegesRepository {
.from(privileges)
.where(eq(privileges.code, code))
.limit(1);
return row ? this.toDomain(row) : null;
return row ? this.hydrateOne(row) : null;
}
async create(input: CreatePrivilegeInput): Promise<PrivilegeWithDetails> {
@@ -117,7 +140,8 @@ export class PrivilegesRepository {
}
const details = await this.loadDetails(row.id, tx);
return { ...this.toDomain(row), details };
const [base] = await this.hydrate([row]);
return { ...base, details };
});
} catch (error) {
this.rethrowUniqueViolation(error);
@@ -190,7 +214,8 @@ export class PrivilegesRepository {
}
const details = await this.loadDetails(id, tx);
return { ...this.toDomain(row), details };
const [base] = await this.hydrate([row]);
return { ...base, details };
});
} catch (error) {
this.rethrowUniqueViolation(error);
@@ -215,7 +240,7 @@ export class PrivilegesRepository {
if (!row) {
throw new NotFoundException('Privilege not found');
}
return this.toDomain(row);
return this.hydrateOne(row);
}
async bulkUpdateStatus(
@@ -297,7 +322,12 @@ export class PrivilegesRepository {
.select()
.from(privilegeKeys)
.where(where)
.orderBy(asc(privilegeKeys.sortOrder), asc(privilegeKeys.code))
.orderBy(
...toOrderClauses(PRIVILEGE_KEY_ORDER_COLUMNS, filters, [
{ column: 'sortOrder', type: 'ASC' },
{ column: 'code', type: 'ASC' },
]),
)
.limit(filters.limit)
.offset(filters.offset);
@@ -451,7 +481,7 @@ export class PrivilegesRepository {
}));
}
private toDomain(row: PrivilegeRow): Privilege {
private toDomain(row: PrivilegeRow) {
return {
id: row.id,
name: row.name,
@@ -464,6 +494,18 @@ export class PrivilegesRepository {
};
}
private async hydrate(rows: PrivilegeRow[]): Promise<Privilege[]> {
return attachAuditUsers(
this.db,
rows.map((row) => this.toDomain(row)),
);
}
private async hydrateOne(row: PrivilegeRow): Promise<Privilege> {
const [item] = await this.hydrate([row]);
return item;
}
private toKeyDomain(row: PrivilegeKeyRow): PrivilegeKey {
return {
id: row.id,
@@ -36,6 +36,8 @@ describe('PrivilegesService', () => {
createdAt: now,
updatedAt: now,
createdBy: 'user-1',
createdByUser: { id: 'user-1', username: 'admin' },
updatedByUser: { id: 'user-1', username: 'admin' },
updatedBy: 'user-1',
details: [],
};
+11 -3
View File
@@ -4,7 +4,7 @@ import {
NotFoundException,
} from '@nestjs/common';
import type { PaginationResponse } from '../../common/http/response';
import { toListPage } from '../../common/http/response';
import { pickUserRelation, toListPage } from '../../common/http/response';
import { Status } from '../../common/value-objects/status/status';
import type { PrivilegeAction } from './privilege-action';
import { assertPrivilegeAction } from './privilege-action';
@@ -23,6 +23,8 @@ export type ListPrivilegesQuery = {
readonly code?: string;
readonly status?: string;
readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly page?: number;
readonly limit?: number;
readonly offset?: number;
@@ -30,6 +32,8 @@ export type ListPrivilegesQuery = {
export type ListPrivilegeKeysQuery = {
readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly page?: number;
readonly limit?: number;
readonly offset?: number;
@@ -59,6 +63,8 @@ export class PrivilegesService {
code: query.code,
status: query.status,
search: query.search,
orderBy: query.orderBy,
orderType: query.orderType,
limit: page.limit,
offset: page.offset,
});
@@ -241,6 +247,8 @@ export class PrivilegesService {
const page = toListPage(query);
return this.privilegesRepository.listKeys({
search: query.search,
orderBy: query.orderBy,
orderType: query.orderType,
limit: page.limit,
offset: page.offset,
});
@@ -313,8 +321,8 @@ export class PrivilegesService {
status: privilege.status.value,
createdAt: privilege.createdAt.value,
updatedAt: privilege.updatedAt.value,
createdBy: privilege.createdBy,
updatedBy: privilege.updatedBy,
createdBy: pickUserRelation(privilege.createdByUser),
updatedBy: pickUserRelation(privilege.updatedByUser),
};
}
@@ -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,
};
+2
View File
@@ -53,6 +53,8 @@ export type ListUsersFilters = {
readonly privilegeId?: string;
readonly status?: string;
readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly limit: number;
readonly offset: number;
};
+15 -2
View File
@@ -4,7 +4,8 @@ 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 { alias } from 'drizzle-orm/pg-core';
import { randomUUID } from 'node:crypto';
import { DateTime } from '../../common/value-objects/date-time/date-time';
@@ -23,6 +24,14 @@ import type {
User,
} from './user';
const USER_ORDER_COLUMNS = {
id: users.id,
username: users.username,
status: users.status,
createdAt: users.createdAt,
updatedAt: users.updatedAt,
};
const createdByUsers = alias(users, 'created_by_users');
const updatedByUsers = alias(users, 'updated_by_users');
@@ -57,7 +66,11 @@ export class UsersRepository {
qb = this.extendListQuery(qb, filters);
const rows = await qb
.where(where)
.orderBy(asc(users.username))
.orderBy(
...toOrderClauses(USER_ORDER_COLUMNS, filters, [
{ column: 'username', type: 'ASC' },
]),
)
.limit(filters.limit)
.offset(filters.offset);
+2
View File
@@ -69,6 +69,8 @@ describe('UsersService', () => {
updatedAt: now,
createdBy: 'actor-1',
updatedBy: 'actor-1',
createdByUser: { id: 'actor-1', username: 'actor' },
updatedByUser: { id: 'actor-1', username: 'actor' },
userId: null,
user: null,
};
+4
View File
@@ -31,6 +31,8 @@ export type ListUsersQuery = {
readonly privilegeId?: string;
readonly status?: string;
readonly search?: string;
readonly orderBy?: string;
readonly orderType?: string;
readonly page?: number;
readonly limit?: number;
readonly offset?: number;
@@ -69,6 +71,8 @@ export class UsersService {
privilegeId: query.privilegeId,
status: query.status,
search: query.search,
orderBy: query.orderBy,
orderType: query.orderType,
limit: page.limit,
offset: page.offset,
});