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:
@@ -36,7 +36,7 @@ interface PaginationMeta {
|
|||||||
|
|
||||||
- Mark every list endpoint with `@Pagination()`
|
- Mark every list endpoint with `@Pagination()`
|
||||||
- Return `{ data, total }` from the handler — **never** build `meta` in the service or controller
|
- Return `{ data, total }` from the handler — **never** build `meta` in the service or controller
|
||||||
- Query: `page`/`limit` or `offset`/`limit` (defaults `page=1`, `limit=10`; max limit `200`)
|
- Query: `page`/`limit` or `offset`/`limit` (defaults `page=1`, `limit=10`; max limit `200`) plus `orderBy`/`orderType` (`ASC` | `DESC`, default `ASC`)
|
||||||
- Use `@RawResponse()` for file downloads / health probes that must skip wrapping
|
- Use `@RawResponse()` for file downloads / health probes that must skip wrapping
|
||||||
- Non-list handlers (detail, create, update, delete, status, import) pass through **unwrapped**
|
- Non-list handlers (detail, create, update, delete, status, import) pass through **unwrapped**
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ Register static write paths (`import`, `bulk-delete`, `bulk-status`) **before**
|
|||||||
List requirements:
|
List requirements:
|
||||||
|
|
||||||
- Query filters for the resource’s own attributes **plus** `search` (case-insensitive match on the module’s searchable text columns; AND with other filters)
|
- Query filters for the resource’s own attributes **plus** `search` (case-insensitive match on the module’s searchable text columns; AND with other filters)
|
||||||
- Shared pagination query (`page`/`limit` or `offset`/`limit`) via `PaginationQueryDto`
|
- Shared pagination query (`page`/`limit` or `offset`/`limit`) plus `orderBy`/`orderType` via `PaginationQueryDto`
|
||||||
- Handler **must** use `@Pagination()` and return `{ data, total }` — never build `meta` here (see `.cursor/rules/pagination-response.mdc`)
|
- Handler **must** use `@Pagination()` and return `{ data, total }` — never build `meta` here (see `.cursor/rules/pagination-response.mdc`)
|
||||||
- Service `visibleFields` whitelist: default **all non-secret** attributes; modules may narrow. Project in the **service**, not the controller
|
- Service `visibleFields` whitelist: default **all non-secret** attributes; modules may narrow. Project in the **service**, not the controller
|
||||||
- FK relations in list/detail (and write responses that reuse the mapper) MUST be nested objects via `pickRelation` — see `.cursor/rules/relation-response.mdc`
|
- FK relations in list/detail (and write responses that reuse the mapper) MUST be nested objects via `pickRelation` — see `.cursor/rules/relation-response.mdc`
|
||||||
|
|||||||
@@ -23,12 +23,27 @@ export {
|
|||||||
PAGINATION_MAX_LIMIT,
|
PAGINATION_MAX_LIMIT,
|
||||||
} from './pagination.constants';
|
} from './pagination.constants';
|
||||||
export {
|
export {
|
||||||
|
CODE_RELATION_FIELDS,
|
||||||
DEFAULT_RELATION_FIELDS,
|
DEFAULT_RELATION_FIELDS,
|
||||||
|
fallbackUserRelation,
|
||||||
|
pickCodeRelation,
|
||||||
pickDefaultRelation,
|
pickDefaultRelation,
|
||||||
pickRelation,
|
pickRelation,
|
||||||
pickUserRelation,
|
pickUserRelation,
|
||||||
USER_RELATION_FIELDS,
|
USER_RELATION_FIELDS,
|
||||||
|
type CodeRelation,
|
||||||
type DefaultRelation,
|
type DefaultRelation,
|
||||||
type UserRelation,
|
type UserRelation,
|
||||||
} from './relation-fields';
|
} 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsInt, IsOptional, Max, Min } from 'class-validator';
|
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';
|
import { PAGINATION_MAX_LIMIT } from './pagination.constants';
|
||||||
|
|
||||||
export class PaginationQueryDto {
|
export class PaginationQueryDto {
|
||||||
@@ -21,4 +23,19 @@ export class PaginationQueryDto {
|
|||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(0)
|
@Min(0)
|
||||||
offset?: number;
|
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 {
|
import {
|
||||||
|
CODE_RELATION_FIELDS,
|
||||||
DEFAULT_RELATION_FIELDS,
|
DEFAULT_RELATION_FIELDS,
|
||||||
|
pickCodeRelation,
|
||||||
pickDefaultRelation,
|
pickDefaultRelation,
|
||||||
pickRelation,
|
pickRelation,
|
||||||
pickUserRelation,
|
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', () => {
|
describe('pickDefaultRelation / pickUserRelation', () => {
|
||||||
it('maps catalog and user sources without leaking extra fields', () => {
|
it('maps catalog and user sources without leaking extra fields', () => {
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export const DEFAULT_RELATION_FIELDS = ['id', 'code', 'name'] as const;
|
export const DEFAULT_RELATION_FIELDS = ['id', 'code', 'name'] as const;
|
||||||
export const USER_RELATION_FIELDS = ['id', 'username'] as const;
|
export const USER_RELATION_FIELDS = ['id', 'username'] as const;
|
||||||
|
export const CODE_RELATION_FIELDS = ['id', 'code'] as const;
|
||||||
|
|
||||||
export type DefaultRelation = {
|
export type DefaultRelation = {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
@@ -12,6 +13,11 @@ export type UserRelation = {
|
|||||||
readonly username: string;
|
readonly username: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CodeRelation = {
|
||||||
|
readonly id: string;
|
||||||
|
readonly code: string;
|
||||||
|
};
|
||||||
|
|
||||||
export function pickRelation<T, K extends keyof NonNullable<T>>(
|
export function pickRelation<T, K extends keyof NonNullable<T>>(
|
||||||
source: T | null | undefined,
|
source: T | null | undefined,
|
||||||
fields: readonly K[],
|
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: '' });
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,3 +18,11 @@ export class UserRelationDto {
|
|||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
username!: string;
|
username!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class CodeRelationDto {
|
||||||
|
@ApiProperty({ format: 'uuid' })
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
code!: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ export const employees = pgTable(
|
|||||||
name: varchar('name', { length: 64 }).notNull(),
|
name: varchar('name', { length: 64 }).notNull(),
|
||||||
phone: text('phone').notNull(),
|
phone: text('phone').notNull(),
|
||||||
position: text('position').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),
|
...primaryEntityColumns(users),
|
||||||
},
|
},
|
||||||
(t) => [
|
(t) => [
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 workingHoursStart?: string;
|
||||||
readonly workingHoursEnd?: string;
|
readonly workingHoursEnd?: string;
|
||||||
readonly search?: string;
|
readonly search?: string;
|
||||||
|
readonly orderBy?: string;
|
||||||
|
readonly orderType?: string;
|
||||||
readonly limit: number;
|
readonly limit: number;
|
||||||
readonly offset: number;
|
readonly offset: number;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} 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 { alias } from 'drizzle-orm/pg-core';
|
||||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||||
import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
|
import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
|
||||||
@@ -24,6 +25,18 @@ import type {
|
|||||||
UpdateBranchInput,
|
UpdateBranchInput,
|
||||||
} from './branch';
|
} 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 createdByUsers = alias(users, 'created_by_users');
|
||||||
const updatedByUsers = alias(users, 'updated_by_users');
|
const updatedByUsers = alias(users, 'updated_by_users');
|
||||||
|
|
||||||
@@ -52,7 +65,11 @@ export class BranchesRepository {
|
|||||||
qb = this.extendListQuery(qb, filters);
|
qb = this.extendListQuery(qb, filters);
|
||||||
const rows = await qb
|
const rows = await qb
|
||||||
.where(where)
|
.where(where)
|
||||||
.orderBy(asc(branches.code))
|
.orderBy(
|
||||||
|
...toOrderClauses(BRANCH_ORDER_COLUMNS, filters, [
|
||||||
|
{ column: 'code', type: 'ASC' },
|
||||||
|
]),
|
||||||
|
)
|
||||||
.limit(filters.limit)
|
.limit(filters.limit)
|
||||||
.offset(filters.offset);
|
.offset(filters.offset);
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import type { PaginationResponse } from '../../../common/http/response';
|
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 { InvalidPhoneNumberError } from '../../../common/value-objects/phone-number/invalid-phone-number.error';
|
||||||
import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
|
import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
|
||||||
import { Status } from '../../../common/value-objects/status/status';
|
import { Status } from '../../../common/value-objects/status/status';
|
||||||
@@ -36,6 +41,8 @@ export type ListBranchesQuery = {
|
|||||||
readonly workingHoursStart?: string;
|
readonly workingHoursStart?: string;
|
||||||
readonly workingHoursEnd?: string;
|
readonly workingHoursEnd?: string;
|
||||||
readonly search?: string;
|
readonly search?: string;
|
||||||
|
readonly orderBy?: string;
|
||||||
|
readonly orderType?: string;
|
||||||
readonly page?: number;
|
readonly page?: number;
|
||||||
readonly limit?: number;
|
readonly limit?: number;
|
||||||
readonly offset?: number;
|
readonly offset?: number;
|
||||||
@@ -94,6 +101,8 @@ export class BranchesService {
|
|||||||
workingHoursStart: query.workingHoursStart,
|
workingHoursStart: query.workingHoursStart,
|
||||||
workingHoursEnd: query.workingHoursEnd,
|
workingHoursEnd: query.workingHoursEnd,
|
||||||
search: query.search,
|
search: query.search,
|
||||||
|
orderBy: query.orderBy,
|
||||||
|
orderType: query.orderType,
|
||||||
limit: page.limit,
|
limit: page.limit,
|
||||||
offset: page.offset,
|
offset: page.offset,
|
||||||
});
|
});
|
||||||
@@ -341,31 +350,15 @@ export class BranchesService {
|
|||||||
workingHoursStart: branch.workingHoursStart,
|
workingHoursStart: branch.workingHoursStart,
|
||||||
workingHoursEnd: branch.workingHoursEnd,
|
workingHoursEnd: branch.workingHoursEnd,
|
||||||
nfcId: branch.nfcId,
|
nfcId: branch.nfcId,
|
||||||
division: this.toDivisionItem(branch.division),
|
division: pickRelation(branch.division, DEFAULT_RELATION_FIELDS),
|
||||||
status: branch.status.value,
|
status: branch.status.value,
|
||||||
createdAt: branch.createdAt.value,
|
createdAt: branch.createdAt.value,
|
||||||
updatedAt: branch.updatedAt.value,
|
updatedAt: branch.updatedAt.value,
|
||||||
createdBy: this.toUserItem(branch.createdByUser),
|
createdBy: pickUserRelation(branch.createdByUser),
|
||||||
updatedBy: this.toUserItem(branch.updatedByUser),
|
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[] {
|
get visibleFields(): readonly string[] {
|
||||||
return VISIBLE_FIELDS;
|
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 { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||||
import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
|
import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
|
||||||
import { Status } from '../../../common/value-objects/status/status';
|
import { Status } from '../../../common/value-objects/status/status';
|
||||||
@@ -27,6 +28,8 @@ export type Customer = {
|
|||||||
readonly updatedAt: DateTime;
|
readonly updatedAt: DateTime;
|
||||||
readonly createdBy: string;
|
readonly createdBy: string;
|
||||||
readonly updatedBy: string;
|
readonly updatedBy: string;
|
||||||
|
readonly createdByUser: UserRelation;
|
||||||
|
readonly updatedByUser: UserRelation;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CustomerContactInput = {
|
export type CustomerContactInput = {
|
||||||
@@ -79,6 +82,8 @@ export type ListCustomersFilters = {
|
|||||||
readonly nfcId?: string;
|
readonly nfcId?: string;
|
||||||
readonly status?: string;
|
readonly status?: string;
|
||||||
readonly search?: string;
|
readonly search?: string;
|
||||||
|
readonly orderBy?: string;
|
||||||
|
readonly orderType?: string;
|
||||||
readonly limit: number;
|
readonly limit: number;
|
||||||
readonly offset: number;
|
readonly offset: number;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -67,7 +67,13 @@ describe('CustomersRepository', () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
where.mockImplementation(() => ({ limit, orderBy, returning }));
|
where.mockImplementation(() =>
|
||||||
|
Object.assign(Promise.resolve([{ id: 'user-1', username: 'admin' }]), {
|
||||||
|
limit,
|
||||||
|
orderBy,
|
||||||
|
returning,
|
||||||
|
}),
|
||||||
|
);
|
||||||
orderBy.mockImplementation(() => ({ limit }));
|
orderBy.mockImplementation(() => ({ limit }));
|
||||||
limit.mockImplementation(() => ({ offset }));
|
limit.mockImplementation(() => ({ offset }));
|
||||||
offset.mockResolvedValue([row]);
|
offset.mockResolvedValue([row]);
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { and, asc, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm';
|
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 { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||||
import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
|
import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
|
||||||
import { Status } from '../../../common/value-objects/status/status';
|
import { Status } from '../../../common/value-objects/status/status';
|
||||||
@@ -26,6 +28,18 @@ import type {
|
|||||||
UpdateCustomerInput,
|
UpdateCustomerInput,
|
||||||
} from './customer';
|
} 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'>;
|
type QueryExecutor = Pick<DrizzleDB, 'select' | 'insert' | 'delete'>;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -46,12 +60,16 @@ export class CustomersRepository {
|
|||||||
qb = this.extendListQuery(qb, filters);
|
qb = this.extendListQuery(qb, filters);
|
||||||
const rows = await qb
|
const rows = await qb
|
||||||
.where(where)
|
.where(where)
|
||||||
.orderBy(asc(customers.code))
|
.orderBy(
|
||||||
|
...toOrderClauses(CUSTOMER_ORDER_COLUMNS, filters, [
|
||||||
|
{ column: 'code', type: 'ASC' },
|
||||||
|
]),
|
||||||
|
)
|
||||||
.limit(filters.limit)
|
.limit(filters.limit)
|
||||||
.offset(filters.offset);
|
.offset(filters.offset);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: rows.map((row) => this.toDomain(row, [])),
|
data: await Promise.all(rows.map((row) => this.hydrate(row, []))),
|
||||||
total: Number(totalRow?.total ?? 0),
|
total: Number(totalRow?.total ?? 0),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -75,7 +93,7 @@ export class CustomersRepository {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const contacts = await this.selectContacts(this.db, id);
|
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> {
|
async findByCode(code: string): Promise<Customer | null> {
|
||||||
@@ -89,7 +107,7 @@ export class CustomersRepository {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const contacts = await this.selectContacts(this.db, row.id);
|
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> {
|
async create(input: CreateCustomerInput): Promise<Customer> {
|
||||||
@@ -104,7 +122,7 @@ export class CustomersRepository {
|
|||||||
const row = inserted[0];
|
const row = inserted[0];
|
||||||
await this.replaceContacts(tx, row.id, input.contacts ?? []);
|
await this.replaceContacts(tx, row.id, input.contacts ?? []);
|
||||||
const contacts = await this.selectContacts(tx, row.id);
|
const contacts = await this.selectContacts(tx, row.id);
|
||||||
return this.toDomain(row, contacts);
|
return this.hydrate(row, contacts);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.rethrowConstraintViolation(error);
|
this.rethrowConstraintViolation(error);
|
||||||
@@ -170,7 +188,7 @@ export class CustomersRepository {
|
|||||||
await this.replaceContacts(tx, id, input.contacts);
|
await this.replaceContacts(tx, id, input.contacts);
|
||||||
}
|
}
|
||||||
const contacts = await this.selectContacts(tx, id);
|
const contacts = await this.selectContacts(tx, id);
|
||||||
return this.toDomain(row, contacts);
|
return this.hydrate(row, contacts);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.rethrowConstraintViolation(error);
|
this.rethrowConstraintViolation(error);
|
||||||
@@ -197,7 +215,7 @@ export class CustomersRepository {
|
|||||||
throw new NotFoundException('Customer not found');
|
throw new NotFoundException('Customer not found');
|
||||||
}
|
}
|
||||||
const contacts = await this.selectContacts(this.db, id);
|
const contacts = await this.selectContacts(this.db, id);
|
||||||
return this.toDomain(row, contacts);
|
return this.hydrate(row, contacts);
|
||||||
}
|
}
|
||||||
|
|
||||||
async bulkUpdateStatus(
|
async bulkUpdateStatus(
|
||||||
@@ -437,10 +455,17 @@ export class CustomersRepository {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private toDomain(
|
private async hydrate(
|
||||||
row: CustomerRow,
|
row: CustomerRow,
|
||||||
contactRows: CustomerContactRow[],
|
contactRows: CustomerContactRow[],
|
||||||
): Customer {
|
): Promise<Customer> {
|
||||||
|
const [item] = await attachAuditUsers(this.db, [
|
||||||
|
this.toDomain(row, contactRows),
|
||||||
|
]);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDomain(row: CustomerRow, contactRows: CustomerContactRow[]) {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
code: row.code,
|
code: row.code,
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ describe('CustomersService', () => {
|
|||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
createdBy: 'user-1',
|
createdBy: 'user-1',
|
||||||
|
createdByUser: { id: 'user-1', username: 'admin' },
|
||||||
|
updatedByUser: { id: 'user-1', username: 'admin' },
|
||||||
updatedBy: 'user-1',
|
updatedBy: 'user-1',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import type { PaginationResponse } from '../../../common/http/response';
|
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 { InvalidPhoneNumberError } from '../../../common/value-objects/phone-number/invalid-phone-number.error';
|
||||||
import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
|
import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
|
||||||
import { Status } from '../../../common/value-objects/status/status';
|
import { Status } from '../../../common/value-objects/status/status';
|
||||||
@@ -38,6 +38,8 @@ export type ListCustomersQuery = {
|
|||||||
readonly nfcId?: string;
|
readonly nfcId?: string;
|
||||||
readonly status?: string;
|
readonly status?: string;
|
||||||
readonly search?: string;
|
readonly search?: string;
|
||||||
|
readonly orderBy?: string;
|
||||||
|
readonly orderType?: string;
|
||||||
readonly page?: number;
|
readonly page?: number;
|
||||||
readonly limit?: number;
|
readonly limit?: number;
|
||||||
readonly offset?: number;
|
readonly offset?: number;
|
||||||
@@ -85,6 +87,8 @@ export class CustomersService {
|
|||||||
nfcId: query.nfcId,
|
nfcId: query.nfcId,
|
||||||
status: query.status,
|
status: query.status,
|
||||||
search: query.search,
|
search: query.search,
|
||||||
|
orderBy: query.orderBy,
|
||||||
|
orderType: query.orderType,
|
||||||
limit: page.limit,
|
limit: page.limit,
|
||||||
offset: page.offset,
|
offset: page.offset,
|
||||||
});
|
});
|
||||||
@@ -350,8 +354,8 @@ export class CustomersService {
|
|||||||
status: customer.status.value,
|
status: customer.status.value,
|
||||||
createdAt: customer.createdAt.value,
|
createdAt: customer.createdAt.value,
|
||||||
updatedAt: customer.updatedAt.value,
|
updatedAt: customer.updatedAt.value,
|
||||||
createdBy: customer.createdBy,
|
createdBy: pickUserRelation(customer.createdByUser),
|
||||||
updatedBy: customer.updatedBy,
|
updatedBy: pickUserRelation(customer.updatedByUser),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ import {
|
|||||||
Min,
|
Min,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
} from 'class-validator';
|
} 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 { CORE_STATUSES } from '../../../../common/value-objects/status/status';
|
||||||
import {
|
import {
|
||||||
CONTACT_JOB_TITLE_MAX_LENGTH,
|
CONTACT_JOB_TITLE_MAX_LENGTH,
|
||||||
@@ -347,9 +350,9 @@ export class CustomerDto {
|
|||||||
@ApiProperty({ description: 'Unix ms' })
|
@ApiProperty({ description: 'Unix ms' })
|
||||||
updatedAt!: number;
|
updatedAt!: number;
|
||||||
|
|
||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ type: UserRelationDto })
|
||||||
createdBy!: string;
|
createdBy!: UserRelationDto;
|
||||||
|
|
||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ type: UserRelationDto })
|
||||||
updatedBy!: string;
|
updatedBy!: UserRelationDto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { UserRelation } from '../../../common/http/response';
|
||||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||||
import { Status } from '../../../common/value-objects/status/status';
|
import { Status } from '../../../common/value-objects/status/status';
|
||||||
|
|
||||||
@@ -10,6 +11,8 @@ export type Division = {
|
|||||||
readonly updatedAt: DateTime;
|
readonly updatedAt: DateTime;
|
||||||
readonly createdBy: string;
|
readonly createdBy: string;
|
||||||
readonly updatedBy: string;
|
readonly updatedBy: string;
|
||||||
|
readonly createdByUser: UserRelation;
|
||||||
|
readonly updatedByUser: UserRelation;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CreateDivisionInput = {
|
export type CreateDivisionInput = {
|
||||||
@@ -30,6 +33,8 @@ export type ListDivisionsFilters = {
|
|||||||
readonly code?: string;
|
readonly code?: string;
|
||||||
readonly status?: string;
|
readonly status?: string;
|
||||||
readonly search?: string;
|
readonly search?: string;
|
||||||
|
readonly orderBy?: string;
|
||||||
|
readonly orderType?: string;
|
||||||
readonly limit: number;
|
readonly limit: number;
|
||||||
readonly offset: number;
|
readonly offset: number;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -43,7 +43,13 @@ describe('DivisionsRepository', () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
where.mockImplementation(() => ({ limit, orderBy }));
|
where.mockImplementation(() =>
|
||||||
|
Object.assign(Promise.resolve([{ id: 'user-1', username: 'admin' }]), {
|
||||||
|
limit,
|
||||||
|
orderBy,
|
||||||
|
returning,
|
||||||
|
}),
|
||||||
|
);
|
||||||
orderBy.mockImplementation(() => ({ limit }));
|
orderBy.mockImplementation(() => ({ limit }));
|
||||||
limit.mockImplementation(() => ({ offset }));
|
limit.mockImplementation(() => ({ offset }));
|
||||||
offset.mockResolvedValue([row]);
|
offset.mockResolvedValue([row]);
|
||||||
@@ -59,7 +65,6 @@ describe('DivisionsRepository', () => {
|
|||||||
update.mockReturnValue({ set });
|
update.mockReturnValue({ set });
|
||||||
del.mockReturnValue({ where });
|
del.mockReturnValue({ where });
|
||||||
returning.mockResolvedValue([row]);
|
returning.mockResolvedValue([row]);
|
||||||
where.mockImplementation(() => ({ limit, orderBy, returning }));
|
|
||||||
|
|
||||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||||
providers: [DivisionsRepository, { provide: DRIZZLE, useValue: db }],
|
providers: [DivisionsRepository, { provide: DRIZZLE, useValue: db }],
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} 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 { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||||
import { Status } from '../../../common/value-objects/status/status';
|
import { Status } from '../../../common/value-objects/status/status';
|
||||||
import { DRIZZLE, type DrizzleDB } from '../../../database/database.module';
|
import { DRIZZLE, type DrizzleDB } from '../../../database/database.module';
|
||||||
|
import { attachAuditUsers } from '../../../database/load-user-refs';
|
||||||
import { divisions, type DivisionRow } from '../../../database/schema';
|
import { divisions, type DivisionRow } from '../../../database/schema';
|
||||||
import type {
|
import type {
|
||||||
CreateDivisionInput,
|
CreateDivisionInput,
|
||||||
@@ -16,6 +18,15 @@ import type {
|
|||||||
UpdateDivisionInput,
|
UpdateDivisionInput,
|
||||||
} from './division';
|
} 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()
|
@Injectable()
|
||||||
export class DivisionsRepository {
|
export class DivisionsRepository {
|
||||||
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
|
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
|
||||||
@@ -34,12 +45,16 @@ export class DivisionsRepository {
|
|||||||
qb = this.extendListQuery(qb, filters);
|
qb = this.extendListQuery(qb, filters);
|
||||||
const rows = await qb
|
const rows = await qb
|
||||||
.where(where)
|
.where(where)
|
||||||
.orderBy(asc(divisions.code))
|
.orderBy(
|
||||||
|
...toOrderClauses(DIVISION_ORDER_COLUMNS, filters, [
|
||||||
|
{ column: 'code', type: 'ASC' },
|
||||||
|
]),
|
||||||
|
)
|
||||||
.limit(filters.limit)
|
.limit(filters.limit)
|
||||||
.offset(filters.offset);
|
.offset(filters.offset);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: rows.map((row) => this.toDomain(row)),
|
data: await this.hydrate(rows),
|
||||||
total: Number(totalRow?.total ?? 0),
|
total: Number(totalRow?.total ?? 0),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -59,7 +74,7 @@ export class DivisionsRepository {
|
|||||||
.where(eq(divisions.id, id))
|
.where(eq(divisions.id, id))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
const row = rows[0];
|
const row = rows[0];
|
||||||
return row ? this.toDomain(row) : null;
|
return row ? this.hydrateOne(row) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByCode(code: string): Promise<Division | null> {
|
async findByCode(code: string): Promise<Division | null> {
|
||||||
@@ -69,7 +84,7 @@ export class DivisionsRepository {
|
|||||||
.where(eq(divisions.code, code))
|
.where(eq(divisions.code, code))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
const row = rows[0];
|
const row = rows[0];
|
||||||
return row ? this.toDomain(row) : null;
|
return row ? this.hydrateOne(row) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(input: CreateDivisionInput): Promise<Division> {
|
async create(input: CreateDivisionInput): Promise<Division> {
|
||||||
@@ -89,7 +104,7 @@ export class DivisionsRepository {
|
|||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
const row = inserted[0];
|
const row = inserted[0];
|
||||||
return this.toDomain(row);
|
return this.hydrateOne(row);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.rethrowUniqueViolation(error);
|
this.rethrowUniqueViolation(error);
|
||||||
}
|
}
|
||||||
@@ -139,7 +154,7 @@ export class DivisionsRepository {
|
|||||||
.where(eq(divisions.id, id))
|
.where(eq(divisions.id, id))
|
||||||
.returning();
|
.returning();
|
||||||
const row = updated[0];
|
const row = updated[0];
|
||||||
return this.toDomain(row);
|
return this.hydrateOne(row);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.rethrowUniqueViolation(error);
|
this.rethrowUniqueViolation(error);
|
||||||
}
|
}
|
||||||
@@ -164,7 +179,7 @@ export class DivisionsRepository {
|
|||||||
if (!row) {
|
if (!row) {
|
||||||
throw new NotFoundException('Division not found');
|
throw new NotFoundException('Division not found');
|
||||||
}
|
}
|
||||||
return this.toDomain(row);
|
return this.hydrateOne(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
async bulkUpdateStatus(
|
async bulkUpdateStatus(
|
||||||
@@ -243,8 +258,10 @@ export class DivisionsRepository {
|
|||||||
return parts.length === 1 ? parts[0] : and(...parts);
|
return parts.length === 1 ? parts[0] : and(...parts);
|
||||||
}
|
}
|
||||||
|
|
||||||
private toDomain(row: DivisionRow): Division {
|
private async hydrate(rows: DivisionRow[]): Promise<Division[]> {
|
||||||
return {
|
return attachAuditUsers(
|
||||||
|
this.db,
|
||||||
|
rows.map((row) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
code: row.code,
|
code: row.code,
|
||||||
@@ -253,7 +270,13 @@ export class DivisionsRepository {
|
|||||||
updatedAt: DateTime.fromUnixMs(row.updatedAt),
|
updatedAt: DateTime.fromUnixMs(row.updatedAt),
|
||||||
createdBy: row.createdBy,
|
createdBy: row.createdBy,
|
||||||
updatedBy: row.updatedBy,
|
updatedBy: row.updatedBy,
|
||||||
};
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async hydrateOne(row: DivisionRow): Promise<Division> {
|
||||||
|
const [item] = await this.hydrate([row]);
|
||||||
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
private rethrowUniqueViolation(error: unknown): never {
|
private rethrowUniqueViolation(error: unknown): never {
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ describe('DivisionsService', () => {
|
|||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
createdBy: 'user-1',
|
createdBy: 'user-1',
|
||||||
updatedBy: 'user-1',
|
updatedBy: 'user-1',
|
||||||
|
createdByUser: { id: 'user-1', username: 'admin' },
|
||||||
|
updatedByUser: { id: 'user-1', username: 'admin' },
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -68,6 +70,7 @@ describe('DivisionsService', () => {
|
|||||||
code: 'FIN',
|
code: 'FIN',
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
createdAt: now.value,
|
createdAt: now.value,
|
||||||
|
createdBy: { id: 'user-1', username: 'admin' },
|
||||||
});
|
});
|
||||||
expect(service.visibleFields).toContain('status');
|
expect(service.visibleFields).toContain('status');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import type { PaginationResponse } from '../../../common/http/response';
|
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 { Status } from '../../../common/value-objects/status/status';
|
||||||
import type {
|
import type {
|
||||||
CreateDivisionInput,
|
CreateDivisionInput,
|
||||||
@@ -19,6 +19,8 @@ export type ListDivisionsQuery = {
|
|||||||
readonly code?: string;
|
readonly code?: string;
|
||||||
readonly status?: string;
|
readonly status?: string;
|
||||||
readonly search?: string;
|
readonly search?: string;
|
||||||
|
readonly orderBy?: string;
|
||||||
|
readonly orderType?: string;
|
||||||
readonly page?: number;
|
readonly page?: number;
|
||||||
readonly limit?: number;
|
readonly limit?: number;
|
||||||
readonly offset?: number;
|
readonly offset?: number;
|
||||||
@@ -48,6 +50,8 @@ export class DivisionsService {
|
|||||||
code: query.code,
|
code: query.code,
|
||||||
status: query.status,
|
status: query.status,
|
||||||
search: query.search,
|
search: query.search,
|
||||||
|
orderBy: query.orderBy,
|
||||||
|
orderType: query.orderType,
|
||||||
limit: page.limit,
|
limit: page.limit,
|
||||||
offset: page.offset,
|
offset: page.offset,
|
||||||
});
|
});
|
||||||
@@ -221,8 +225,8 @@ export class DivisionsService {
|
|||||||
status: division.status.value,
|
status: division.status.value,
|
||||||
createdAt: division.createdAt.value,
|
createdAt: division.createdAt.value,
|
||||||
updatedAt: division.updatedAt.value,
|
updatedAt: division.updatedAt.value,
|
||||||
createdBy: division.createdBy,
|
createdBy: pickUserRelation(division.createdByUser),
|
||||||
updatedBy: division.updatedBy,
|
updatedBy: pickUserRelation(division.updatedByUser),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ import {
|
|||||||
Matches,
|
Matches,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
} from 'class-validator';
|
} 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 { CORE_STATUSES } from '../../../../common/value-objects/status/status';
|
||||||
import {
|
import {
|
||||||
DIVISION_CODE_MAX_LENGTH,
|
DIVISION_CODE_MAX_LENGTH,
|
||||||
@@ -19,6 +22,15 @@ import {
|
|||||||
DIVISION_NAME_PATTERN,
|
DIVISION_NAME_PATTERN,
|
||||||
} from '../division-fields';
|
} from '../division-fields';
|
||||||
|
|
||||||
|
export const DIVISION_ORDER_FIELDS = [
|
||||||
|
'id',
|
||||||
|
'name',
|
||||||
|
'code',
|
||||||
|
'status',
|
||||||
|
'createdAt',
|
||||||
|
'updatedAt',
|
||||||
|
] as const;
|
||||||
|
|
||||||
export class CreateDivisionDto {
|
export class CreateDivisionDto {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: 'Human Resources',
|
example: 'Human Resources',
|
||||||
@@ -138,9 +150,9 @@ export class DivisionDto {
|
|||||||
@ApiProperty({ description: 'Unix ms' })
|
@ApiProperty({ description: 'Unix ms' })
|
||||||
updatedAt!: number;
|
updatedAt!: number;
|
||||||
|
|
||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ type: UserRelationDto })
|
||||||
createdBy!: string;
|
createdBy!: UserRelationDto;
|
||||||
|
|
||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ type: UserRelationDto })
|
||||||
updatedBy!: string;
|
updatedBy!: UserRelationDto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -247,11 +247,11 @@ export class EmployeeDto {
|
|||||||
@ApiProperty({ description: 'Unix ms' })
|
@ApiProperty({ description: 'Unix ms' })
|
||||||
updatedAt!: number;
|
updatedAt!: number;
|
||||||
|
|
||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ type: UserRelationDto })
|
||||||
createdBy!: string;
|
createdBy!: UserRelationDto;
|
||||||
|
|
||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ type: UserRelationDto })
|
||||||
updatedBy!: string;
|
updatedBy!: UserRelationDto;
|
||||||
|
|
||||||
@ApiPropertyOptional({ type: UserRelationDto, nullable: true })
|
@ApiPropertyOptional({ type: UserRelationDto, nullable: true })
|
||||||
user!: UserRelationDto | null;
|
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 { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||||
import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
|
import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
|
||||||
import { Status } from '../../../common/value-objects/status/status';
|
import { Status } from '../../../common/value-objects/status/status';
|
||||||
@@ -14,6 +15,8 @@ export type Employee = {
|
|||||||
readonly updatedAt: DateTime;
|
readonly updatedAt: DateTime;
|
||||||
readonly createdBy: string;
|
readonly createdBy: string;
|
||||||
readonly updatedBy: string;
|
readonly updatedBy: string;
|
||||||
|
readonly createdByUser: UserRelation;
|
||||||
|
readonly updatedByUser: UserRelation;
|
||||||
readonly userId: string | null;
|
readonly userId: string | null;
|
||||||
readonly user: { readonly id: string; readonly username: string } | null;
|
readonly user: { readonly id: string; readonly username: string } | null;
|
||||||
};
|
};
|
||||||
@@ -51,6 +54,8 @@ export type ListEmployeesFilters = {
|
|||||||
readonly status?: string;
|
readonly status?: string;
|
||||||
readonly userId?: string;
|
readonly userId?: string;
|
||||||
readonly search?: string;
|
readonly search?: string;
|
||||||
|
readonly orderBy?: string;
|
||||||
|
readonly orderType?: string;
|
||||||
readonly limit: number;
|
readonly limit: number;
|
||||||
readonly offset: number;
|
readonly offset: number;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -57,7 +57,13 @@ describe('EmployeesRepository', () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
where.mockImplementation(() => ({ limit, orderBy, returning }));
|
where.mockImplementation(() =>
|
||||||
|
Object.assign(Promise.resolve([{ id: 'user-1', username: 'admin' }]), {
|
||||||
|
limit,
|
||||||
|
orderBy,
|
||||||
|
returning,
|
||||||
|
}),
|
||||||
|
);
|
||||||
orderBy.mockImplementation(() => ({ limit }));
|
orderBy.mockImplementation(() => ({ limit }));
|
||||||
limit.mockImplementation(() => ({ offset }));
|
limit.mockImplementation(() => ({ offset }));
|
||||||
offset.mockResolvedValue([row]);
|
offset.mockResolvedValue([row]);
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} 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 { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||||
import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
|
import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
|
||||||
import { Status } from '../../../common/value-objects/status/status';
|
import { Status } from '../../../common/value-objects/status/status';
|
||||||
@@ -19,6 +21,17 @@ import type {
|
|||||||
UpdateEmployeeInput,
|
UpdateEmployeeInput,
|
||||||
} from './employee';
|
} 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()
|
@Injectable()
|
||||||
export class EmployeesRepository {
|
export class EmployeesRepository {
|
||||||
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
|
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
|
||||||
@@ -37,16 +50,21 @@ export class EmployeesRepository {
|
|||||||
qb = this.extendListQuery(qb, filters);
|
qb = this.extendListQuery(qb, filters);
|
||||||
const rows = await qb
|
const rows = await qb
|
||||||
.where(where)
|
.where(where)
|
||||||
.orderBy(asc(employees.code))
|
.orderBy(
|
||||||
|
...toOrderClauses(EMPLOYEE_ORDER_COLUMNS, filters, [
|
||||||
|
{ column: 'code', type: 'ASC' },
|
||||||
|
]),
|
||||||
|
)
|
||||||
.limit(filters.limit)
|
.limit(filters.limit)
|
||||||
.offset(filters.offset);
|
.offset(filters.offset);
|
||||||
|
|
||||||
return {
|
const mapped = await Promise.all(
|
||||||
data: await Promise.all(
|
|
||||||
rows.map(async (row) =>
|
rows.map(async (row) =>
|
||||||
this.toDomain(row, await this.loadAssignedUser(row.userId)),
|
this.hydrateOne(row, await this.loadAssignedUser(row.userId)),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
data: mapped,
|
||||||
total: Number(totalRow?.total ?? 0),
|
total: Number(totalRow?.total ?? 0),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -67,7 +85,7 @@ export class EmployeesRepository {
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
const row = rows[0];
|
const row = rows[0];
|
||||||
return row
|
return row
|
||||||
? this.toDomain(row, await this.loadAssignedUser(row.userId))
|
? this.hydrateOne(row, await this.loadAssignedUser(row.userId))
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +97,7 @@ export class EmployeesRepository {
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
const row = rows[0];
|
const row = rows[0];
|
||||||
return row
|
return row
|
||||||
? this.toDomain(row, await this.loadAssignedUser(row.userId))
|
? this.hydrateOne(row, await this.loadAssignedUser(row.userId))
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +109,7 @@ export class EmployeesRepository {
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
const row = rows[0];
|
const row = rows[0];
|
||||||
return row
|
return row
|
||||||
? this.toDomain(row, await this.loadAssignedUser(row.userId))
|
? this.hydrateOne(row, await this.loadAssignedUser(row.userId))
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +122,7 @@ export class EmployeesRepository {
|
|||||||
.values(this.toInsertValues(input, status, now, input.userId))
|
.values(this.toInsertValues(input, status, now, input.userId))
|
||||||
.returning();
|
.returning();
|
||||||
const row = inserted[0];
|
const row = inserted[0];
|
||||||
return this.toDomain(row, await this.loadAssignedUser(row.userId));
|
return this.hydrateOne(row, await this.loadAssignedUser(row.userId));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.rethrowUniqueViolation(error);
|
this.rethrowUniqueViolation(error);
|
||||||
}
|
}
|
||||||
@@ -153,7 +171,7 @@ export class EmployeesRepository {
|
|||||||
.where(eq(employees.id, id))
|
.where(eq(employees.id, id))
|
||||||
.returning();
|
.returning();
|
||||||
const row = updated[0];
|
const row = updated[0];
|
||||||
return this.toDomain(row, await this.loadAssignedUser(row.userId));
|
return this.hydrateOne(row, await this.loadAssignedUser(row.userId));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.rethrowUniqueViolation(error);
|
this.rethrowUniqueViolation(error);
|
||||||
}
|
}
|
||||||
@@ -178,7 +196,7 @@ export class EmployeesRepository {
|
|||||||
if (!row) {
|
if (!row) {
|
||||||
throw new NotFoundException('Employee not found');
|
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(
|
async bulkUpdateStatus(
|
||||||
@@ -308,7 +326,7 @@ export class EmployeesRepository {
|
|||||||
private toDomain(
|
private toDomain(
|
||||||
row: EmployeeRow,
|
row: EmployeeRow,
|
||||||
user: { id: string; username: string } | null,
|
user: { id: string; username: string } | null,
|
||||||
): Employee {
|
) {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
code: row.code,
|
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 {
|
private rethrowUniqueViolation(error: unknown): never {
|
||||||
const err = this.unwrapDbError(error);
|
const err = this.unwrapDbError(error);
|
||||||
if (err.code === '23505') {
|
if (err.code === '23505') {
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ describe('EmployeesService', () => {
|
|||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
createdBy: 'user-1',
|
createdBy: 'user-1',
|
||||||
updatedBy: 'user-1',
|
updatedBy: 'user-1',
|
||||||
|
createdByUser: { id: 'user-1', username: 'admin' },
|
||||||
|
updatedByUser: { id: 'user-1', username: 'admin' },
|
||||||
userId: null,
|
userId: null,
|
||||||
user: null,
|
user: null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,12 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import type { PaginationResponse } from '../../../common/http/response';
|
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 { InvalidPhoneNumberError } from '../../../common/value-objects/phone-number/invalid-phone-number.error';
|
||||||
import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
|
import { PhoneNumber } from '../../../common/value-objects/phone-number/phone-number';
|
||||||
import { Status } from '../../../common/value-objects/status/status';
|
import { Status } from '../../../common/value-objects/status/status';
|
||||||
@@ -33,6 +38,8 @@ export type ListEmployeesQuery = {
|
|||||||
readonly status?: string;
|
readonly status?: string;
|
||||||
readonly userId?: string;
|
readonly userId?: string;
|
||||||
readonly search?: string;
|
readonly search?: string;
|
||||||
|
readonly orderBy?: string;
|
||||||
|
readonly orderType?: string;
|
||||||
readonly page?: number;
|
readonly page?: number;
|
||||||
readonly limit?: number;
|
readonly limit?: number;
|
||||||
readonly offset?: number;
|
readonly offset?: number;
|
||||||
@@ -73,6 +80,8 @@ export class EmployeesService {
|
|||||||
status: query.status,
|
status: query.status,
|
||||||
userId: query.userId,
|
userId: query.userId,
|
||||||
search: query.search,
|
search: query.search,
|
||||||
|
orderBy: query.orderBy,
|
||||||
|
orderType: query.orderType,
|
||||||
limit: page.limit,
|
limit: page.limit,
|
||||||
offset: page.offset,
|
offset: page.offset,
|
||||||
});
|
});
|
||||||
@@ -284,9 +293,9 @@ export class EmployeesService {
|
|||||||
status: employee.status.value,
|
status: employee.status.value,
|
||||||
createdAt: employee.createdAt.value,
|
createdAt: employee.createdAt.value,
|
||||||
updatedAt: employee.updatedAt.value,
|
updatedAt: employee.updatedAt.value,
|
||||||
createdBy: employee.createdBy,
|
createdBy: pickUserRelation(employee.createdByUser),
|
||||||
updatedBy: employee.updatedBy,
|
updatedBy: pickUserRelation(employee.updatedByUser),
|
||||||
user: employee.user,
|
user: pickRelation(employee.user, USER_RELATION_FIELDS),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ import {
|
|||||||
Matches,
|
Matches,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
} from 'class-validator';
|
} 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 { CORE_STATUSES } from '../../../../common/value-objects/status/status';
|
||||||
import {
|
import {
|
||||||
PRODUCT_BRAND_MAX_LENGTH,
|
PRODUCT_BRAND_MAX_LENGTH,
|
||||||
@@ -200,9 +203,9 @@ export class ProductDto {
|
|||||||
@ApiProperty({ description: 'Unix ms' })
|
@ApiProperty({ description: 'Unix ms' })
|
||||||
updatedAt!: number;
|
updatedAt!: number;
|
||||||
|
|
||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ type: UserRelationDto })
|
||||||
createdBy!: string;
|
createdBy!: UserRelationDto;
|
||||||
|
|
||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ type: UserRelationDto })
|
||||||
updatedBy!: string;
|
updatedBy!: UserRelationDto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { UserRelation } from '../../../common/http/response';
|
||||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||||
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||||
import { Status } from '../../../common/value-objects/status/status';
|
import { Status } from '../../../common/value-objects/status/status';
|
||||||
@@ -14,6 +15,8 @@ export type Product = {
|
|||||||
readonly updatedAt: DateTime;
|
readonly updatedAt: DateTime;
|
||||||
readonly createdBy: string;
|
readonly createdBy: string;
|
||||||
readonly updatedBy: string;
|
readonly updatedBy: string;
|
||||||
|
readonly createdByUser: UserRelation;
|
||||||
|
readonly updatedByUser: UserRelation;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CreateProductInput = {
|
export type CreateProductInput = {
|
||||||
@@ -42,6 +45,8 @@ export type ListProductsFilters = {
|
|||||||
readonly brand?: string;
|
readonly brand?: string;
|
||||||
readonly status?: string;
|
readonly status?: string;
|
||||||
readonly search?: string;
|
readonly search?: string;
|
||||||
|
readonly orderBy?: string;
|
||||||
|
readonly orderType?: string;
|
||||||
readonly limit: number;
|
readonly limit: number;
|
||||||
readonly offset: number;
|
readonly offset: number;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -56,7 +56,13 @@ describe('ProductsRepository', () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
where.mockImplementation(() => ({ limit, orderBy, returning }));
|
where.mockImplementation(() =>
|
||||||
|
Object.assign(Promise.resolve([{ id: 'user-1', username: 'admin' }]), {
|
||||||
|
limit,
|
||||||
|
orderBy,
|
||||||
|
returning,
|
||||||
|
}),
|
||||||
|
);
|
||||||
orderBy.mockImplementation(() => ({ limit }));
|
orderBy.mockImplementation(() => ({ limit }));
|
||||||
limit.mockImplementation(() => ({ offset }));
|
limit.mockImplementation(() => ({ offset }));
|
||||||
offset.mockResolvedValue([row]);
|
offset.mockResolvedValue([row]);
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} 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 { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||||
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||||
import { Status } from '../../../common/value-objects/status/status';
|
import { Status } from '../../../common/value-objects/status/status';
|
||||||
@@ -17,6 +19,17 @@ import type {
|
|||||||
UpdateProductInput,
|
UpdateProductInput,
|
||||||
} from './product';
|
} 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()
|
@Injectable()
|
||||||
export class ProductsRepository {
|
export class ProductsRepository {
|
||||||
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
|
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
|
||||||
@@ -35,12 +48,16 @@ export class ProductsRepository {
|
|||||||
qb = this.extendListQuery(qb, filters);
|
qb = this.extendListQuery(qb, filters);
|
||||||
const rows = await qb
|
const rows = await qb
|
||||||
.where(where)
|
.where(where)
|
||||||
.orderBy(asc(products.code))
|
.orderBy(
|
||||||
|
...toOrderClauses(PRODUCT_ORDER_COLUMNS, filters, [
|
||||||
|
{ column: 'code', type: 'ASC' },
|
||||||
|
]),
|
||||||
|
)
|
||||||
.limit(filters.limit)
|
.limit(filters.limit)
|
||||||
.offset(filters.offset);
|
.offset(filters.offset);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: rows.map((row) => this.toDomain(row)),
|
data: await this.hydrate(rows),
|
||||||
total: Number(totalRow?.total ?? 0),
|
total: Number(totalRow?.total ?? 0),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -60,7 +77,7 @@ export class ProductsRepository {
|
|||||||
.where(eq(products.id, id))
|
.where(eq(products.id, id))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
const row = rows[0];
|
const row = rows[0];
|
||||||
return row ? this.toDomain(row) : null;
|
return row ? await this.hydrateOne(row) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByCode(code: string): Promise<Product | null> {
|
async findByCode(code: string): Promise<Product | null> {
|
||||||
@@ -70,7 +87,7 @@ export class ProductsRepository {
|
|||||||
.where(eq(products.code, code))
|
.where(eq(products.code, code))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
const row = rows[0];
|
const row = rows[0];
|
||||||
return row ? this.toDomain(row) : null;
|
return row ? await this.hydrateOne(row) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(input: CreateProductInput): Promise<Product> {
|
async create(input: CreateProductInput): Promise<Product> {
|
||||||
@@ -82,7 +99,7 @@ export class ProductsRepository {
|
|||||||
.values(this.toInsertValues(input, status, now, input.userId))
|
.values(this.toInsertValues(input, status, now, input.userId))
|
||||||
.returning();
|
.returning();
|
||||||
const row = inserted[0];
|
const row = inserted[0];
|
||||||
return this.toDomain(row);
|
return await this.hydrateOne(row);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.rethrowConstraintViolation(error);
|
this.rethrowConstraintViolation(error);
|
||||||
}
|
}
|
||||||
@@ -132,7 +149,7 @@ export class ProductsRepository {
|
|||||||
.where(eq(products.id, id))
|
.where(eq(products.id, id))
|
||||||
.returning();
|
.returning();
|
||||||
const row = updated[0];
|
const row = updated[0];
|
||||||
return this.toDomain(row);
|
return await this.hydrateOne(row);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.rethrowConstraintViolation(error);
|
this.rethrowConstraintViolation(error);
|
||||||
}
|
}
|
||||||
@@ -157,7 +174,7 @@ export class ProductsRepository {
|
|||||||
if (!row) {
|
if (!row) {
|
||||||
throw new NotFoundException('Product not found');
|
throw new NotFoundException('Product not found');
|
||||||
}
|
}
|
||||||
return this.toDomain(row);
|
return await this.hydrateOne(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
async bulkUpdateStatus(
|
async bulkUpdateStatus(
|
||||||
@@ -262,7 +279,7 @@ export class ProductsRepository {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private toDomain(row: ProductRow): Product {
|
private toDomain(row: ProductRow) {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
code: row.code,
|
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 {
|
private rethrowConstraintViolation(error: unknown): never {
|
||||||
const err = this.unwrapDbError(error);
|
const err = this.unwrapDbError(error);
|
||||||
if (err.code === '23505') {
|
if (err.code === '23505') {
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ describe('ProductsService', () => {
|
|||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
createdBy: 'user-1',
|
createdBy: 'user-1',
|
||||||
|
createdByUser: { id: 'user-1', username: 'admin' },
|
||||||
|
updatedByUser: { id: 'user-1', username: 'admin' },
|
||||||
updatedBy: 'user-1',
|
updatedBy: 'user-1',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import type { PaginationResponse } from '../../../common/http/response';
|
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 { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||||
import { InvalidDecimalError } from '../../../common/value-objects/decimal/invalid-decimal.error';
|
import { InvalidDecimalError } from '../../../common/value-objects/decimal/invalid-decimal.error';
|
||||||
import { Status } from '../../../common/value-objects/status/status';
|
import { Status } from '../../../common/value-objects/status/status';
|
||||||
@@ -29,6 +29,8 @@ export type ListProductsQuery = {
|
|||||||
readonly brand?: string;
|
readonly brand?: string;
|
||||||
readonly status?: string;
|
readonly status?: string;
|
||||||
readonly search?: string;
|
readonly search?: string;
|
||||||
|
readonly orderBy?: string;
|
||||||
|
readonly orderType?: string;
|
||||||
readonly page?: number;
|
readonly page?: number;
|
||||||
readonly limit?: number;
|
readonly limit?: number;
|
||||||
readonly offset?: number;
|
readonly offset?: number;
|
||||||
@@ -65,6 +67,8 @@ export class ProductsService {
|
|||||||
brand: query.brand,
|
brand: query.brand,
|
||||||
status: query.status,
|
status: query.status,
|
||||||
search: query.search,
|
search: query.search,
|
||||||
|
orderBy: query.orderBy,
|
||||||
|
orderType: query.orderType,
|
||||||
limit: page.limit,
|
limit: page.limit,
|
||||||
offset: page.offset,
|
offset: page.offset,
|
||||||
});
|
});
|
||||||
@@ -240,8 +244,8 @@ export class ProductsService {
|
|||||||
status: product.status.value,
|
status: product.status.value,
|
||||||
createdAt: product.createdAt.value,
|
createdAt: product.createdAt.value,
|
||||||
updatedAt: product.updatedAt.value,
|
updatedAt: product.updatedAt.value,
|
||||||
createdBy: product.createdBy,
|
createdBy: pickUserRelation(product.createdByUser),
|
||||||
updatedBy: product.updatedBy,
|
updatedBy: pickUserRelation(product.updatedByUser),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import type {
|
||||||
|
DefaultRelation,
|
||||||
|
UserRelation,
|
||||||
|
} from '../../../common/http/response';
|
||||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||||
import { Status } from '../../../common/value-objects/status/status';
|
import { Status } from '../../../common/value-objects/status/status';
|
||||||
import type { FieldPurpose, WeekdayName } from '../shared/field-purpose';
|
import type { FieldPurpose, WeekdayName } from '../shared/field-purpose';
|
||||||
@@ -7,6 +11,7 @@ import type { WeekdaysInput } from '../shared/field-fields';
|
|||||||
export type CycleDestination = {
|
export type CycleDestination = {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly customerId: string;
|
readonly customerId: string;
|
||||||
|
readonly customer: DefaultRelation | null;
|
||||||
readonly sortOrder: number;
|
readonly sortOrder: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -15,6 +20,8 @@ export type CycleWeekday = {
|
|||||||
readonly weekday: WeekdayName;
|
readonly weekday: WeekdayName;
|
||||||
readonly startBranchId: string;
|
readonly startBranchId: string;
|
||||||
readonly endBranchId: string;
|
readonly endBranchId: string;
|
||||||
|
readonly startBranch: DefaultRelation | null;
|
||||||
|
readonly endBranch: DefaultRelation | null;
|
||||||
readonly routeGeometry: RouteGeometry;
|
readonly routeGeometry: RouteGeometry;
|
||||||
readonly destinations: readonly CycleDestination[];
|
readonly destinations: readonly CycleDestination[];
|
||||||
};
|
};
|
||||||
@@ -22,6 +29,7 @@ export type CycleWeekday = {
|
|||||||
export type Cycle = {
|
export type Cycle = {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly employeeId: string;
|
readonly employeeId: string;
|
||||||
|
readonly employee: DefaultRelation | null;
|
||||||
readonly purpose: FieldPurpose;
|
readonly purpose: FieldPurpose;
|
||||||
readonly cycleNumber: number;
|
readonly cycleNumber: number;
|
||||||
readonly weekdays: readonly CycleWeekday[];
|
readonly weekdays: readonly CycleWeekday[];
|
||||||
@@ -30,6 +38,8 @@ export type Cycle = {
|
|||||||
readonly updatedAt: DateTime;
|
readonly updatedAt: DateTime;
|
||||||
readonly createdBy: string;
|
readonly createdBy: string;
|
||||||
readonly updatedBy: string;
|
readonly updatedBy: string;
|
||||||
|
readonly createdByUser: UserRelation;
|
||||||
|
readonly updatedByUser: UserRelation;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CreateCycleInput = {
|
export type CreateCycleInput = {
|
||||||
@@ -55,6 +65,8 @@ export type ListCyclesFilters = {
|
|||||||
readonly cycleNumber?: number;
|
readonly cycleNumber?: number;
|
||||||
readonly status?: string;
|
readonly status?: string;
|
||||||
readonly search?: string;
|
readonly search?: string;
|
||||||
|
readonly orderBy?: string;
|
||||||
|
readonly orderType?: string;
|
||||||
readonly purposes?: readonly string[];
|
readonly purposes?: readonly string[];
|
||||||
readonly limit: number;
|
readonly limit: number;
|
||||||
readonly offset: number;
|
readonly offset: number;
|
||||||
|
|||||||
@@ -16,6 +16,14 @@ import {
|
|||||||
sql,
|
sql,
|
||||||
SQL,
|
SQL,
|
||||||
} from 'drizzle-orm';
|
} 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 { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||||
import { Status } from '../../../common/value-objects/status/status';
|
import { Status } from '../../../common/value-objects/status/status';
|
||||||
import { DRIZZLE, type DrizzleDB } from '../../../database/database.module';
|
import { DRIZZLE, type DrizzleDB } from '../../../database/database.module';
|
||||||
@@ -30,6 +38,16 @@ import {
|
|||||||
import type { FieldPurpose, WeekdayName } from '../shared/field-purpose';
|
import type { FieldPurpose, WeekdayName } from '../shared/field-purpose';
|
||||||
import type { Cycle, ListCyclesFilters, PersistableWeekday } from './cycle';
|
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'>;
|
type QueryExecutor = Pick<DrizzleDB, 'select' | 'insert' | 'delete' | 'update'>;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -48,11 +66,15 @@ export class CyclesRepository {
|
|||||||
qb = this.extendListQuery(qb, filters);
|
qb = this.extendListQuery(qb, filters);
|
||||||
const rows = await qb
|
const rows = await qb
|
||||||
.where(where)
|
.where(where)
|
||||||
.orderBy(asc(cycles.cycleNumber))
|
.orderBy(
|
||||||
|
...toOrderClauses(CYCLE_ORDER_COLUMNS, filters, [
|
||||||
|
{ column: 'cycleNumber', type: 'ASC' },
|
||||||
|
]),
|
||||||
|
)
|
||||||
.limit(filters.limit)
|
.limit(filters.limit)
|
||||||
.offset(filters.offset);
|
.offset(filters.offset);
|
||||||
return {
|
return {
|
||||||
data: rows.map((row) => this.toDomain(row, [], [])),
|
data: await this.hydrate(rows.map((row) => this.toDomain(row, [], []))),
|
||||||
total: Number(totalRows[0]?.total ?? 0),
|
total: Number(totalRows[0]?.total ?? 0),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -77,7 +99,7 @@ export class CyclesRepository {
|
|||||||
this.db,
|
this.db,
|
||||||
weekdays.map((weekday) => weekday.id),
|
weekdays.map((weekday) => weekday.id),
|
||||||
);
|
);
|
||||||
return this.toDomain(row, weekdays, destinations);
|
return this.hydrateOne(row, weekdays, destinations);
|
||||||
}
|
}
|
||||||
|
|
||||||
async findLiveByKey(
|
async findLiveByKey(
|
||||||
@@ -101,7 +123,7 @@ export class CyclesRepository {
|
|||||||
.where(and(...parts))
|
.where(and(...parts))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
const row = rows[0];
|
const row = rows[0];
|
||||||
return row ? this.toDomain(row, [], []) : null;
|
return row ? this.hydrateOne(row, [], []) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async listActiveByEmployeePurpose(
|
async listActiveByEmployeePurpose(
|
||||||
@@ -126,7 +148,7 @@ export class CyclesRepository {
|
|||||||
this.db,
|
this.db,
|
||||||
weekdays.map((weekday) => weekday.id),
|
weekdays.map((weekday) => weekday.id),
|
||||||
);
|
);
|
||||||
result.push(this.toDomain(row, weekdays, destinations));
|
result.push(await this.hydrateOne(row, weekdays, destinations));
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -291,7 +313,7 @@ export class CyclesRepository {
|
|||||||
executor,
|
executor,
|
||||||
weekdays.map((weekday) => weekday.id),
|
weekdays.map((weekday) => weekday.id),
|
||||||
);
|
);
|
||||||
return this.toDomain(row, weekdays, destinations);
|
return this.hydrateOne(row, weekdays, destinations);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async replaceWeekdays(
|
private async replaceWeekdays(
|
||||||
@@ -406,11 +428,14 @@ export class CyclesRepository {
|
|||||||
weekday: weekday.weekday as WeekdayName,
|
weekday: weekday.weekday as WeekdayName,
|
||||||
startBranchId: weekday.startBranchId,
|
startBranchId: weekday.startBranchId,
|
||||||
endBranchId: weekday.endBranchId,
|
endBranchId: weekday.endBranchId,
|
||||||
|
startBranch: null,
|
||||||
|
endBranch: null,
|
||||||
routeGeometry: weekday.routeGeometry,
|
routeGeometry: weekday.routeGeometry,
|
||||||
destinations: (destinationsByWeekday.get(weekday.id) ?? []).map(
|
destinations: (destinationsByWeekday.get(weekday.id) ?? []).map(
|
||||||
(destination) => ({
|
(destination) => ({
|
||||||
id: destination.id,
|
id: destination.id,
|
||||||
customerId: destination.customerId,
|
customerId: destination.customerId,
|
||||||
|
customer: null,
|
||||||
sortOrder: destination.sortOrder,
|
sortOrder: destination.sortOrder,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -420,9 +445,59 @@ export class CyclesRepository {
|
|||||||
updatedAt: DateTime.fromUnixMs(row.updatedAt),
|
updatedAt: DateTime.fromUnixMs(row.updatedAt),
|
||||||
createdBy: row.createdBy,
|
createdBy: row.createdBy,
|
||||||
updatedBy: row.updatedBy,
|
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 {
|
private rethrowConstraintViolation(error: unknown): never {
|
||||||
const err = this.unwrapDbError(error);
|
const err = this.unwrapDbError(error);
|
||||||
if (err.code === '23505') {
|
if (err.code === '23505') {
|
||||||
|
|||||||
@@ -62,8 +62,17 @@ describe('CyclesService', () => {
|
|||||||
weekday: 'monday',
|
weekday: 'monday',
|
||||||
startBranchId: 'br-1',
|
startBranchId: 'br-1',
|
||||||
endBranchId: 'br-2',
|
endBranchId: 'br-2',
|
||||||
|
startBranch: { id: 'br-1', code: 'B1', name: 'Start' },
|
||||||
|
endBranch: { id: 'br-2', code: 'B2', name: 'End' },
|
||||||
routeGeometry: geometry,
|
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'),
|
status: Status.create('draft'),
|
||||||
@@ -71,6 +80,9 @@ describe('CyclesService', () => {
|
|||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
createdBy: 'user-1',
|
createdBy: 'user-1',
|
||||||
updatedBy: '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 };
|
const located = { id: 'x', latitude: -6.2, longitude: 106.8 };
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import type { AuthUser } from '../../../common/auth/auth-user';
|
import type { AuthUser } from '../../../common/auth/auth-user';
|
||||||
import type { PaginationResponse } from '../../../common/http/response';
|
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 { InvalidStatusError } from '../../../common/value-objects/status/invalid-status.error';
|
||||||
import { Status } from '../../../common/value-objects/status/status';
|
import { Status } from '../../../common/value-objects/status/status';
|
||||||
import { BranchesService } from '../../configuration/branches/branches.service';
|
import { BranchesService } from '../../configuration/branches/branches.service';
|
||||||
@@ -40,6 +45,8 @@ export type ListCyclesQuery = {
|
|||||||
readonly cycleNumber?: number;
|
readonly cycleNumber?: number;
|
||||||
readonly status?: string;
|
readonly status?: string;
|
||||||
readonly search?: string;
|
readonly search?: string;
|
||||||
|
readonly orderBy?: string;
|
||||||
|
readonly orderType?: string;
|
||||||
readonly page?: number;
|
readonly page?: number;
|
||||||
readonly limit?: number;
|
readonly limit?: number;
|
||||||
readonly offset?: number;
|
readonly offset?: number;
|
||||||
@@ -93,6 +100,8 @@ export class CyclesService {
|
|||||||
cycleNumber: query.cycleNumber,
|
cycleNumber: query.cycleNumber,
|
||||||
status: query.status,
|
status: query.status,
|
||||||
search: query.search,
|
search: query.search,
|
||||||
|
orderBy: query.orderBy,
|
||||||
|
orderType: query.orderType,
|
||||||
purposes: query.purpose ? undefined : purposes,
|
purposes: query.purpose ? undefined : purposes,
|
||||||
limit: page.limit,
|
limit: page.limit,
|
||||||
offset: page.offset,
|
offset: page.offset,
|
||||||
@@ -237,26 +246,26 @@ export class CyclesService {
|
|||||||
toItem(cycle: Cycle) {
|
toItem(cycle: Cycle) {
|
||||||
return {
|
return {
|
||||||
id: cycle.id,
|
id: cycle.id,
|
||||||
employeeId: cycle.employeeId,
|
employee: pickRelation(cycle.employee, DEFAULT_RELATION_FIELDS),
|
||||||
purpose: cycle.purpose,
|
purpose: cycle.purpose,
|
||||||
cycleNumber: cycle.cycleNumber,
|
cycleNumber: cycle.cycleNumber,
|
||||||
weekdays: cycle.weekdays.map((weekday) => ({
|
weekdays: cycle.weekdays.map((weekday) => ({
|
||||||
id: weekday.id,
|
id: weekday.id,
|
||||||
weekday: weekday.weekday,
|
weekday: weekday.weekday,
|
||||||
startBranchId: weekday.startBranchId,
|
startBranch: pickRelation(weekday.startBranch, DEFAULT_RELATION_FIELDS),
|
||||||
endBranchId: weekday.endBranchId,
|
endBranch: pickRelation(weekday.endBranch, DEFAULT_RELATION_FIELDS),
|
||||||
routeGeometry: weekday.routeGeometry,
|
routeGeometry: weekday.routeGeometry,
|
||||||
destinations: weekday.destinations.map((destination) => ({
|
destinations: weekday.destinations.map((destination) => ({
|
||||||
id: destination.id,
|
id: destination.id,
|
||||||
customerId: destination.customerId,
|
customer: pickRelation(destination.customer, DEFAULT_RELATION_FIELDS),
|
||||||
sortOrder: destination.sortOrder,
|
sortOrder: destination.sortOrder,
|
||||||
})),
|
})),
|
||||||
})),
|
})),
|
||||||
status: cycle.status.value,
|
status: cycle.status.value,
|
||||||
createdAt: cycle.createdAt.value,
|
createdAt: cycle.createdAt.value,
|
||||||
updatedAt: cycle.updatedAt.value,
|
updatedAt: cycle.updatedAt.value,
|
||||||
createdBy: cycle.createdBy,
|
createdBy: pickUserRelation(cycle.createdByUser),
|
||||||
updatedBy: cycle.updatedBy,
|
updatedBy: pickUserRelation(cycle.updatedByUser),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,11 @@ import {
|
|||||||
Min,
|
Min,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
} from 'class-validator';
|
} 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 { CORE_STATUSES } from '../../../../common/value-objects/status/status';
|
||||||
import { FIELD_PURPOSES, WEEKDAY_NAMES } from '../../shared/field-purpose';
|
import { FIELD_PURPOSES, WEEKDAY_NAMES } from '../../shared/field-purpose';
|
||||||
import { RouteGeometryDto } from '../../shared/route-geometry.dto';
|
import { RouteGeometryDto } from '../../shared/route-geometry.dto';
|
||||||
@@ -143,8 +147,8 @@ export class CycleDestinationDto {
|
|||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ format: 'uuid' })
|
||||||
id!: string;
|
id!: string;
|
||||||
|
|
||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ type: DefaultRelationDto, nullable: true })
|
||||||
customerId!: string;
|
customer!: DefaultRelationDto | null;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
sortOrder!: number;
|
sortOrder!: number;
|
||||||
@@ -157,11 +161,11 @@ export class CycleWeekdayDto {
|
|||||||
@ApiProperty({ enum: WEEKDAY_NAMES })
|
@ApiProperty({ enum: WEEKDAY_NAMES })
|
||||||
weekday!: string;
|
weekday!: string;
|
||||||
|
|
||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ type: DefaultRelationDto, nullable: true })
|
||||||
startBranchId!: string;
|
startBranch!: DefaultRelationDto | null;
|
||||||
|
|
||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ type: DefaultRelationDto, nullable: true })
|
||||||
endBranchId!: string;
|
endBranch!: DefaultRelationDto | null;
|
||||||
|
|
||||||
@ApiProperty({ type: RouteGeometryDto })
|
@ApiProperty({ type: RouteGeometryDto })
|
||||||
routeGeometry!: RouteGeometryDto;
|
routeGeometry!: RouteGeometryDto;
|
||||||
@@ -174,8 +178,8 @@ export class CycleDto {
|
|||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ format: 'uuid' })
|
||||||
id!: string;
|
id!: string;
|
||||||
|
|
||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ type: DefaultRelationDto, nullable: true })
|
||||||
employeeId!: string;
|
employee!: DefaultRelationDto | null;
|
||||||
|
|
||||||
@ApiProperty({ enum: FIELD_PURPOSES })
|
@ApiProperty({ enum: FIELD_PURPOSES })
|
||||||
purpose!: string;
|
purpose!: string;
|
||||||
@@ -195,11 +199,11 @@ export class CycleDto {
|
|||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
updatedAt!: number;
|
updatedAt!: number;
|
||||||
|
|
||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ type: UserRelationDto })
|
||||||
createdBy!: string;
|
createdBy!: UserRelationDto;
|
||||||
|
|
||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ type: UserRelationDto })
|
||||||
updatedBy!: string;
|
updatedBy!: UserRelationDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
void ValidateNested;
|
void ValidateNested;
|
||||||
|
|||||||
@@ -10,7 +10,12 @@ import {
|
|||||||
IsUUID,
|
IsUUID,
|
||||||
Matches,
|
Matches,
|
||||||
} from 'class-validator';
|
} 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 { CORE_STATUSES } from '../../../../common/value-objects/status/status';
|
||||||
import { FIELD_PURPOSES } from '../../shared/field-purpose';
|
import { FIELD_PURPOSES } from '../../shared/field-purpose';
|
||||||
import { RouteGeometryDto } from '../../shared/route-geometry.dto';
|
import { RouteGeometryDto } from '../../shared/route-geometry.dto';
|
||||||
@@ -199,8 +204,8 @@ export class PlanDestinationDto {
|
|||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ format: 'uuid' })
|
||||||
id!: string;
|
id!: string;
|
||||||
|
|
||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ type: DefaultRelationDto, nullable: true })
|
||||||
customerId!: string;
|
customer!: DefaultRelationDto | null;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
sortOrder!: number;
|
sortOrder!: number;
|
||||||
@@ -210,8 +215,8 @@ export class PlanDto {
|
|||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ format: 'uuid' })
|
||||||
id!: string;
|
id!: string;
|
||||||
|
|
||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ type: DefaultRelationDto, nullable: true })
|
||||||
employeeId!: string;
|
employee!: DefaultRelationDto | null;
|
||||||
|
|
||||||
@ApiProperty({ enum: FIELD_PURPOSES })
|
@ApiProperty({ enum: FIELD_PURPOSES })
|
||||||
purpose!: string;
|
purpose!: string;
|
||||||
@@ -219,11 +224,11 @@ export class PlanDto {
|
|||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
date!: number;
|
date!: number;
|
||||||
|
|
||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ type: DefaultRelationDto, nullable: true })
|
||||||
startBranchId!: string;
|
startBranch!: DefaultRelationDto | null;
|
||||||
|
|
||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ type: DefaultRelationDto, nullable: true })
|
||||||
endBranchId!: string;
|
endBranch!: DefaultRelationDto | null;
|
||||||
|
|
||||||
@ApiProperty({ type: RouteGeometryDto })
|
@ApiProperty({ type: RouteGeometryDto })
|
||||||
routeGeometry!: RouteGeometryDto;
|
routeGeometry!: RouteGeometryDto;
|
||||||
@@ -231,11 +236,11 @@ export class PlanDto {
|
|||||||
@ApiProperty({ type: [PlanDestinationDto] })
|
@ApiProperty({ type: [PlanDestinationDto] })
|
||||||
destinations!: PlanDestinationDto[];
|
destinations!: PlanDestinationDto[];
|
||||||
|
|
||||||
@ApiProperty({ type: [String] })
|
@ApiProperty({ type: [CodeRelationDto] })
|
||||||
invoiceIds!: string[];
|
invoices!: CodeRelationDto[];
|
||||||
|
|
||||||
@ApiProperty({ type: [String] })
|
@ApiProperty({ type: [CodeRelationDto] })
|
||||||
packingSlipIds!: string[];
|
packingSlips!: CodeRelationDto[];
|
||||||
|
|
||||||
@ApiProperty({ enum: CORE_STATUSES })
|
@ApiProperty({ enum: CORE_STATUSES })
|
||||||
status!: string;
|
status!: string;
|
||||||
@@ -246,11 +251,11 @@ export class PlanDto {
|
|||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
updatedAt!: number;
|
updatedAt!: number;
|
||||||
|
|
||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ type: UserRelationDto })
|
||||||
createdBy!: string;
|
createdBy!: UserRelationDto;
|
||||||
|
|
||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ type: UserRelationDto })
|
||||||
updatedBy!: string;
|
updatedBy!: UserRelationDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
void Type;
|
void Type;
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
import type {
|
||||||
|
CodeRelation,
|
||||||
|
DefaultRelation,
|
||||||
|
UserRelation,
|
||||||
|
} from '../../../common/http/response';
|
||||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||||
import { Status } from '../../../common/value-objects/status/status';
|
import { Status } from '../../../common/value-objects/status/status';
|
||||||
import type { FieldPurpose } from '../shared/field-purpose';
|
import type { FieldPurpose } from '../shared/field-purpose';
|
||||||
@@ -6,25 +11,33 @@ import type { RouteGeometry } from '../shared/route-line-string';
|
|||||||
export type PlanDestination = {
|
export type PlanDestination = {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly customerId: string;
|
readonly customerId: string;
|
||||||
|
readonly customer: DefaultRelation | null;
|
||||||
readonly sortOrder: number;
|
readonly sortOrder: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Plan = {
|
export type Plan = {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly employeeId: string;
|
readonly employeeId: string;
|
||||||
|
readonly employee: DefaultRelation | null;
|
||||||
readonly purpose: FieldPurpose;
|
readonly purpose: FieldPurpose;
|
||||||
readonly date: DateTime;
|
readonly date: DateTime;
|
||||||
readonly startBranchId: string;
|
readonly startBranchId: string;
|
||||||
readonly endBranchId: string;
|
readonly endBranchId: string;
|
||||||
|
readonly startBranch: DefaultRelation | null;
|
||||||
|
readonly endBranch: DefaultRelation | null;
|
||||||
readonly routeGeometry: RouteGeometry;
|
readonly routeGeometry: RouteGeometry;
|
||||||
readonly destinations: readonly PlanDestination[];
|
readonly destinations: readonly PlanDestination[];
|
||||||
readonly invoiceIds: readonly string[];
|
readonly invoiceIds: readonly string[];
|
||||||
readonly packingSlipIds: readonly string[];
|
readonly packingSlipIds: readonly string[];
|
||||||
|
readonly invoices: readonly CodeRelation[];
|
||||||
|
readonly packingSlips: readonly CodeRelation[];
|
||||||
readonly status: Status;
|
readonly status: Status;
|
||||||
readonly createdAt: DateTime;
|
readonly createdAt: DateTime;
|
||||||
readonly updatedAt: DateTime;
|
readonly updatedAt: DateTime;
|
||||||
readonly createdBy: string;
|
readonly createdBy: string;
|
||||||
readonly updatedBy: string;
|
readonly updatedBy: string;
|
||||||
|
readonly createdByUser: UserRelation;
|
||||||
|
readonly updatedByUser: UserRelation;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PersistablePlanDestination = {
|
export type PersistablePlanDestination = {
|
||||||
@@ -37,6 +50,8 @@ export type ListPlansFilters = {
|
|||||||
readonly date?: number;
|
readonly date?: number;
|
||||||
readonly status?: string;
|
readonly status?: string;
|
||||||
readonly search?: string;
|
readonly search?: string;
|
||||||
|
readonly orderBy?: string;
|
||||||
|
readonly orderType?: string;
|
||||||
readonly purposes?: readonly string[];
|
readonly purposes?: readonly string[];
|
||||||
readonly limit: number;
|
readonly limit: number;
|
||||||
readonly offset: number;
|
readonly offset: number;
|
||||||
|
|||||||
@@ -16,6 +16,16 @@ import {
|
|||||||
sql,
|
sql,
|
||||||
SQL,
|
SQL,
|
||||||
} from 'drizzle-orm';
|
} 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 { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||||
import { Status } from '../../../common/value-objects/status/status';
|
import { Status } from '../../../common/value-objects/status/status';
|
||||||
import { DRIZZLE, type DrizzleDB } from '../../../database/database.module';
|
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 { RouteGeometry } from '../shared/route-line-string';
|
||||||
import type { ListPlansFilters, Plan } from './plan';
|
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'>;
|
type QueryExecutor = Pick<DrizzleDB, 'select' | 'insert' | 'delete' | 'update'>;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -51,11 +71,17 @@ export class PlansRepository {
|
|||||||
qb = this.extendListQuery(qb, filters);
|
qb = this.extendListQuery(qb, filters);
|
||||||
const rows = await qb
|
const rows = await qb
|
||||||
.where(where)
|
.where(where)
|
||||||
.orderBy(asc(plans.date))
|
.orderBy(
|
||||||
|
...toOrderClauses(PLAN_ORDER_COLUMNS, filters, [
|
||||||
|
{ column: 'date', type: 'ASC' },
|
||||||
|
]),
|
||||||
|
)
|
||||||
.limit(filters.limit)
|
.limit(filters.limit)
|
||||||
.offset(filters.offset);
|
.offset(filters.offset);
|
||||||
return {
|
return {
|
||||||
data: rows.map((row) => this.toDomain(row, [], [], [])),
|
data: await this.hydrate(
|
||||||
|
rows.map((row) => this.toDomain(row, [], [], [])),
|
||||||
|
),
|
||||||
total: Number(totalRows[0]?.total ?? 0),
|
total: Number(totalRows[0]?.total ?? 0),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -99,7 +125,7 @@ export class PlansRepository {
|
|||||||
.where(and(...parts))
|
.where(and(...parts))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
const row = rows[0];
|
const row = rows[0];
|
||||||
return row ? this.toDomain(row, [], [], []) : null;
|
return row ? this.hydrateOne(row, [], [], []) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(input: {
|
async create(input: {
|
||||||
@@ -327,7 +353,7 @@ export class PlansRepository {
|
|||||||
.select()
|
.select()
|
||||||
.from(planPackingSlips)
|
.from(planPackingSlips)
|
||||||
.where(eq(planPackingSlips.planId, row.id));
|
.where(eq(planPackingSlips.planId, row.id));
|
||||||
return this.toDomain(row, destinations, invoices, packingSlips);
|
return this.hydrateOne(row, destinations, invoices, packingSlips);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async replaceChildren(
|
private async replaceChildren(
|
||||||
@@ -423,21 +449,86 @@ export class PlansRepository {
|
|||||||
startBranchId: row.startBranchId,
|
startBranchId: row.startBranchId,
|
||||||
endBranchId: row.endBranchId,
|
endBranchId: row.endBranchId,
|
||||||
routeGeometry: row.routeGeometry,
|
routeGeometry: row.routeGeometry,
|
||||||
|
startBranch: null,
|
||||||
|
endBranch: null,
|
||||||
|
employee: null,
|
||||||
destinations: destinationRows.map((destination) => ({
|
destinations: destinationRows.map((destination) => ({
|
||||||
id: destination.id,
|
id: destination.id,
|
||||||
customerId: destination.customerId,
|
customerId: destination.customerId,
|
||||||
|
customer: null,
|
||||||
sortOrder: destination.sortOrder,
|
sortOrder: destination.sortOrder,
|
||||||
})),
|
})),
|
||||||
invoiceIds: invoiceRows.map((row) => row.invoiceId),
|
invoiceIds: invoiceRows.map((invoice) => invoice.invoiceId),
|
||||||
packingSlipIds: packingSlipRows.map((row) => row.packingSlipId),
|
packingSlipIds: packingSlipRows.map((slip) => slip.packingSlipId),
|
||||||
|
invoices: [],
|
||||||
|
packingSlips: [],
|
||||||
status: Status.create(row.status),
|
status: Status.create(row.status),
|
||||||
createdAt: DateTime.fromUnixMs(row.createdAt),
|
createdAt: DateTime.fromUnixMs(row.createdAt),
|
||||||
updatedAt: DateTime.fromUnixMs(row.updatedAt),
|
updatedAt: DateTime.fromUnixMs(row.updatedAt),
|
||||||
createdBy: row.createdBy,
|
createdBy: row.createdBy,
|
||||||
updatedBy: row.updatedBy,
|
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 {
|
private rethrowConstraintViolation(error: unknown): never {
|
||||||
const err = this.unwrapDbError(error);
|
const err = this.unwrapDbError(error);
|
||||||
if (err.code === '23505') {
|
if (err.code === '23505') {
|
||||||
|
|||||||
@@ -68,8 +68,17 @@ describe('PlansService', () => {
|
|||||||
weekday: 'monday',
|
weekday: 'monday',
|
||||||
startBranchId: 'br-1',
|
startBranchId: 'br-1',
|
||||||
endBranchId: 'br-2',
|
endBranchId: 'br-2',
|
||||||
|
startBranch: { id: 'br-1', code: 'B1', name: 'Start' },
|
||||||
|
endBranch: { id: 'br-2', code: 'B2', name: 'End' },
|
||||||
routeGeometry: geometry,
|
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'),
|
status: Status.create('active'),
|
||||||
@@ -77,6 +86,9 @@ describe('PlansService', () => {
|
|||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
createdBy: 'user-1',
|
createdBy: 'user-1',
|
||||||
updatedBy: '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 = {
|
const plan: Plan = {
|
||||||
@@ -87,14 +99,28 @@ describe('PlansService', () => {
|
|||||||
startBranchId: 'br-1',
|
startBranchId: 'br-1',
|
||||||
endBranchId: 'br-2',
|
endBranchId: 'br-2',
|
||||||
routeGeometry: geometry,
|
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: [],
|
invoiceIds: [],
|
||||||
packingSlipIds: [],
|
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'),
|
status: Status.create('active'),
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
createdBy: 'user-1',
|
createdBy: 'user-1',
|
||||||
updatedBy: 'user-1',
|
updatedBy: 'user-1',
|
||||||
|
createdByUser: { id: 'user-1', username: 'admin' },
|
||||||
|
updatedByUser: { id: 'user-1', username: 'admin' },
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -196,7 +222,12 @@ describe('PlansService', () => {
|
|||||||
...plan,
|
...plan,
|
||||||
destinations: [
|
destinations: [
|
||||||
...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);
|
await service.addDestination('pln-1', 'cus-2', undefined, user);
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import type { AuthUser } from '../../../common/auth/auth-user';
|
import type { AuthUser } from '../../../common/auth/auth-user';
|
||||||
import type { PaginationResponse } from '../../../common/http/response';
|
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 { InvalidDateTimeError } from '../../../common/value-objects/date-time/invalid-date-time.error';
|
||||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||||
import { InvalidStatusError } from '../../../common/value-objects/status/invalid-status.error';
|
import { InvalidStatusError } from '../../../common/value-objects/status/invalid-status.error';
|
||||||
@@ -40,6 +45,8 @@ export type ListPlansQuery = {
|
|||||||
readonly date?: string;
|
readonly date?: string;
|
||||||
readonly status?: string;
|
readonly status?: string;
|
||||||
readonly search?: string;
|
readonly search?: string;
|
||||||
|
readonly orderBy?: string;
|
||||||
|
readonly orderType?: string;
|
||||||
readonly page?: number;
|
readonly page?: number;
|
||||||
readonly limit?: number;
|
readonly limit?: number;
|
||||||
readonly offset?: number;
|
readonly offset?: number;
|
||||||
@@ -96,6 +103,8 @@ export class PlansService {
|
|||||||
: undefined,
|
: undefined,
|
||||||
status: query.status,
|
status: query.status,
|
||||||
search: query.search,
|
search: query.search,
|
||||||
|
orderBy: query.orderBy,
|
||||||
|
orderType: query.orderType,
|
||||||
purposes: query.purpose ? undefined : purposes,
|
purposes: query.purpose ? undefined : purposes,
|
||||||
limit: page.limit,
|
limit: page.limit,
|
||||||
offset: page.offset,
|
offset: page.offset,
|
||||||
@@ -390,24 +399,24 @@ export class PlansService {
|
|||||||
toItem(plan: Plan) {
|
toItem(plan: Plan) {
|
||||||
return {
|
return {
|
||||||
id: plan.id,
|
id: plan.id,
|
||||||
employeeId: plan.employeeId,
|
employee: pickRelation(plan.employee, DEFAULT_RELATION_FIELDS),
|
||||||
purpose: plan.purpose,
|
purpose: plan.purpose,
|
||||||
date: plan.date.value,
|
date: plan.date.value,
|
||||||
startBranchId: plan.startBranchId,
|
startBranch: pickRelation(plan.startBranch, DEFAULT_RELATION_FIELDS),
|
||||||
endBranchId: plan.endBranchId,
|
endBranch: pickRelation(plan.endBranch, DEFAULT_RELATION_FIELDS),
|
||||||
routeGeometry: plan.routeGeometry,
|
routeGeometry: plan.routeGeometry,
|
||||||
destinations: plan.destinations.map((destination) => ({
|
destinations: plan.destinations.map((destination) => ({
|
||||||
id: destination.id,
|
id: destination.id,
|
||||||
customerId: destination.customerId,
|
customer: pickRelation(destination.customer, DEFAULT_RELATION_FIELDS),
|
||||||
sortOrder: destination.sortOrder,
|
sortOrder: destination.sortOrder,
|
||||||
})),
|
})),
|
||||||
invoiceIds: [...plan.invoiceIds],
|
invoices: [...plan.invoices],
|
||||||
packingSlipIds: [...plan.packingSlipIds],
|
packingSlips: [...plan.packingSlips],
|
||||||
status: plan.status.value,
|
status: plan.status.value,
|
||||||
createdAt: plan.createdAt.value,
|
createdAt: plan.createdAt.value,
|
||||||
updatedAt: plan.updatedAt.value,
|
updatedAt: plan.updatedAt.value,
|
||||||
createdBy: plan.createdBy,
|
createdBy: pickUserRelation(plan.createdByUser),
|
||||||
updatedBy: plan.updatedBy,
|
updatedBy: pickUserRelation(plan.updatedByUser),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ import {
|
|||||||
MaxLength,
|
MaxLength,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
} from 'class-validator';
|
} 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 { CORE_STATUSES } from '../../../common/value-objects/status/status';
|
||||||
import { PRIVILEGE_ACTIONS } from '../privilege-action';
|
import { PRIVILEGE_ACTIONS } from '../privilege-action';
|
||||||
|
|
||||||
@@ -178,11 +181,11 @@ export class PrivilegeDto {
|
|||||||
@ApiProperty({ description: 'Unix ms' })
|
@ApiProperty({ description: 'Unix ms' })
|
||||||
updatedAt!: number;
|
updatedAt!: number;
|
||||||
|
|
||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ type: UserRelationDto })
|
||||||
createdBy!: string;
|
createdBy!: UserRelationDto;
|
||||||
|
|
||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ type: UserRelationDto })
|
||||||
updatedBy!: string;
|
updatedBy!: UserRelationDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PrivilegeDetailResponseDto extends PrivilegeDto {
|
export class PrivilegeDetailResponseDto extends PrivilegeDto {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { UserRelation } from '../../common/http/response';
|
||||||
import { DateTime } from '../../common/value-objects/date-time/date-time';
|
import { DateTime } from '../../common/value-objects/date-time/date-time';
|
||||||
import { Status } from '../../common/value-objects/status/status';
|
import { Status } from '../../common/value-objects/status/status';
|
||||||
import type { PrivilegeAction } from './privilege-action';
|
import type { PrivilegeAction } from './privilege-action';
|
||||||
@@ -21,6 +22,8 @@ export type Privilege = {
|
|||||||
readonly updatedAt: DateTime;
|
readonly updatedAt: DateTime;
|
||||||
readonly createdBy: string;
|
readonly createdBy: string;
|
||||||
readonly updatedBy: string;
|
readonly updatedBy: string;
|
||||||
|
readonly createdByUser: UserRelation;
|
||||||
|
readonly updatedByUser: UserRelation;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PrivilegeWithDetails = Privilege & {
|
export type PrivilegeWithDetails = Privilege & {
|
||||||
@@ -60,12 +63,16 @@ export type ListPrivilegesFilters = {
|
|||||||
readonly code?: string;
|
readonly code?: string;
|
||||||
readonly status?: string;
|
readonly status?: string;
|
||||||
readonly search?: string;
|
readonly search?: string;
|
||||||
|
readonly orderBy?: string;
|
||||||
|
readonly orderType?: string;
|
||||||
readonly limit: number;
|
readonly limit: number;
|
||||||
readonly offset: number;
|
readonly offset: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ListPrivilegeKeysFilters = {
|
export type ListPrivilegeKeysFilters = {
|
||||||
readonly search?: string;
|
readonly search?: string;
|
||||||
|
readonly orderBy?: string;
|
||||||
|
readonly orderType?: string;
|
||||||
readonly limit: number;
|
readonly limit: number;
|
||||||
readonly offset: number;
|
readonly offset: number;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { and, asc, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm';
|
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 { DateTime } from '../../common/value-objects/date-time/date-time';
|
||||||
import { Status } from '../../common/value-objects/status/status';
|
import { Status } from '../../common/value-objects/status/status';
|
||||||
import { DRIZZLE, type DrizzleDB } from '../../database/database.module';
|
import { DRIZZLE, type DrizzleDB } from '../../database/database.module';
|
||||||
@@ -30,6 +32,22 @@ import type {
|
|||||||
UpdatePrivilegeInput,
|
UpdatePrivilegeInput,
|
||||||
} from './privilege';
|
} 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()
|
@Injectable()
|
||||||
export class PrivilegesRepository {
|
export class PrivilegesRepository {
|
||||||
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
|
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
|
||||||
@@ -47,12 +65,16 @@ export class PrivilegesRepository {
|
|||||||
qb = this.extendListQuery(qb, filters);
|
qb = this.extendListQuery(qb, filters);
|
||||||
const rows = await qb
|
const rows = await qb
|
||||||
.where(where)
|
.where(where)
|
||||||
.orderBy(asc(privileges.code))
|
.orderBy(
|
||||||
|
...toOrderClauses(PRIVILEGE_ORDER_COLUMNS, filters, [
|
||||||
|
{ column: 'code', type: 'ASC' },
|
||||||
|
]),
|
||||||
|
)
|
||||||
.limit(filters.limit)
|
.limit(filters.limit)
|
||||||
.offset(filters.offset);
|
.offset(filters.offset);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: rows.map((row) => this.toDomain(row)),
|
data: await this.hydrate(rows),
|
||||||
total: Number(totalRow?.total ?? 0),
|
total: Number(totalRow?.total ?? 0),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -75,7 +97,8 @@ export class PrivilegesRepository {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const details = await this.loadDetails(id);
|
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> {
|
async findByCode(code: string): Promise<Privilege | null> {
|
||||||
@@ -84,7 +107,7 @@ export class PrivilegesRepository {
|
|||||||
.from(privileges)
|
.from(privileges)
|
||||||
.where(eq(privileges.code, code))
|
.where(eq(privileges.code, code))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
return row ? this.toDomain(row) : null;
|
return row ? this.hydrateOne(row) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(input: CreatePrivilegeInput): Promise<PrivilegeWithDetails> {
|
async create(input: CreatePrivilegeInput): Promise<PrivilegeWithDetails> {
|
||||||
@@ -117,7 +140,8 @@ export class PrivilegesRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const details = await this.loadDetails(row.id, tx);
|
const details = await this.loadDetails(row.id, tx);
|
||||||
return { ...this.toDomain(row), details };
|
const [base] = await this.hydrate([row]);
|
||||||
|
return { ...base, details };
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.rethrowUniqueViolation(error);
|
this.rethrowUniqueViolation(error);
|
||||||
@@ -190,7 +214,8 @@ export class PrivilegesRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const details = await this.loadDetails(id, tx);
|
const details = await this.loadDetails(id, tx);
|
||||||
return { ...this.toDomain(row), details };
|
const [base] = await this.hydrate([row]);
|
||||||
|
return { ...base, details };
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.rethrowUniqueViolation(error);
|
this.rethrowUniqueViolation(error);
|
||||||
@@ -215,7 +240,7 @@ export class PrivilegesRepository {
|
|||||||
if (!row) {
|
if (!row) {
|
||||||
throw new NotFoundException('Privilege not found');
|
throw new NotFoundException('Privilege not found');
|
||||||
}
|
}
|
||||||
return this.toDomain(row);
|
return this.hydrateOne(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
async bulkUpdateStatus(
|
async bulkUpdateStatus(
|
||||||
@@ -297,7 +322,12 @@ export class PrivilegesRepository {
|
|||||||
.select()
|
.select()
|
||||||
.from(privilegeKeys)
|
.from(privilegeKeys)
|
||||||
.where(where)
|
.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)
|
.limit(filters.limit)
|
||||||
.offset(filters.offset);
|
.offset(filters.offset);
|
||||||
|
|
||||||
@@ -451,7 +481,7 @@ export class PrivilegesRepository {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
private toDomain(row: PrivilegeRow): Privilege {
|
private toDomain(row: PrivilegeRow) {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
name: row.name,
|
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 {
|
private toKeyDomain(row: PrivilegeKeyRow): PrivilegeKey {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ describe('PrivilegesService', () => {
|
|||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
createdBy: 'user-1',
|
createdBy: 'user-1',
|
||||||
|
createdByUser: { id: 'user-1', username: 'admin' },
|
||||||
|
updatedByUser: { id: 'user-1', username: 'admin' },
|
||||||
updatedBy: 'user-1',
|
updatedBy: 'user-1',
|
||||||
details: [],
|
details: [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import type { PaginationResponse } from '../../common/http/response';
|
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 { Status } from '../../common/value-objects/status/status';
|
||||||
import type { PrivilegeAction } from './privilege-action';
|
import type { PrivilegeAction } from './privilege-action';
|
||||||
import { assertPrivilegeAction } from './privilege-action';
|
import { assertPrivilegeAction } from './privilege-action';
|
||||||
@@ -23,6 +23,8 @@ export type ListPrivilegesQuery = {
|
|||||||
readonly code?: string;
|
readonly code?: string;
|
||||||
readonly status?: string;
|
readonly status?: string;
|
||||||
readonly search?: string;
|
readonly search?: string;
|
||||||
|
readonly orderBy?: string;
|
||||||
|
readonly orderType?: string;
|
||||||
readonly page?: number;
|
readonly page?: number;
|
||||||
readonly limit?: number;
|
readonly limit?: number;
|
||||||
readonly offset?: number;
|
readonly offset?: number;
|
||||||
@@ -30,6 +32,8 @@ export type ListPrivilegesQuery = {
|
|||||||
|
|
||||||
export type ListPrivilegeKeysQuery = {
|
export type ListPrivilegeKeysQuery = {
|
||||||
readonly search?: string;
|
readonly search?: string;
|
||||||
|
readonly orderBy?: string;
|
||||||
|
readonly orderType?: string;
|
||||||
readonly page?: number;
|
readonly page?: number;
|
||||||
readonly limit?: number;
|
readonly limit?: number;
|
||||||
readonly offset?: number;
|
readonly offset?: number;
|
||||||
@@ -59,6 +63,8 @@ export class PrivilegesService {
|
|||||||
code: query.code,
|
code: query.code,
|
||||||
status: query.status,
|
status: query.status,
|
||||||
search: query.search,
|
search: query.search,
|
||||||
|
orderBy: query.orderBy,
|
||||||
|
orderType: query.orderType,
|
||||||
limit: page.limit,
|
limit: page.limit,
|
||||||
offset: page.offset,
|
offset: page.offset,
|
||||||
});
|
});
|
||||||
@@ -241,6 +247,8 @@ export class PrivilegesService {
|
|||||||
const page = toListPage(query);
|
const page = toListPage(query);
|
||||||
return this.privilegesRepository.listKeys({
|
return this.privilegesRepository.listKeys({
|
||||||
search: query.search,
|
search: query.search,
|
||||||
|
orderBy: query.orderBy,
|
||||||
|
orderType: query.orderType,
|
||||||
limit: page.limit,
|
limit: page.limit,
|
||||||
offset: page.offset,
|
offset: page.offset,
|
||||||
});
|
});
|
||||||
@@ -313,8 +321,8 @@ export class PrivilegesService {
|
|||||||
status: privilege.status.value,
|
status: privilege.status.value,
|
||||||
createdAt: privilege.createdAt.value,
|
createdAt: privilege.createdAt.value,
|
||||||
updatedAt: privilege.updatedAt.value,
|
updatedAt: privilege.updatedAt.value,
|
||||||
createdBy: privilege.createdBy,
|
createdBy: pickUserRelation(privilege.createdByUser),
|
||||||
updatedBy: privilege.updatedBy,
|
updatedBy: pickUserRelation(privilege.updatedByUser),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,12 @@ import {
|
|||||||
Min,
|
Min,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { PaginationQueryDto } from '../../../../common/http/response';
|
import {
|
||||||
|
CodeRelationDto,
|
||||||
|
DefaultRelationDto,
|
||||||
|
PaginationQueryDto,
|
||||||
|
UserRelationDto,
|
||||||
|
} from '../../../../common/http/response';
|
||||||
import {
|
import {
|
||||||
DOCUMENT_ADDRESS_MAX_LENGTH,
|
DOCUMENT_ADDRESS_MAX_LENGTH,
|
||||||
DOCUMENT_CODE_MAX_LENGTH,
|
DOCUMENT_CODE_MAX_LENGTH,
|
||||||
@@ -217,14 +222,12 @@ export class PackingSlipDto {
|
|||||||
id!: string;
|
id!: string;
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
code!: string;
|
code!: string;
|
||||||
@ApiPropertyOptional({ nullable: true, format: 'uuid' })
|
@ApiPropertyOptional({ type: CodeRelationDto, nullable: true })
|
||||||
salesOrderId!: string | null;
|
salesOrder!: CodeRelationDto | null;
|
||||||
@ApiPropertyOptional({ nullable: true })
|
|
||||||
salesOrderNumber!: string | null;
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
date!: number;
|
date!: number;
|
||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ type: DefaultRelationDto, nullable: true })
|
||||||
customerId!: string;
|
customer!: DefaultRelationDto | null;
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
address!: string;
|
address!: string;
|
||||||
@ApiPropertyOptional({ nullable: true })
|
@ApiPropertyOptional({ nullable: true })
|
||||||
@@ -239,8 +242,8 @@ export class PackingSlipDto {
|
|||||||
createdAt!: number;
|
createdAt!: number;
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
updatedAt!: number;
|
updatedAt!: number;
|
||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ type: UserRelationDto })
|
||||||
createdBy!: string;
|
createdBy!: UserRelationDto;
|
||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ type: UserRelationDto })
|
||||||
updatedBy!: string;
|
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 { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||||
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||||
import { Status } from '../../../common/value-objects/status/status';
|
import { Status } from '../../../common/value-objects/status/status';
|
||||||
@@ -5,6 +10,7 @@ import { Status } from '../../../common/value-objects/status/status';
|
|||||||
export type PackingSlipLine = {
|
export type PackingSlipLine = {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly productId: string;
|
readonly productId: string;
|
||||||
|
readonly product: DefaultRelation | null;
|
||||||
readonly quantity: Decimal;
|
readonly quantity: Decimal;
|
||||||
readonly price: Decimal;
|
readonly price: Decimal;
|
||||||
};
|
};
|
||||||
@@ -26,6 +32,10 @@ export type PackingSlip = {
|
|||||||
readonly updatedAt: DateTime;
|
readonly updatedAt: DateTime;
|
||||||
readonly createdBy: string;
|
readonly createdBy: string;
|
||||||
readonly updatedBy: string;
|
readonly updatedBy: string;
|
||||||
|
readonly salesOrder: CodeRelation | null;
|
||||||
|
readonly customer: DefaultRelation | null;
|
||||||
|
readonly createdByUser: UserRelation;
|
||||||
|
readonly updatedByUser: UserRelation;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PackingSlipLineInput = {
|
export type PackingSlipLineInput = {
|
||||||
@@ -69,6 +79,8 @@ export type ListPackingSlipsFilters = {
|
|||||||
readonly customerId?: string;
|
readonly customerId?: string;
|
||||||
readonly salesOrderId?: string;
|
readonly salesOrderId?: string;
|
||||||
readonly search?: string;
|
readonly search?: string;
|
||||||
|
readonly orderBy?: string;
|
||||||
|
readonly orderType?: string;
|
||||||
readonly limit: number;
|
readonly limit: number;
|
||||||
readonly offset: number;
|
readonly offset: number;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,16 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} 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 { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||||
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||||
import { Status } from '../../../common/value-objects/status/status';
|
import { Status } from '../../../common/value-objects/status/status';
|
||||||
@@ -26,6 +35,15 @@ import type {
|
|||||||
UpdatePackingSlipInput,
|
UpdatePackingSlipInput,
|
||||||
} from './packing-slip';
|
} 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'>;
|
type QueryExecutor = Pick<DrizzleDB, 'select' | 'insert' | 'delete'>;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -47,11 +65,15 @@ export class PackingSlipsRepository {
|
|||||||
.select()
|
.select()
|
||||||
.from(packingSlips)
|
.from(packingSlips)
|
||||||
.where(where)
|
.where(where)
|
||||||
.orderBy(asc(packingSlips.code))
|
.orderBy(
|
||||||
|
...toOrderClauses(PACKING_SLIP_ORDER_COLUMNS, filters, [
|
||||||
|
{ column: 'code', type: 'ASC' },
|
||||||
|
]),
|
||||||
|
)
|
||||||
.limit(filters.limit)
|
.limit(filters.limit)
|
||||||
.offset(filters.offset);
|
.offset(filters.offset);
|
||||||
return {
|
return {
|
||||||
data: rows.map((row) => this.toDomain(row, [])),
|
data: await this.hydrate(rows.map((row) => this.toDomain(row, []))),
|
||||||
total: Number(totalRows[0]?.total ?? 0),
|
total: Number(totalRows[0]?.total ?? 0),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -67,7 +89,7 @@ export class PackingSlipsRepository {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const products = await this.selectProducts(this.db, id);
|
const products = await this.selectProducts(this.db, id);
|
||||||
return this.toDomain(row, products);
|
return this.hydrateOne(row, products);
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(input: CreatePackingSlipInput): Promise<PackingSlip> {
|
async create(input: CreatePackingSlipInput): Promise<PackingSlip> {
|
||||||
@@ -90,7 +112,7 @@ export class PackingSlipsRepository {
|
|||||||
const row = inserted[0];
|
const row = inserted[0];
|
||||||
await this.replaceProducts(tx, row.id, input.products);
|
await this.replaceProducts(tx, row.id, input.products);
|
||||||
const products = await this.selectProducts(tx, row.id);
|
const products = await this.selectProducts(tx, row.id);
|
||||||
return this.toDomain(row, products);
|
return this.hydrateOne(row, products);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.rethrowConstraintViolation(error);
|
this.rethrowConstraintViolation(error);
|
||||||
@@ -153,7 +175,7 @@ export class PackingSlipsRepository {
|
|||||||
await this.replaceProducts(tx, id, input.products);
|
await this.replaceProducts(tx, id, input.products);
|
||||||
}
|
}
|
||||||
const products = await this.selectProducts(tx, id);
|
const products = await this.selectProducts(tx, id);
|
||||||
return this.toDomain(row, products);
|
return this.hydrateOne(row, products);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.rethrowConstraintViolation(error);
|
this.rethrowConstraintViolation(error);
|
||||||
@@ -180,7 +202,7 @@ export class PackingSlipsRepository {
|
|||||||
throw new NotFoundException('Packing slip not found');
|
throw new NotFoundException('Packing slip not found');
|
||||||
}
|
}
|
||||||
const products = await this.selectProducts(this.db, id);
|
const products = await this.selectProducts(this.db, id);
|
||||||
return this.toDomain(row, products);
|
return this.hydrateOne(row, products);
|
||||||
}
|
}
|
||||||
|
|
||||||
async bulkUpdateStatus(
|
async bulkUpdateStatus(
|
||||||
@@ -329,6 +351,7 @@ export class PackingSlipsRepository {
|
|||||||
products: productRows.map((line) => ({
|
products: productRows.map((line) => ({
|
||||||
id: line.id,
|
id: line.id,
|
||||||
productId: line.productId,
|
productId: line.productId,
|
||||||
|
product: null,
|
||||||
quantity: Decimal.create(line.quantity),
|
quantity: Decimal.create(line.quantity),
|
||||||
price: Decimal.create(line.price),
|
price: Decimal.create(line.price),
|
||||||
})),
|
})),
|
||||||
@@ -337,9 +360,52 @@ export class PackingSlipsRepository {
|
|||||||
updatedAt: DateTime.fromUnixMs(row.updatedAt),
|
updatedAt: DateTime.fromUnixMs(row.updatedAt),
|
||||||
createdBy: row.createdBy,
|
createdBy: row.createdBy,
|
||||||
updatedBy: row.updatedBy,
|
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 {
|
private rethrowConstraintViolation(error: unknown): never {
|
||||||
if (error instanceof NotFoundException) {
|
if (error instanceof NotFoundException) {
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ describe('PackingSlipsService', () => {
|
|||||||
{
|
{
|
||||||
id: 'line-1',
|
id: 'line-1',
|
||||||
productId: 'prd-1',
|
productId: 'prd-1',
|
||||||
|
product: { id: 'prd-1', code: 'P1', name: 'Fuel' },
|
||||||
quantity: Decimal.create('2'),
|
quantity: Decimal.create('2'),
|
||||||
price: Decimal.create('12500'),
|
price: Decimal.create('12500'),
|
||||||
},
|
},
|
||||||
@@ -66,6 +67,10 @@ describe('PackingSlipsService', () => {
|
|||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
createdBy: 'user-1',
|
createdBy: 'user-1',
|
||||||
updatedBy: '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 = {
|
const createBody = {
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import type { PaginationResponse } from '../../../common/http/response';
|
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 { InvalidDateTimeError } from '../../../common/value-objects/date-time/invalid-date-time.error';
|
||||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||||
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||||
@@ -42,6 +48,8 @@ export type ListPackingSlipsQuery = {
|
|||||||
readonly customerId?: string;
|
readonly customerId?: string;
|
||||||
readonly salesOrderId?: string;
|
readonly salesOrderId?: string;
|
||||||
readonly search?: string;
|
readonly search?: string;
|
||||||
|
readonly orderBy?: string;
|
||||||
|
readonly orderType?: string;
|
||||||
readonly page?: number;
|
readonly page?: number;
|
||||||
readonly limit?: number;
|
readonly limit?: number;
|
||||||
readonly offset?: number;
|
readonly offset?: number;
|
||||||
@@ -70,6 +78,8 @@ export class PackingSlipsService {
|
|||||||
customerId: query.customerId,
|
customerId: query.customerId,
|
||||||
salesOrderId: query.salesOrderId,
|
salesOrderId: query.salesOrderId,
|
||||||
search: query.search,
|
search: query.search,
|
||||||
|
orderBy: query.orderBy,
|
||||||
|
orderType: query.orderType,
|
||||||
limit: page.limit,
|
limit: page.limit,
|
||||||
offset: page.offset,
|
offset: page.offset,
|
||||||
});
|
});
|
||||||
@@ -272,10 +282,9 @@ export class PackingSlipsService {
|
|||||||
return {
|
return {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
code: item.code,
|
code: item.code,
|
||||||
salesOrderId: item.salesOrderId,
|
salesOrder: pickCodeRelation(item.salesOrder),
|
||||||
salesOrderNumber: item.salesOrderNumber,
|
|
||||||
date: item.date.value,
|
date: item.date.value,
|
||||||
customerId: item.customerId,
|
customer: pickRelation(item.customer, DEFAULT_RELATION_FIELDS),
|
||||||
address: item.address,
|
address: item.address,
|
||||||
latitude: item.latitude,
|
latitude: item.latitude,
|
||||||
longitude: item.longitude,
|
longitude: item.longitude,
|
||||||
@@ -283,8 +292,8 @@ export class PackingSlipsService {
|
|||||||
status: item.status.value,
|
status: item.status.value,
|
||||||
createdAt: item.createdAt.value,
|
createdAt: item.createdAt.value,
|
||||||
updatedAt: item.updatedAt.value,
|
updatedAt: item.updatedAt.value,
|
||||||
createdBy: item.createdBy,
|
createdBy: pickUserRelation(item.createdByUser),
|
||||||
updatedBy: item.updatedBy,
|
updatedBy: pickUserRelation(item.updatedByUser),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,7 +302,7 @@ export class PackingSlipsService {
|
|||||||
...this.toListItem(item),
|
...this.toListItem(item),
|
||||||
products: item.products.map((line) => ({
|
products: item.products.map((line) => ({
|
||||||
id: line.id,
|
id: line.id,
|
||||||
productId: line.productId,
|
product: pickRelation(line.product, DEFAULT_RELATION_FIELDS),
|
||||||
quantity: line.quantity.value,
|
quantity: line.quantity.value,
|
||||||
price: line.price.value,
|
price: line.price.value,
|
||||||
})),
|
})),
|
||||||
@@ -331,7 +340,7 @@ export class PackingSlipsService {
|
|||||||
salesOrderId: input.salesOrderId,
|
salesOrderId: input.salesOrderId,
|
||||||
salesOrderNumber: source.code,
|
salesOrderNumber: source.code,
|
||||||
date: input.date ?? DateTime.fromUnixMs(source.date).format(),
|
date: input.date ?? DateTime.fromUnixMs(source.date).format(),
|
||||||
customerId: input.customerId ?? source.customerId,
|
customerId: input.customerId ?? source.customer?.id ?? '',
|
||||||
address: input.address ?? source.address,
|
address: input.address ?? source.address,
|
||||||
latitude: input.latitude !== undefined ? input.latitude : source.latitude,
|
latitude: input.latitude !== undefined ? input.latitude : source.latitude,
|
||||||
longitude:
|
longitude:
|
||||||
@@ -340,7 +349,7 @@ export class PackingSlipsService {
|
|||||||
products:
|
products:
|
||||||
input.products ??
|
input.products ??
|
||||||
source.products.map((line) => ({
|
source.products.map((line) => ({
|
||||||
productId: line.productId,
|
productId: line.product?.id ?? '',
|
||||||
quantity: line.quantity,
|
quantity: line.quantity,
|
||||||
price: line.price,
|
price: line.price,
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -12,7 +12,12 @@ import {
|
|||||||
MaxLength,
|
MaxLength,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { PaginationQueryDto } from '../../../../common/http/response';
|
import {
|
||||||
|
CodeRelationDto,
|
||||||
|
DefaultRelationDto,
|
||||||
|
PaginationQueryDto,
|
||||||
|
UserRelationDto,
|
||||||
|
} from '../../../../common/http/response';
|
||||||
import {
|
import {
|
||||||
DOCUMENT_CODE_MAX_LENGTH,
|
DOCUMENT_CODE_MAX_LENGTH,
|
||||||
DOCUMENT_CODE_PATTERN,
|
DOCUMENT_CODE_PATTERN,
|
||||||
@@ -232,24 +237,20 @@ export class SalesInvoiceDto {
|
|||||||
id!: string;
|
id!: string;
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
code!: string;
|
code!: string;
|
||||||
@ApiPropertyOptional({ nullable: true, format: 'uuid' })
|
@ApiPropertyOptional({ type: CodeRelationDto, nullable: true })
|
||||||
salesOrderId!: string | null;
|
salesOrder!: CodeRelationDto | null;
|
||||||
@ApiPropertyOptional({ nullable: true })
|
@ApiPropertyOptional({ type: CodeRelationDto, nullable: true })
|
||||||
salesOrderCode!: string | null;
|
packingSlip!: CodeRelationDto | null;
|
||||||
@ApiPropertyOptional({ nullable: true, format: 'uuid' })
|
|
||||||
packingSlipId!: string | null;
|
|
||||||
@ApiPropertyOptional({ nullable: true })
|
|
||||||
packingSlipCode!: string | null;
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
date!: number;
|
date!: number;
|
||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ type: DefaultRelationDto, nullable: true })
|
||||||
salesPersonId!: string;
|
salesPerson!: DefaultRelationDto | null;
|
||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ type: DefaultRelationDto, nullable: true })
|
||||||
branchId!: string;
|
branch!: DefaultRelationDto | null;
|
||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ type: DefaultRelationDto, nullable: true })
|
||||||
divisionId!: string;
|
division!: DefaultRelationDto | null;
|
||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ type: DefaultRelationDto, nullable: true })
|
||||||
customerId!: string;
|
customer!: DefaultRelationDto | null;
|
||||||
@ApiPropertyOptional({ nullable: true })
|
@ApiPropertyOptional({ nullable: true })
|
||||||
notes!: string | null;
|
notes!: string | null;
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@@ -260,8 +261,8 @@ export class SalesInvoiceDto {
|
|||||||
createdAt!: number;
|
createdAt!: number;
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
updatedAt!: number;
|
updatedAt!: number;
|
||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ type: UserRelationDto })
|
||||||
createdBy!: string;
|
createdBy!: UserRelationDto;
|
||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ type: UserRelationDto })
|
||||||
updatedBy!: string;
|
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 { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||||
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||||
import { Status } from '../../../common/value-objects/status/status';
|
import { Status } from '../../../common/value-objects/status/status';
|
||||||
@@ -5,6 +10,7 @@ import { Status } from '../../../common/value-objects/status/status';
|
|||||||
export type SalesInvoiceLine = {
|
export type SalesInvoiceLine = {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly productId: string;
|
readonly productId: string;
|
||||||
|
readonly product: DefaultRelation | null;
|
||||||
readonly quantity: Decimal;
|
readonly quantity: Decimal;
|
||||||
readonly price: Decimal;
|
readonly price: Decimal;
|
||||||
};
|
};
|
||||||
@@ -29,6 +35,14 @@ export type SalesInvoice = {
|
|||||||
readonly updatedAt: DateTime;
|
readonly updatedAt: DateTime;
|
||||||
readonly createdBy: string;
|
readonly createdBy: string;
|
||||||
readonly updatedBy: 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 = {
|
export type SalesInvoiceLineInput = {
|
||||||
@@ -80,6 +94,8 @@ export type ListSalesInvoicesFilters = {
|
|||||||
readonly salesOrderId?: string;
|
readonly salesOrderId?: string;
|
||||||
readonly packingSlipId?: string;
|
readonly packingSlipId?: string;
|
||||||
readonly search?: string;
|
readonly search?: string;
|
||||||
|
readonly orderBy?: string;
|
||||||
|
readonly orderType?: string;
|
||||||
readonly limit: number;
|
readonly limit: number;
|
||||||
readonly offset: number;
|
readonly offset: number;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,15 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { and, asc, count, eq, ilike, inArray, or, SQL, sum } from 'drizzle-orm';
|
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 { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||||
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||||
import { Status } from '../../../common/value-objects/status/status';
|
import { Status } from '../../../common/value-objects/status/status';
|
||||||
@@ -30,6 +39,15 @@ import type {
|
|||||||
UpdateSalesInvoiceInput,
|
UpdateSalesInvoiceInput,
|
||||||
} from './sales-invoice';
|
} 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'>;
|
type QueryExecutor = Pick<DrizzleDB, 'select' | 'insert' | 'delete' | 'update'>;
|
||||||
|
|
||||||
export type InvoiceTotals = {
|
export type InvoiceTotals = {
|
||||||
@@ -57,11 +75,15 @@ export class SalesInvoicesRepository {
|
|||||||
.select()
|
.select()
|
||||||
.from(salesInvoices)
|
.from(salesInvoices)
|
||||||
.where(where)
|
.where(where)
|
||||||
.orderBy(asc(salesInvoices.code))
|
.orderBy(
|
||||||
|
...toOrderClauses(SALES_INVOICE_ORDER_COLUMNS, filters, [
|
||||||
|
{ column: 'code', type: 'ASC' },
|
||||||
|
]),
|
||||||
|
)
|
||||||
.limit(filters.limit)
|
.limit(filters.limit)
|
||||||
.offset(filters.offset);
|
.offset(filters.offset);
|
||||||
return {
|
return {
|
||||||
data: rows.map((row) => this.toDomain(row, [])),
|
data: await this.hydrate(rows.map((row) => this.toDomain(row, []))),
|
||||||
total: Number(totalRows[0]?.total ?? 0),
|
total: Number(totalRows[0]?.total ?? 0),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -77,7 +99,7 @@ export class SalesInvoicesRepository {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const products = await this.selectProducts(this.db, id);
|
const products = await this.selectProducts(this.db, id);
|
||||||
return this.toDomain(row, products);
|
return this.hydrateOne(row, products);
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(input: CreateSalesInvoiceInput): Promise<SalesInvoice> {
|
async create(input: CreateSalesInvoiceInput): Promise<SalesInvoice> {
|
||||||
@@ -101,7 +123,7 @@ export class SalesInvoicesRepository {
|
|||||||
await this.replaceProducts(tx, row.id, input.products);
|
await this.replaceProducts(tx, row.id, input.products);
|
||||||
const products = await this.selectProducts(tx, row.id);
|
const products = await this.selectProducts(tx, row.id);
|
||||||
const withBalance = await this.refreshBalance(tx, row.id, products);
|
const withBalance = await this.refreshBalance(tx, row.id, products);
|
||||||
return this.toDomain(withBalance, products);
|
return this.hydrateOne(withBalance, products);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.rethrowConstraintViolation(error);
|
this.rethrowConstraintViolation(error);
|
||||||
@@ -169,7 +191,7 @@ export class SalesInvoicesRepository {
|
|||||||
}
|
}
|
||||||
const products = await this.selectProducts(tx, id);
|
const products = await this.selectProducts(tx, id);
|
||||||
const withBalance = await this.refreshBalance(tx, id, products);
|
const withBalance = await this.refreshBalance(tx, id, products);
|
||||||
return this.toDomain(withBalance, products);
|
return this.hydrateOne(withBalance, products);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.rethrowConstraintViolation(error);
|
this.rethrowConstraintViolation(error);
|
||||||
@@ -196,7 +218,7 @@ export class SalesInvoicesRepository {
|
|||||||
throw new NotFoundException('Sales invoice not found');
|
throw new NotFoundException('Sales invoice not found');
|
||||||
}
|
}
|
||||||
const products = await this.selectProducts(this.db, id);
|
const products = await this.selectProducts(this.db, id);
|
||||||
return this.toDomain(row, products);
|
return this.hydrateOne(row, products);
|
||||||
}
|
}
|
||||||
|
|
||||||
async bulkUpdateStatus(
|
async bulkUpdateStatus(
|
||||||
@@ -249,7 +271,7 @@ export class SalesInvoicesRepository {
|
|||||||
async refreshStoredBalance(invoiceId: string): Promise<SalesInvoice> {
|
async refreshStoredBalance(invoiceId: string): Promise<SalesInvoice> {
|
||||||
const products = await this.selectProducts(this.db, invoiceId);
|
const products = await this.selectProducts(this.db, invoiceId);
|
||||||
const row = await this.refreshBalance(this.db, invoiceId, products);
|
const row = await this.refreshBalance(this.db, invoiceId, products);
|
||||||
return this.toDomain(row, products);
|
return this.hydrateOne(row, products);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async totalsFrom(
|
private async totalsFrom(
|
||||||
@@ -419,6 +441,7 @@ export class SalesInvoicesRepository {
|
|||||||
products: productRows.map((line) => ({
|
products: productRows.map((line) => ({
|
||||||
id: line.id,
|
id: line.id,
|
||||||
productId: line.productId,
|
productId: line.productId,
|
||||||
|
product: null,
|
||||||
quantity: Decimal.create(line.quantity),
|
quantity: Decimal.create(line.quantity),
|
||||||
price: Decimal.create(line.price),
|
price: Decimal.create(line.price),
|
||||||
})),
|
})),
|
||||||
@@ -427,9 +450,58 @@ export class SalesInvoicesRepository {
|
|||||||
updatedAt: DateTime.fromUnixMs(row.updatedAt),
|
updatedAt: DateTime.fromUnixMs(row.updatedAt),
|
||||||
createdBy: row.createdBy,
|
createdBy: row.createdBy,
|
||||||
updatedBy: row.updatedBy,
|
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 {
|
private rethrowConstraintViolation(error: unknown): never {
|
||||||
if (error instanceof NotFoundException) {
|
if (error instanceof NotFoundException) {
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ describe('SalesInvoicesService', () => {
|
|||||||
{
|
{
|
||||||
id: 'line-1',
|
id: 'line-1',
|
||||||
productId: 'prd-1',
|
productId: 'prd-1',
|
||||||
|
product: { id: 'prd-1', code: 'P1', name: 'Fuel' },
|
||||||
quantity: Decimal.create('2'),
|
quantity: Decimal.create('2'),
|
||||||
price: Decimal.create('12500'),
|
price: Decimal.create('12500'),
|
||||||
},
|
},
|
||||||
@@ -81,6 +82,14 @@ describe('SalesInvoicesService', () => {
|
|||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
createdBy: 'user-1',
|
createdBy: 'user-1',
|
||||||
updatedBy: '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 = {
|
const createBody = {
|
||||||
@@ -134,15 +143,15 @@ describe('SalesInvoicesService', () => {
|
|||||||
id: 'so-1',
|
id: 'so-1',
|
||||||
code: 'SO-1',
|
code: 'SO-1',
|
||||||
date: now.value,
|
date: now.value,
|
||||||
salesPersonId: 'emp-1',
|
salesPerson: { id: 'emp-1', code: 'E1', name: 'Ada' },
|
||||||
branchId: 'br-1',
|
branch: { id: 'br-1', code: 'B1', name: 'Jakarta' },
|
||||||
divisionId: 'div-1',
|
division: { id: 'div-1', code: 'FIN', name: 'Finance' },
|
||||||
customerId: 'cus-1',
|
customer: { id: 'cus-1', code: 'C1', name: 'Acme' },
|
||||||
notes: 'from order',
|
notes: 'from order',
|
||||||
products: [
|
products: [
|
||||||
{
|
{
|
||||||
id: 'ol-1',
|
id: 'ol-1',
|
||||||
productId: 'prd-1',
|
product: { id: 'prd-1', code: 'P1', name: 'Fuel' },
|
||||||
quantity: '2.0000',
|
quantity: '2.0000',
|
||||||
price: '12500.0000',
|
price: '12500.0000',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import type { PaginationResponse } from '../../../common/http/response';
|
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 { InvalidDateTimeError } from '../../../common/value-objects/date-time/invalid-date-time.error';
|
||||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||||
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||||
@@ -47,6 +53,8 @@ export type ListSalesInvoicesQuery = {
|
|||||||
readonly salesOrderId?: string;
|
readonly salesOrderId?: string;
|
||||||
readonly packingSlipId?: string;
|
readonly packingSlipId?: string;
|
||||||
readonly search?: string;
|
readonly search?: string;
|
||||||
|
readonly orderBy?: string;
|
||||||
|
readonly orderType?: string;
|
||||||
readonly page?: number;
|
readonly page?: number;
|
||||||
readonly limit?: number;
|
readonly limit?: number;
|
||||||
readonly offset?: number;
|
readonly offset?: number;
|
||||||
@@ -89,6 +97,8 @@ export class SalesInvoicesService {
|
|||||||
salesOrderId: query.salesOrderId,
|
salesOrderId: query.salesOrderId,
|
||||||
packingSlipId: query.packingSlipId,
|
packingSlipId: query.packingSlipId,
|
||||||
search: query.search,
|
search: query.search,
|
||||||
|
orderBy: query.orderBy,
|
||||||
|
orderType: query.orderType,
|
||||||
limit: page.limit,
|
limit: page.limit,
|
||||||
offset: page.offset,
|
offset: page.offset,
|
||||||
});
|
});
|
||||||
@@ -316,22 +326,20 @@ export class SalesInvoicesService {
|
|||||||
return {
|
return {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
code: item.code,
|
code: item.code,
|
||||||
salesOrderId: item.salesOrderId,
|
salesOrder: pickCodeRelation(item.salesOrder),
|
||||||
salesOrderCode: item.salesOrderCode,
|
packingSlip: pickCodeRelation(item.packingSlip),
|
||||||
packingSlipId: item.packingSlipId,
|
|
||||||
packingSlipCode: item.packingSlipCode,
|
|
||||||
date: item.date.value,
|
date: item.date.value,
|
||||||
salesPersonId: item.salesPersonId,
|
salesPerson: pickRelation(item.salesPerson, DEFAULT_RELATION_FIELDS),
|
||||||
branchId: item.branchId,
|
branch: pickRelation(item.branch, DEFAULT_RELATION_FIELDS),
|
||||||
divisionId: item.divisionId,
|
division: pickRelation(item.division, DEFAULT_RELATION_FIELDS),
|
||||||
customerId: item.customerId,
|
customer: pickRelation(item.customer, DEFAULT_RELATION_FIELDS),
|
||||||
notes: item.notes,
|
notes: item.notes,
|
||||||
balance: item.balance.value,
|
balance: item.balance.value,
|
||||||
status: item.status.value,
|
status: item.status.value,
|
||||||
createdAt: item.createdAt.value,
|
createdAt: item.createdAt.value,
|
||||||
updatedAt: item.updatedAt.value,
|
updatedAt: item.updatedAt.value,
|
||||||
createdBy: item.createdBy,
|
createdBy: pickUserRelation(item.createdByUser),
|
||||||
updatedBy: item.updatedBy,
|
updatedBy: pickUserRelation(item.updatedByUser),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,7 +348,7 @@ export class SalesInvoicesService {
|
|||||||
...this.toListItem(item),
|
...this.toListItem(item),
|
||||||
products: item.products.map((line) => ({
|
products: item.products.map((line) => ({
|
||||||
id: line.id,
|
id: line.id,
|
||||||
productId: line.productId,
|
product: pickRelation(line.product, DEFAULT_RELATION_FIELDS),
|
||||||
quantity: line.quantity.value,
|
quantity: line.quantity.value,
|
||||||
price: line.price.value,
|
price: line.price.value,
|
||||||
})),
|
})),
|
||||||
@@ -374,15 +382,15 @@ export class SalesInvoicesService {
|
|||||||
const order = await this.salesOrdersService.findById(input.salesOrderId);
|
const order = await this.salesOrdersService.findById(input.salesOrderId);
|
||||||
salesOrderCode = order.code;
|
salesOrderCode = order.code;
|
||||||
date = date || DateTime.fromUnixMs(order.date).format();
|
date = date || DateTime.fromUnixMs(order.date).format();
|
||||||
salesPersonId = salesPersonId || order.salesPersonId;
|
salesPersonId = salesPersonId || order.salesPerson?.id || '';
|
||||||
branchId = branchId || order.branchId;
|
branchId = branchId || order.branch?.id || '';
|
||||||
divisionId = divisionId || order.divisionId;
|
divisionId = divisionId || order.division?.id || '';
|
||||||
customerId = customerId || order.customerId;
|
customerId = customerId || order.customer?.id || '';
|
||||||
notes = notes !== undefined ? notes : order.notes;
|
notes = notes !== undefined ? notes : order.notes;
|
||||||
products =
|
products =
|
||||||
products ??
|
products ??
|
||||||
order.products.map((line) => ({
|
order.products.map((line) => ({
|
||||||
productId: line.productId,
|
productId: line.product?.id ?? '',
|
||||||
quantity: line.quantity,
|
quantity: line.quantity,
|
||||||
price: line.price,
|
price: line.price,
|
||||||
}));
|
}));
|
||||||
@@ -391,13 +399,13 @@ export class SalesInvoicesService {
|
|||||||
const slip = await this.packingSlipsService.findById(input.packingSlipId);
|
const slip = await this.packingSlipsService.findById(input.packingSlipId);
|
||||||
packingSlipCode = slip.code;
|
packingSlipCode = slip.code;
|
||||||
date = date || DateTime.fromUnixMs(slip.date).format();
|
date = date || DateTime.fromUnixMs(slip.date).format();
|
||||||
customerId = customerId || slip.customerId;
|
customerId = customerId || slip.customer?.id || '';
|
||||||
notes = notes !== undefined ? notes : slip.notes;
|
notes = notes !== undefined ? notes : slip.notes;
|
||||||
products =
|
products =
|
||||||
input.products ??
|
input.products ??
|
||||||
products ??
|
products ??
|
||||||
slip.products.map((line) => ({
|
slip.products.map((line) => ({
|
||||||
productId: line.productId,
|
productId: line.product?.id ?? '',
|
||||||
quantity: line.quantity,
|
quantity: line.quantity,
|
||||||
price: line.price,
|
price: line.price,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -15,7 +15,12 @@ import {
|
|||||||
Min,
|
Min,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { PaginationQueryDto } from '../../../../common/http/response';
|
import {
|
||||||
|
CodeRelationDto,
|
||||||
|
DefaultRelationDto,
|
||||||
|
PaginationQueryDto,
|
||||||
|
UserRelationDto,
|
||||||
|
} from '../../../../common/http/response';
|
||||||
import {
|
import {
|
||||||
DOCUMENT_ADDRESS_MAX_LENGTH,
|
DOCUMENT_ADDRESS_MAX_LENGTH,
|
||||||
DOCUMENT_CODE_MAX_LENGTH,
|
DOCUMENT_CODE_MAX_LENGTH,
|
||||||
@@ -272,18 +277,18 @@ export class SalesOrderDto {
|
|||||||
id!: string;
|
id!: string;
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
code!: string;
|
code!: string;
|
||||||
@ApiPropertyOptional({ nullable: true, format: 'uuid' })
|
@ApiPropertyOptional({ type: CodeRelationDto, nullable: true })
|
||||||
salesRequestId!: string | null;
|
salesRequest!: CodeRelationDto | null;
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
date!: number;
|
date!: number;
|
||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ type: DefaultRelationDto, nullable: true })
|
||||||
salesPersonId!: string;
|
salesPerson!: DefaultRelationDto | null;
|
||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ type: DefaultRelationDto, nullable: true })
|
||||||
branchId!: string;
|
branch!: DefaultRelationDto | null;
|
||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ type: DefaultRelationDto, nullable: true })
|
||||||
divisionId!: string;
|
division!: DefaultRelationDto | null;
|
||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ type: DefaultRelationDto, nullable: true })
|
||||||
customerId!: string;
|
customer!: DefaultRelationDto | null;
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
address!: string;
|
address!: string;
|
||||||
@ApiPropertyOptional({ nullable: true })
|
@ApiPropertyOptional({ nullable: true })
|
||||||
@@ -298,8 +303,8 @@ export class SalesOrderDto {
|
|||||||
createdAt!: number;
|
createdAt!: number;
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
updatedAt!: number;
|
updatedAt!: number;
|
||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ type: UserRelationDto })
|
||||||
createdBy!: string;
|
createdBy!: UserRelationDto;
|
||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ type: UserRelationDto })
|
||||||
updatedBy!: string;
|
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 { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||||
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||||
import { Status } from '../../../common/value-objects/status/status';
|
import { Status } from '../../../common/value-objects/status/status';
|
||||||
@@ -5,6 +10,7 @@ import { Status } from '../../../common/value-objects/status/status';
|
|||||||
export type SalesOrderLine = {
|
export type SalesOrderLine = {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly productId: string;
|
readonly productId: string;
|
||||||
|
readonly product: DefaultRelation | null;
|
||||||
readonly quantity: Decimal;
|
readonly quantity: Decimal;
|
||||||
readonly price: Decimal;
|
readonly price: Decimal;
|
||||||
};
|
};
|
||||||
@@ -35,6 +41,13 @@ export type SalesOrder = {
|
|||||||
readonly updatedAt: DateTime;
|
readonly updatedAt: DateTime;
|
||||||
readonly createdBy: string;
|
readonly createdBy: string;
|
||||||
readonly updatedBy: 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 = {
|
export type SalesOrderLineInput = {
|
||||||
@@ -90,6 +103,8 @@ export type ListSalesOrdersFilters = {
|
|||||||
readonly branchId?: string;
|
readonly branchId?: string;
|
||||||
readonly divisionId?: string;
|
readonly divisionId?: string;
|
||||||
readonly search?: string;
|
readonly search?: string;
|
||||||
|
readonly orderBy?: string;
|
||||||
|
readonly orderType?: string;
|
||||||
readonly limit: number;
|
readonly limit: number;
|
||||||
readonly offset: number;
|
readonly offset: number;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,15 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} 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 { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||||
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||||
import { Status } from '../../../common/value-objects/status/status';
|
import { Status } from '../../../common/value-objects/status/status';
|
||||||
@@ -29,6 +37,15 @@ import type {
|
|||||||
UpdateSalesOrderInput,
|
UpdateSalesOrderInput,
|
||||||
} from './sales-order';
|
} 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'>;
|
type QueryExecutor = Pick<DrizzleDB, 'select' | 'insert' | 'delete'>;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -50,11 +67,15 @@ export class SalesOrdersRepository {
|
|||||||
.select()
|
.select()
|
||||||
.from(salesOrders)
|
.from(salesOrders)
|
||||||
.where(where)
|
.where(where)
|
||||||
.orderBy(asc(salesOrders.code))
|
.orderBy(
|
||||||
|
...toOrderClauses(SALES_ORDER_ORDER_COLUMNS, filters, [
|
||||||
|
{ column: 'code', type: 'ASC' },
|
||||||
|
]),
|
||||||
|
)
|
||||||
.limit(filters.limit)
|
.limit(filters.limit)
|
||||||
.offset(filters.offset);
|
.offset(filters.offset);
|
||||||
return {
|
return {
|
||||||
data: rows.map((row) => this.toDomain(row, [], [])),
|
data: await this.hydrate(rows.map((row) => this.toDomain(row, [], []))),
|
||||||
total: Number(totalRows[0]?.total ?? 0),
|
total: Number(totalRows[0]?.total ?? 0),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -71,7 +92,7 @@ export class SalesOrdersRepository {
|
|||||||
}
|
}
|
||||||
const products = await this.selectProducts(this.db, id);
|
const products = await this.selectProducts(this.db, id);
|
||||||
const images = await this.selectImages(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> {
|
async create(input: CreateSalesOrderInput): Promise<SalesOrder> {
|
||||||
@@ -95,7 +116,7 @@ export class SalesOrdersRepository {
|
|||||||
await this.replaceImages(tx, row.id, input.images ?? []);
|
await this.replaceImages(tx, row.id, input.images ?? []);
|
||||||
const products = await this.selectProducts(tx, row.id);
|
const products = await this.selectProducts(tx, row.id);
|
||||||
const images = await this.selectImages(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) {
|
} catch (error) {
|
||||||
this.rethrowConstraintViolation(error);
|
this.rethrowConstraintViolation(error);
|
||||||
@@ -154,7 +175,7 @@ export class SalesOrdersRepository {
|
|||||||
}
|
}
|
||||||
const products = await this.selectProducts(tx, id);
|
const products = await this.selectProducts(tx, id);
|
||||||
const images = await this.selectImages(tx, id);
|
const images = await this.selectImages(tx, id);
|
||||||
return this.toDomain(row, products, images);
|
return this.hydrateOne(row, products, images);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.rethrowConstraintViolation(error);
|
this.rethrowConstraintViolation(error);
|
||||||
@@ -182,7 +203,7 @@ export class SalesOrdersRepository {
|
|||||||
}
|
}
|
||||||
const products = await this.selectProducts(this.db, id);
|
const products = await this.selectProducts(this.db, id);
|
||||||
const images = await this.selectImages(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(
|
async bulkUpdateStatus(
|
||||||
@@ -372,6 +393,7 @@ export class SalesOrdersRepository {
|
|||||||
products: productRows.map((line) => ({
|
products: productRows.map((line) => ({
|
||||||
id: line.id,
|
id: line.id,
|
||||||
productId: line.productId,
|
productId: line.productId,
|
||||||
|
product: null,
|
||||||
quantity: Decimal.create(line.quantity),
|
quantity: Decimal.create(line.quantity),
|
||||||
price: Decimal.create(line.price),
|
price: Decimal.create(line.price),
|
||||||
})),
|
})),
|
||||||
@@ -385,9 +407,47 @@ export class SalesOrdersRepository {
|
|||||||
updatedAt: DateTime.fromUnixMs(row.updatedAt),
|
updatedAt: DateTime.fromUnixMs(row.updatedAt),
|
||||||
createdBy: row.createdBy,
|
createdBy: row.createdBy,
|
||||||
updatedBy: row.updatedBy,
|
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 {
|
private rethrowConstraintViolation(error: unknown): never {
|
||||||
if (error instanceof NotFoundException) {
|
if (error instanceof NotFoundException) {
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ describe('SalesOrdersService', () => {
|
|||||||
{
|
{
|
||||||
id: 'line-1',
|
id: 'line-1',
|
||||||
productId: 'prd-1',
|
productId: 'prd-1',
|
||||||
|
product: { id: 'prd-1', code: 'P1', name: 'Fuel' },
|
||||||
quantity: Decimal.create('2'),
|
quantity: Decimal.create('2'),
|
||||||
price: Decimal.create('12500'),
|
price: Decimal.create('12500'),
|
||||||
},
|
},
|
||||||
@@ -75,6 +76,13 @@ describe('SalesOrdersService', () => {
|
|||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
createdBy: 'user-1',
|
createdBy: 'user-1',
|
||||||
updatedBy: '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 = {
|
const createBody = {
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import type { PaginationResponse } from '../../../common/http/response';
|
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 { InvalidDateTimeError } from '../../../common/value-objects/date-time/invalid-date-time.error';
|
||||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||||
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||||
@@ -55,6 +61,8 @@ export type ListSalesOrdersQuery = {
|
|||||||
readonly branchId?: string;
|
readonly branchId?: string;
|
||||||
readonly divisionId?: string;
|
readonly divisionId?: string;
|
||||||
readonly search?: string;
|
readonly search?: string;
|
||||||
|
readonly orderBy?: string;
|
||||||
|
readonly orderType?: string;
|
||||||
readonly page?: number;
|
readonly page?: number;
|
||||||
readonly limit?: number;
|
readonly limit?: number;
|
||||||
readonly offset?: number;
|
readonly offset?: number;
|
||||||
@@ -93,6 +101,8 @@ export class SalesOrdersService {
|
|||||||
branchId: query.branchId,
|
branchId: query.branchId,
|
||||||
divisionId: query.divisionId,
|
divisionId: query.divisionId,
|
||||||
search: query.search,
|
search: query.search,
|
||||||
|
orderBy: query.orderBy,
|
||||||
|
orderType: query.orderType,
|
||||||
limit: page.limit,
|
limit: page.limit,
|
||||||
offset: page.offset,
|
offset: page.offset,
|
||||||
});
|
});
|
||||||
@@ -303,12 +313,12 @@ export class SalesOrdersService {
|
|||||||
return {
|
return {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
code: item.code,
|
code: item.code,
|
||||||
salesRequestId: item.salesRequestId,
|
salesRequest: pickCodeRelation(item.salesRequest),
|
||||||
date: item.date.value,
|
date: item.date.value,
|
||||||
salesPersonId: item.salesPersonId,
|
salesPerson: pickRelation(item.salesPerson, DEFAULT_RELATION_FIELDS),
|
||||||
branchId: item.branchId,
|
branch: pickRelation(item.branch, DEFAULT_RELATION_FIELDS),
|
||||||
divisionId: item.divisionId,
|
division: pickRelation(item.division, DEFAULT_RELATION_FIELDS),
|
||||||
customerId: item.customerId,
|
customer: pickRelation(item.customer, DEFAULT_RELATION_FIELDS),
|
||||||
address: item.address,
|
address: item.address,
|
||||||
latitude: item.latitude,
|
latitude: item.latitude,
|
||||||
longitude: item.longitude,
|
longitude: item.longitude,
|
||||||
@@ -316,8 +326,8 @@ export class SalesOrdersService {
|
|||||||
status: item.status.value,
|
status: item.status.value,
|
||||||
createdAt: item.createdAt.value,
|
createdAt: item.createdAt.value,
|
||||||
updatedAt: item.updatedAt.value,
|
updatedAt: item.updatedAt.value,
|
||||||
createdBy: item.createdBy,
|
createdBy: pickUserRelation(item.createdByUser),
|
||||||
updatedBy: item.updatedBy,
|
updatedBy: pickUserRelation(item.updatedByUser),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,7 +336,7 @@ export class SalesOrdersService {
|
|||||||
...this.toListItem(item),
|
...this.toListItem(item),
|
||||||
products: item.products.map((line) => ({
|
products: item.products.map((line) => ({
|
||||||
id: line.id,
|
id: line.id,
|
||||||
productId: line.productId,
|
product: pickRelation(line.product, DEFAULT_RELATION_FIELDS),
|
||||||
quantity: line.quantity.value,
|
quantity: line.quantity.value,
|
||||||
price: line.price.value,
|
price: line.price.value,
|
||||||
})),
|
})),
|
||||||
@@ -373,10 +383,10 @@ export class SalesOrdersService {
|
|||||||
return {
|
return {
|
||||||
...input,
|
...input,
|
||||||
date: input.date ?? DateTime.fromUnixMs(source.date).format(),
|
date: input.date ?? DateTime.fromUnixMs(source.date).format(),
|
||||||
salesPersonId: input.salesPersonId ?? source.salesPersonId,
|
salesPersonId: input.salesPersonId ?? source.salesPerson?.id ?? '',
|
||||||
branchId: input.branchId ?? source.branchId,
|
branchId: input.branchId ?? source.branch?.id ?? '',
|
||||||
divisionId: input.divisionId ?? source.divisionId,
|
divisionId: input.divisionId ?? source.division?.id ?? '',
|
||||||
customerId: input.customerId ?? source.customerId,
|
customerId: input.customerId ?? source.customer?.id ?? '',
|
||||||
address: input.address ?? source.address,
|
address: input.address ?? source.address,
|
||||||
latitude: input.latitude !== undefined ? input.latitude : source.latitude,
|
latitude: input.latitude !== undefined ? input.latitude : source.latitude,
|
||||||
longitude:
|
longitude:
|
||||||
@@ -385,7 +395,7 @@ export class SalesOrdersService {
|
|||||||
products:
|
products:
|
||||||
input.products ??
|
input.products ??
|
||||||
source.products.map((line) => ({
|
source.products.map((line) => ({
|
||||||
productId: line.productId,
|
productId: line.product?.id ?? '',
|
||||||
quantity: line.quantity,
|
quantity: line.quantity,
|
||||||
price: line.price,
|
price: line.price,
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ import {
|
|||||||
MaxLength,
|
MaxLength,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { PaginationQueryDto } from '../../../../common/http/response';
|
import {
|
||||||
|
PaginationQueryDto,
|
||||||
|
UserRelationDto,
|
||||||
|
} from '../../../../common/http/response';
|
||||||
import {
|
import {
|
||||||
DOCUMENT_CODE_MAX_LENGTH,
|
DOCUMENT_CODE_MAX_LENGTH,
|
||||||
DOCUMENT_CODE_PATTERN,
|
DOCUMENT_CODE_PATTERN,
|
||||||
@@ -177,8 +180,8 @@ export class SalesPaymentDto {
|
|||||||
createdAt!: number;
|
createdAt!: number;
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
updatedAt!: number;
|
updatedAt!: number;
|
||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ type: UserRelationDto })
|
||||||
createdBy!: string;
|
createdBy!: UserRelationDto;
|
||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ type: UserRelationDto })
|
||||||
updatedBy!: string;
|
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 { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||||
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||||
import { Status } from '../../../common/value-objects/status/status';
|
import { Status } from '../../../common/value-objects/status/status';
|
||||||
@@ -5,6 +6,7 @@ import { Status } from '../../../common/value-objects/status/status';
|
|||||||
export type SalesPaymentAllocation = {
|
export type SalesPaymentAllocation = {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly invoiceId: string;
|
readonly invoiceId: string;
|
||||||
|
readonly invoice: CodeRelation | null;
|
||||||
readonly amount: Decimal;
|
readonly amount: Decimal;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -26,6 +28,8 @@ export type SalesPayment = {
|
|||||||
readonly updatedAt: DateTime;
|
readonly updatedAt: DateTime;
|
||||||
readonly createdBy: string;
|
readonly createdBy: string;
|
||||||
readonly updatedBy: string;
|
readonly updatedBy: string;
|
||||||
|
readonly createdByUser: UserRelation;
|
||||||
|
readonly updatedByUser: UserRelation;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SalesPaymentAllocationInput = {
|
export type SalesPaymentAllocationInput = {
|
||||||
@@ -61,6 +65,8 @@ export type ListSalesPaymentsFilters = {
|
|||||||
readonly code?: string;
|
readonly code?: string;
|
||||||
readonly status?: string;
|
readonly status?: string;
|
||||||
readonly search?: string;
|
readonly search?: string;
|
||||||
|
readonly orderBy?: string;
|
||||||
|
readonly orderType?: string;
|
||||||
readonly limit: number;
|
readonly limit: number;
|
||||||
readonly offset: number;
|
readonly offset: number;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} 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 { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||||
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||||
import { Status } from '../../../common/value-objects/status/status';
|
import { Status } from '../../../common/value-objects/status/status';
|
||||||
@@ -29,6 +35,15 @@ import type {
|
|||||||
UpdateSalesPaymentInput,
|
UpdateSalesPaymentInput,
|
||||||
} from './sales-payment';
|
} 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'>;
|
type QueryExecutor = Pick<DrizzleDB, 'select' | 'insert' | 'delete'>;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -50,11 +65,15 @@ export class SalesPaymentsRepository {
|
|||||||
.select()
|
.select()
|
||||||
.from(salesPayments)
|
.from(salesPayments)
|
||||||
.where(where)
|
.where(where)
|
||||||
.orderBy(asc(salesPayments.code))
|
.orderBy(
|
||||||
|
...toOrderClauses(SALES_PAYMENT_ORDER_COLUMNS, filters, [
|
||||||
|
{ column: 'code', type: 'ASC' },
|
||||||
|
]),
|
||||||
|
)
|
||||||
.limit(filters.limit)
|
.limit(filters.limit)
|
||||||
.offset(filters.offset);
|
.offset(filters.offset);
|
||||||
return {
|
return {
|
||||||
data: rows.map((row) => this.toDomain(row, [], [])),
|
data: await this.hydrate(rows.map((row) => this.toDomain(row, [], []))),
|
||||||
total: Number(totalRows[0]?.total ?? 0),
|
total: Number(totalRows[0]?.total ?? 0),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -71,7 +90,7 @@ export class SalesPaymentsRepository {
|
|||||||
}
|
}
|
||||||
const invoices = await this.selectAllocations(this.db, id);
|
const invoices = await this.selectAllocations(this.db, id);
|
||||||
const images = await this.selectImages(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> {
|
async create(input: CreateSalesPaymentInput): Promise<SalesPayment> {
|
||||||
@@ -96,7 +115,7 @@ export class SalesPaymentsRepository {
|
|||||||
await this.replaceImages(tx, row.id, input.images ?? []);
|
await this.replaceImages(tx, row.id, input.images ?? []);
|
||||||
const invoices = await this.selectAllocations(tx, row.id);
|
const invoices = await this.selectAllocations(tx, row.id);
|
||||||
const images = await this.selectImages(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) {
|
} catch (error) {
|
||||||
this.rethrowConstraintViolation(error);
|
this.rethrowConstraintViolation(error);
|
||||||
@@ -147,7 +166,7 @@ export class SalesPaymentsRepository {
|
|||||||
}
|
}
|
||||||
const invoices = await this.selectAllocations(tx, id);
|
const invoices = await this.selectAllocations(tx, id);
|
||||||
const images = await this.selectImages(tx, id);
|
const images = await this.selectImages(tx, id);
|
||||||
return this.toDomain(row, invoices, images);
|
return this.hydrateOne(row, invoices, images);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.rethrowConstraintViolation(error);
|
this.rethrowConstraintViolation(error);
|
||||||
@@ -175,7 +194,7 @@ export class SalesPaymentsRepository {
|
|||||||
}
|
}
|
||||||
const invoices = await this.selectAllocations(this.db, id);
|
const invoices = await this.selectAllocations(this.db, id);
|
||||||
const images = await this.selectImages(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(
|
async bulkUpdateStatus(
|
||||||
@@ -335,6 +354,7 @@ export class SalesPaymentsRepository {
|
|||||||
invoices: allocationRows.map((line) => ({
|
invoices: allocationRows.map((line) => ({
|
||||||
id: line.id,
|
id: line.id,
|
||||||
invoiceId: line.salesInvoiceId,
|
invoiceId: line.salesInvoiceId,
|
||||||
|
invoice: null,
|
||||||
amount: Decimal.create(line.amount),
|
amount: Decimal.create(line.amount),
|
||||||
})),
|
})),
|
||||||
images: imageRows.map((image) => ({
|
images: imageRows.map((image) => ({
|
||||||
@@ -347,9 +367,35 @@ export class SalesPaymentsRepository {
|
|||||||
updatedAt: DateTime.fromUnixMs(row.updatedAt),
|
updatedAt: DateTime.fromUnixMs(row.updatedAt),
|
||||||
createdBy: row.createdBy,
|
createdBy: row.createdBy,
|
||||||
updatedBy: row.updatedBy,
|
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 {
|
private rethrowConstraintViolation(error: unknown): never {
|
||||||
if (error instanceof NotFoundException) {
|
if (error instanceof NotFoundException) {
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ describe('SalesPaymentsService', () => {
|
|||||||
{
|
{
|
||||||
id: 'alloc-1',
|
id: 'alloc-1',
|
||||||
invoiceId: 'si-1',
|
invoiceId: 'si-1',
|
||||||
|
invoice: { id: 'si-1', code: 'SI-1' },
|
||||||
amount: Decimal.create('10000'),
|
amount: Decimal.create('10000'),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -60,6 +61,8 @@ describe('SalesPaymentsService', () => {
|
|||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
createdBy: 'user-1',
|
createdBy: 'user-1',
|
||||||
updatedBy: 'user-1',
|
updatedBy: 'user-1',
|
||||||
|
createdByUser: { id: 'user-1', username: 'admin' },
|
||||||
|
updatedByUser: { id: 'user-1', username: 'admin' },
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import type { PaginationResponse } from '../../../common/http/response';
|
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 { InvalidDateTimeError } from '../../../common/value-objects/date-time/invalid-date-time.error';
|
||||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||||
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||||
@@ -42,6 +46,8 @@ export type ListSalesPaymentsQuery = {
|
|||||||
readonly code?: string;
|
readonly code?: string;
|
||||||
readonly status?: string;
|
readonly status?: string;
|
||||||
readonly search?: string;
|
readonly search?: string;
|
||||||
|
readonly orderBy?: string;
|
||||||
|
readonly orderType?: string;
|
||||||
readonly page?: number;
|
readonly page?: number;
|
||||||
readonly limit?: number;
|
readonly limit?: number;
|
||||||
readonly offset?: number;
|
readonly offset?: number;
|
||||||
@@ -66,6 +72,8 @@ export class SalesPaymentsService {
|
|||||||
code: query.code,
|
code: query.code,
|
||||||
status: query.status,
|
status: query.status,
|
||||||
search: query.search,
|
search: query.search,
|
||||||
|
orderBy: query.orderBy,
|
||||||
|
orderType: query.orderType,
|
||||||
limit: page.limit,
|
limit: page.limit,
|
||||||
offset: page.offset,
|
offset: page.offset,
|
||||||
});
|
});
|
||||||
@@ -253,8 +261,8 @@ export class SalesPaymentsService {
|
|||||||
status: item.status.value,
|
status: item.status.value,
|
||||||
createdAt: item.createdAt.value,
|
createdAt: item.createdAt.value,
|
||||||
updatedAt: item.updatedAt.value,
|
updatedAt: item.updatedAt.value,
|
||||||
createdBy: item.createdBy,
|
createdBy: pickUserRelation(item.createdByUser),
|
||||||
updatedBy: item.updatedBy,
|
updatedBy: pickUserRelation(item.updatedByUser),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,7 +271,7 @@ export class SalesPaymentsService {
|
|||||||
...this.toListItem(item),
|
...this.toListItem(item),
|
||||||
invoices: item.invoices.map((line) => ({
|
invoices: item.invoices.map((line) => ({
|
||||||
id: line.id,
|
id: line.id,
|
||||||
invoiceId: line.invoiceId,
|
invoice: pickCodeRelation(line.invoice),
|
||||||
amount: line.amount.value,
|
amount: line.amount.value,
|
||||||
})),
|
})),
|
||||||
images: item.images.map((image) => ({
|
images: item.images.map((image) => ({
|
||||||
|
|||||||
@@ -15,7 +15,11 @@ import {
|
|||||||
Min,
|
Min,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { PaginationQueryDto } from '../../../../common/http/response';
|
import {
|
||||||
|
DefaultRelationDto,
|
||||||
|
PaginationQueryDto,
|
||||||
|
UserRelationDto,
|
||||||
|
} from '../../../../common/http/response';
|
||||||
import {
|
import {
|
||||||
DOCUMENT_ADDRESS_MAX_LENGTH,
|
DOCUMENT_ADDRESS_MAX_LENGTH,
|
||||||
DOCUMENT_CODE_MAX_LENGTH,
|
DOCUMENT_CODE_MAX_LENGTH,
|
||||||
@@ -269,14 +273,14 @@ export class SalesRequestDto {
|
|||||||
code!: string;
|
code!: string;
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
date!: number;
|
date!: number;
|
||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ type: DefaultRelationDto, nullable: true })
|
||||||
salesPersonId!: string;
|
salesPerson!: DefaultRelationDto | null;
|
||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ type: DefaultRelationDto, nullable: true })
|
||||||
branchId!: string;
|
branch!: DefaultRelationDto | null;
|
||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ type: DefaultRelationDto, nullable: true })
|
||||||
divisionId!: string;
|
division!: DefaultRelationDto | null;
|
||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ type: DefaultRelationDto, nullable: true })
|
||||||
customerId!: string;
|
customer!: DefaultRelationDto | null;
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
address!: string;
|
address!: string;
|
||||||
@ApiPropertyOptional({ nullable: true })
|
@ApiPropertyOptional({ nullable: true })
|
||||||
@@ -291,8 +295,8 @@ export class SalesRequestDto {
|
|||||||
createdAt!: number;
|
createdAt!: number;
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
updatedAt!: number;
|
updatedAt!: number;
|
||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ type: UserRelationDto })
|
||||||
createdBy!: string;
|
createdBy!: UserRelationDto;
|
||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ type: UserRelationDto })
|
||||||
updatedBy!: string;
|
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 { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||||
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||||
import { Status } from '../../../common/value-objects/status/status';
|
import { Status } from '../../../common/value-objects/status/status';
|
||||||
@@ -5,6 +9,7 @@ import { Status } from '../../../common/value-objects/status/status';
|
|||||||
export type SalesRequestLine = {
|
export type SalesRequestLine = {
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
readonly productId: string;
|
readonly productId: string;
|
||||||
|
readonly product: DefaultRelation | null;
|
||||||
readonly quantity: Decimal;
|
readonly quantity: Decimal;
|
||||||
readonly price: Decimal;
|
readonly price: Decimal;
|
||||||
};
|
};
|
||||||
@@ -34,6 +39,12 @@ export type SalesRequest = {
|
|||||||
readonly updatedAt: DateTime;
|
readonly updatedAt: DateTime;
|
||||||
readonly createdBy: string;
|
readonly createdBy: string;
|
||||||
readonly updatedBy: 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 = {
|
export type SalesRequestLineInput = {
|
||||||
@@ -88,6 +99,8 @@ export type ListSalesRequestsFilters = {
|
|||||||
readonly branchId?: string;
|
readonly branchId?: string;
|
||||||
readonly divisionId?: string;
|
readonly divisionId?: string;
|
||||||
readonly search?: string;
|
readonly search?: string;
|
||||||
|
readonly orderBy?: string;
|
||||||
|
readonly orderType?: string;
|
||||||
readonly limit: number;
|
readonly limit: number;
|
||||||
readonly offset: number;
|
readonly offset: number;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} 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 { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||||
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||||
import { Status } from '../../../common/value-objects/status/status';
|
import { Status } from '../../../common/value-objects/status/status';
|
||||||
@@ -29,6 +35,15 @@ import type {
|
|||||||
UpdateSalesRequestInput,
|
UpdateSalesRequestInput,
|
||||||
} from './sales-request';
|
} 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'>;
|
type QueryExecutor = Pick<DrizzleDB, 'select' | 'insert' | 'delete'>;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -50,11 +65,15 @@ export class SalesRequestsRepository {
|
|||||||
.select()
|
.select()
|
||||||
.from(salesRequests)
|
.from(salesRequests)
|
||||||
.where(where)
|
.where(where)
|
||||||
.orderBy(asc(salesRequests.code))
|
.orderBy(
|
||||||
|
...toOrderClauses(SALES_REQUEST_ORDER_COLUMNS, filters, [
|
||||||
|
{ column: 'code', type: 'ASC' },
|
||||||
|
]),
|
||||||
|
)
|
||||||
.limit(filters.limit)
|
.limit(filters.limit)
|
||||||
.offset(filters.offset);
|
.offset(filters.offset);
|
||||||
return {
|
return {
|
||||||
data: rows.map((row) => this.toDomain(row, [], [])),
|
data: await this.hydrate(rows.map((row) => this.toDomain(row, [], []))),
|
||||||
total: Number(totalRows[0]?.total ?? 0),
|
total: Number(totalRows[0]?.total ?? 0),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -71,7 +90,7 @@ export class SalesRequestsRepository {
|
|||||||
}
|
}
|
||||||
const products = await this.selectProducts(this.db, id);
|
const products = await this.selectProducts(this.db, id);
|
||||||
const images = await this.selectImages(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> {
|
async create(input: CreateSalesRequestInput): Promise<SalesRequest> {
|
||||||
@@ -96,7 +115,7 @@ export class SalesRequestsRepository {
|
|||||||
await this.replaceImages(tx, row.id, input.images ?? []);
|
await this.replaceImages(tx, row.id, input.images ?? []);
|
||||||
const products = await this.selectProducts(tx, row.id);
|
const products = await this.selectProducts(tx, row.id);
|
||||||
const images = await this.selectImages(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) {
|
} catch (error) {
|
||||||
this.rethrowConstraintViolation(error);
|
this.rethrowConstraintViolation(error);
|
||||||
@@ -158,7 +177,7 @@ export class SalesRequestsRepository {
|
|||||||
}
|
}
|
||||||
const products = await this.selectProducts(tx, id);
|
const products = await this.selectProducts(tx, id);
|
||||||
const images = await this.selectImages(tx, id);
|
const images = await this.selectImages(tx, id);
|
||||||
return this.toDomain(row, products, images);
|
return this.hydrateOne(row, products, images);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.rethrowConstraintViolation(error);
|
this.rethrowConstraintViolation(error);
|
||||||
@@ -186,7 +205,7 @@ export class SalesRequestsRepository {
|
|||||||
}
|
}
|
||||||
const products = await this.selectProducts(this.db, id);
|
const products = await this.selectProducts(this.db, id);
|
||||||
const images = await this.selectImages(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(
|
async bulkUpdateStatus(
|
||||||
@@ -374,6 +393,7 @@ export class SalesRequestsRepository {
|
|||||||
products: productRows.map((line) => ({
|
products: productRows.map((line) => ({
|
||||||
id: line.id,
|
id: line.id,
|
||||||
productId: line.productId,
|
productId: line.productId,
|
||||||
|
product: null,
|
||||||
quantity: Decimal.create(line.quantity),
|
quantity: Decimal.create(line.quantity),
|
||||||
price: Decimal.create(line.price),
|
price: Decimal.create(line.price),
|
||||||
})),
|
})),
|
||||||
@@ -387,9 +407,39 @@ export class SalesRequestsRepository {
|
|||||||
updatedAt: DateTime.fromUnixMs(row.updatedAt),
|
updatedAt: DateTime.fromUnixMs(row.updatedAt),
|
||||||
createdBy: row.createdBy,
|
createdBy: row.createdBy,
|
||||||
updatedBy: row.updatedBy,
|
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 {
|
private rethrowConstraintViolation(error: unknown): never {
|
||||||
if (error instanceof NotFoundException) {
|
if (error instanceof NotFoundException) {
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ describe('SalesRequestsService', () => {
|
|||||||
{
|
{
|
||||||
id: 'line-1',
|
id: 'line-1',
|
||||||
productId: 'prd-1',
|
productId: 'prd-1',
|
||||||
|
product: { id: 'prd-1', code: 'P1', name: 'Fuel' },
|
||||||
quantity: Decimal.create('2'),
|
quantity: Decimal.create('2'),
|
||||||
price: Decimal.create('12500'),
|
price: Decimal.create('12500'),
|
||||||
},
|
},
|
||||||
@@ -72,6 +73,12 @@ describe('SalesRequestsService', () => {
|
|||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
createdBy: 'user-1',
|
createdBy: 'user-1',
|
||||||
updatedBy: '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 = {
|
const createBody = {
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import type { PaginationResponse } from '../../../common/http/response';
|
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 { InvalidDateTimeError } from '../../../common/value-objects/date-time/invalid-date-time.error';
|
||||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||||
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||||
@@ -54,6 +59,8 @@ export type ListSalesRequestsQuery = {
|
|||||||
readonly branchId?: string;
|
readonly branchId?: string;
|
||||||
readonly divisionId?: string;
|
readonly divisionId?: string;
|
||||||
readonly search?: string;
|
readonly search?: string;
|
||||||
|
readonly orderBy?: string;
|
||||||
|
readonly orderType?: string;
|
||||||
readonly page?: number;
|
readonly page?: number;
|
||||||
readonly limit?: number;
|
readonly limit?: number;
|
||||||
readonly offset?: number;
|
readonly offset?: number;
|
||||||
@@ -93,6 +100,8 @@ export class SalesRequestsService {
|
|||||||
branchId: query.branchId,
|
branchId: query.branchId,
|
||||||
divisionId: query.divisionId,
|
divisionId: query.divisionId,
|
||||||
search: query.search,
|
search: query.search,
|
||||||
|
orderBy: query.orderBy,
|
||||||
|
orderType: query.orderType,
|
||||||
limit: page.limit,
|
limit: page.limit,
|
||||||
offset: page.offset,
|
offset: page.offset,
|
||||||
});
|
});
|
||||||
@@ -302,10 +311,10 @@ export class SalesRequestsService {
|
|||||||
id: item.id,
|
id: item.id,
|
||||||
code: item.code,
|
code: item.code,
|
||||||
date: item.date.value,
|
date: item.date.value,
|
||||||
salesPersonId: item.salesPersonId,
|
salesPerson: pickRelation(item.salesPerson, DEFAULT_RELATION_FIELDS),
|
||||||
branchId: item.branchId,
|
branch: pickRelation(item.branch, DEFAULT_RELATION_FIELDS),
|
||||||
divisionId: item.divisionId,
|
division: pickRelation(item.division, DEFAULT_RELATION_FIELDS),
|
||||||
customerId: item.customerId,
|
customer: pickRelation(item.customer, DEFAULT_RELATION_FIELDS),
|
||||||
address: item.address,
|
address: item.address,
|
||||||
latitude: item.latitude,
|
latitude: item.latitude,
|
||||||
longitude: item.longitude,
|
longitude: item.longitude,
|
||||||
@@ -313,8 +322,8 @@ export class SalesRequestsService {
|
|||||||
status: item.status.value,
|
status: item.status.value,
|
||||||
createdAt: item.createdAt.value,
|
createdAt: item.createdAt.value,
|
||||||
updatedAt: item.updatedAt.value,
|
updatedAt: item.updatedAt.value,
|
||||||
createdBy: item.createdBy,
|
createdBy: pickUserRelation(item.createdByUser),
|
||||||
updatedBy: item.updatedBy,
|
updatedBy: pickUserRelation(item.updatedByUser),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,7 +332,7 @@ export class SalesRequestsService {
|
|||||||
...this.toListItem(item),
|
...this.toListItem(item),
|
||||||
products: item.products.map((line) => ({
|
products: item.products.map((line) => ({
|
||||||
id: line.id,
|
id: line.id,
|
||||||
productId: line.productId,
|
product: pickRelation(line.product, DEFAULT_RELATION_FIELDS),
|
||||||
quantity: line.quantity.value,
|
quantity: line.quantity.value,
|
||||||
price: line.price.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,
|
||||||
|
};
|
||||||
@@ -53,6 +53,8 @@ export type ListUsersFilters = {
|
|||||||
readonly privilegeId?: string;
|
readonly privilegeId?: string;
|
||||||
readonly status?: string;
|
readonly status?: string;
|
||||||
readonly search?: string;
|
readonly search?: string;
|
||||||
|
readonly orderBy?: string;
|
||||||
|
readonly orderType?: string;
|
||||||
readonly limit: number;
|
readonly limit: number;
|
||||||
readonly offset: number;
|
readonly offset: number;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} 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 { alias } from 'drizzle-orm/pg-core';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { DateTime } from '../../common/value-objects/date-time/date-time';
|
import { DateTime } from '../../common/value-objects/date-time/date-time';
|
||||||
@@ -23,6 +24,14 @@ import type {
|
|||||||
User,
|
User,
|
||||||
} from './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 createdByUsers = alias(users, 'created_by_users');
|
||||||
const updatedByUsers = alias(users, 'updated_by_users');
|
const updatedByUsers = alias(users, 'updated_by_users');
|
||||||
|
|
||||||
@@ -57,7 +66,11 @@ export class UsersRepository {
|
|||||||
qb = this.extendListQuery(qb, filters);
|
qb = this.extendListQuery(qb, filters);
|
||||||
const rows = await qb
|
const rows = await qb
|
||||||
.where(where)
|
.where(where)
|
||||||
.orderBy(asc(users.username))
|
.orderBy(
|
||||||
|
...toOrderClauses(USER_ORDER_COLUMNS, filters, [
|
||||||
|
{ column: 'username', type: 'ASC' },
|
||||||
|
]),
|
||||||
|
)
|
||||||
.limit(filters.limit)
|
.limit(filters.limit)
|
||||||
.offset(filters.offset);
|
.offset(filters.offset);
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ describe('UsersService', () => {
|
|||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
createdBy: 'actor-1',
|
createdBy: 'actor-1',
|
||||||
updatedBy: 'actor-1',
|
updatedBy: 'actor-1',
|
||||||
|
createdByUser: { id: 'actor-1', username: 'actor' },
|
||||||
|
updatedByUser: { id: 'actor-1', username: 'actor' },
|
||||||
userId: null,
|
userId: null,
|
||||||
user: null,
|
user: null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ export type ListUsersQuery = {
|
|||||||
readonly privilegeId?: string;
|
readonly privilegeId?: string;
|
||||||
readonly status?: string;
|
readonly status?: string;
|
||||||
readonly search?: string;
|
readonly search?: string;
|
||||||
|
readonly orderBy?: string;
|
||||||
|
readonly orderType?: string;
|
||||||
readonly page?: number;
|
readonly page?: number;
|
||||||
readonly limit?: number;
|
readonly limit?: number;
|
||||||
readonly offset?: number;
|
readonly offset?: number;
|
||||||
@@ -69,6 +71,8 @@ export class UsersService {
|
|||||||
privilegeId: query.privilegeId,
|
privilegeId: query.privilegeId,
|
||||||
status: query.status,
|
status: query.status,
|
||||||
search: query.search,
|
search: query.search,
|
||||||
|
orderBy: query.orderBy,
|
||||||
|
orderType: query.orderType,
|
||||||
limit: page.limit,
|
limit: page.limit,
|
||||||
offset: page.offset,
|
offset: page.offset,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,10 +19,7 @@ export async function registerAndActivate(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const userId = (register.body as { id: string }).id;
|
const userId = (register.body as { id: string }).id;
|
||||||
await db
|
await db.update(users).set({ status: 'active' }).where(eq(users.id, userId));
|
||||||
.update(users)
|
|
||||||
.set({ status: 'active' })
|
|
||||||
.where(eq(users.id, userId));
|
|
||||||
const login = await request(app.getHttpServer())
|
const login = await request(app.getHttpServer())
|
||||||
.post('/auth/login')
|
.post('/auth/login')
|
||||||
.send({ username, password });
|
.send({ username, password });
|
||||||
|
|||||||
Reference in New Issue
Block a user