2 Commits
Author SHA1 Message Date
shancheas 0e73d14381 Add address fields to sales invoices and implement related validations
- Introduced new columns `address`, `latitude`, and `longitude` in the `sales_invoices` table to store location details.
- Updated the `SalesInvoicesService` to handle the new fields, including validation for address format and geographical coordinates.
- Enhanced the `SalesInvoiceDto` and related data transfer objects to include the new fields for API requests and responses.
- Added unit and e2e tests to ensure proper handling of the new fields and validate their integration within the sales invoice workflow.
- Created a new migration script to apply the database schema changes for the sales invoices.
2026-09-01 09:50:19 +07:00
shancheas 23028abd48 Implement foreign key violation handling in EmployeesRepository delete methods
- Enhanced the `delete` and `bulkDelete` methods in `EmployeesRepository` to handle foreign key violations by throwing a `ConflictException` with a descriptive message.
- Added unit tests to verify that foreign key violations are correctly mapped to `ConflictException` and that unknown errors are rethrown as expected.
- Refactored error handling in the `delete` methods to improve clarity and maintainability.
2026-09-01 08:57:31 +07:00
25 changed files with 501 additions and 44 deletions
@@ -0,0 +1,4 @@
ALTER TABLE "sales_invoices" ADD COLUMN "address" text DEFAULT '' NOT NULL;--> statement-breakpoint
ALTER TABLE "sales_invoices" ALTER COLUMN "address" DROP DEFAULT;--> statement-breakpoint
ALTER TABLE "sales_invoices" ADD COLUMN "latitude" double precision;--> statement-breakpoint
ALTER TABLE "sales_invoices" ADD COLUMN "longitude" double precision;
+7
View File
@@ -99,6 +99,13 @@
"when": 1787561000000,
"tag": "0013_reports",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1787562000000,
"tag": "0014_sales_invoice_location",
"breakpoints": true
}
]
}
+1
View File
@@ -47,3 +47,4 @@ export {
type OrderDefault,
type OrderType,
} from './order-clause';
export { parseQueryIdList } from './parse-query-id-list';
@@ -0,0 +1,20 @@
import { parseQueryIdList } from './parse-query-id-list';
describe('parseQueryIdList', () => {
it('splits comma-separated and array values', () => {
expect(parseQueryIdList('cus-1,cus-2')).toEqual(['cus-1', 'cus-2']);
expect(parseQueryIdList(['cus-1', 'cus-2'])).toEqual(['cus-1', 'cus-2']);
expect(parseQueryIdList(['cus-1,cus-2', 'cus-3'])).toEqual([
'cus-1',
'cus-2',
'cus-3',
]);
expect(parseQueryIdList('cus-1,cus-1,cus-2')).toEqual(['cus-1', 'cus-2']);
});
it('returns undefined for empty input', () => {
expect(parseQueryIdList(undefined)).toBeUndefined();
expect(parseQueryIdList('')).toBeUndefined();
expect(parseQueryIdList([])).toBeUndefined();
});
});
@@ -0,0 +1,11 @@
export function parseQueryIdList(value: unknown): string[] | undefined {
if (value == null || value === '') {
return undefined;
}
const items = Array.isArray(value) ? value : [value];
const ids = items
.flatMap((item) => String(item).split(','))
.map((item) => item.trim())
.filter(Boolean);
return ids.length > 0 ? [...new Set(ids)] : undefined;
}
+4
View File
@@ -1,5 +1,6 @@
import {
bigint,
doublePrecision,
index,
numeric,
pgTable,
@@ -44,6 +45,9 @@ export const salesInvoices = pgTable(
}),
packingSlipCode: varchar('packing_slip_code', { length: 32 }),
balance: numeric('balance', { precision: 18, scale: 4 }).notNull(),
address: text('address').notNull(),
latitude: doublePrecision('latitude'),
longitude: doublePrecision('longitude'),
notes: text('notes'),
...primaryEntityColumns(users),
},
@@ -220,6 +220,26 @@ describe('EmployeesRepository', () => {
);
});
it('delete maps foreign-key violations to ConflictException', async () => {
returning.mockRejectedValueOnce({ code: '23503' });
await expect(repository.delete('emp-1')).rejects.toMatchObject({
constructor: ConflictException,
message: 'Employee is referenced by other records',
});
returning.mockRejectedValueOnce({
cause: { code: '23503' },
});
await expect(repository.delete('emp-1')).rejects.toBeInstanceOf(
ConflictException,
);
});
it('delete rethrows unknown errors', async () => {
returning.mockRejectedValueOnce(new Error('db down'));
await expect(repository.delete('emp-1')).rejects.toThrow('db down');
});
it('bulkUpdateStatus and bulkDelete return 0 for empty ids', async () => {
await expect(
repository.bulkUpdateStatus([], Status.create('active'), 'user-1'),
@@ -240,6 +260,16 @@ describe('EmployeesRepository', () => {
await expect(repository.bulkDelete(['emp-1'])).resolves.toBe(1);
});
it('bulkDelete maps foreign-key violations to ConflictException', async () => {
returning.mockRejectedValueOnce({
cause: { code: '23503' },
});
await expect(repository.bulkDelete(['emp-1'])).rejects.toMatchObject({
constructor: ConflictException,
message: 'Employee is referenced by other records',
});
});
it('extendListQuery is a passthrough hook', () => {
const qb = { join: true };
expect(repository.extendListQuery(qb, { limit: 10, offset: 0 })).toBe(qb);
@@ -221,12 +221,16 @@ export class EmployeesRepository {
}
async delete(id: string): Promise<void> {
const deleted = await this.db
.delete(employees)
.where(eq(employees.id, id))
.returning({ id: employees.id });
if (deleted.length === 0) {
throw new NotFoundException('Employee not found');
try {
const deleted = await this.db
.delete(employees)
.where(eq(employees.id, id))
.returning({ id: employees.id });
if (deleted.length === 0) {
throw new NotFoundException('Employee not found');
}
} catch (error) {
this.rethrowForeignKeyViolation(error);
}
}
@@ -234,11 +238,15 @@ export class EmployeesRepository {
if (ids.length === 0) {
return 0;
}
const deleted = await this.db
.delete(employees)
.where(inArray(employees.id, ids))
.returning({ id: employees.id });
return deleted.length;
try {
const deleted = await this.db
.delete(employees)
.where(inArray(employees.id, ids))
.returning({ id: employees.id });
return deleted.length;
} catch (error) {
this.rethrowForeignKeyViolation(error);
}
}
private buildListWhere(filters: ListEmployeesFilters): SQL | undefined {
@@ -363,6 +371,14 @@ export class EmployeesRepository {
throw error;
}
private rethrowForeignKeyViolation(error: unknown): never {
const err = this.unwrapDbError(error);
if (err.code === '23503') {
throw new ConflictException('Employee is referenced by other records');
}
throw error;
}
private unwrapDbError(error: unknown): {
code?: string;
constraint?: string;
+94 -5
View File
@@ -16,6 +16,37 @@ import type { Plan } from './plan';
import { PlansRepository } from './plans.repository';
import { PlansService } from './plans.service';
const MS_PER_DAY = 86_400_000;
function todayYmd(): string {
return DateTime.fromUnixMs(Math.trunc(Date.now()))
.startOfDay()
.format()
.slice(0, 10);
}
function nextWeekdayYmd(weekday: string): string {
const start = DateTime.fromUnixMs(Math.trunc(Date.now())).startOfDay();
for (let offset = 0; offset < 8; offset += 1) {
const day = DateTime.fromUnixMs(
start.value + offset * MS_PER_DAY,
).startOfDay();
if (day.weekdayName() === weekday) {
return day.format().slice(0, 10);
}
}
return todayYmd();
}
function addDaysYmd(date: string, days: number): string {
return DateTime.fromUnixMs(
DateTime.create(date).startOfDay().value + days * MS_PER_DAY,
)
.startOfDay()
.format()
.slice(0, 10);
}
describe('PlansService', () => {
let service: PlansService;
let plansRepository: jest.Mocked<
@@ -170,7 +201,7 @@ describe('PlansService', () => {
service.create({
employeeId: 'emp-1',
purpose: 'sales',
date: '2026-01-05',
date: todayYmd(),
startBranchId: 'br-1',
endBranchId: 'br-2',
customerIds: ['cus-1'],
@@ -180,15 +211,73 @@ describe('PlansService', () => {
).rejects.toBeInstanceOf(BadRequestException);
});
it('rejects a plan date in the past', async () => {
await expect(
service.create({
employeeId: 'emp-1',
purpose: 'sales',
date: '2020-01-01',
startBranchId: 'br-1',
endBranchId: 'br-2',
customerIds: ['cus-1'],
userId: 'user-1',
}),
).rejects.toMatchObject({ message: 'Date cannot be in the past' });
});
it('rejects invoices that do not belong to selected customers', async () => {
salesInvoicesService.findById.mockResolvedValue({
id: 'inv-1',
customer: { id: 'cus-2', code: 'C2', name: 'Beta' },
});
await expect(
service.create({
employeeId: 'emp-1',
purpose: 'sales',
date: todayYmd(),
startBranchId: 'br-1',
endBranchId: 'br-2',
customerIds: ['cus-1'],
invoiceIds: ['inv-1'],
userId: 'user-1',
}),
).rejects.toMatchObject({
message: 'Invoice does not belong to a selected customer',
});
});
it('keeps an existing past date when the date is unchanged', async () => {
plansRepository.findById.mockResolvedValue(plan);
plansRepository.update.mockResolvedValue(plan);
await service.update(
'pln-1',
{ date: '2026-01-05', userId: 'user-1' },
user,
);
expect(plansRepository.update).toHaveBeenCalled();
});
it('rejects changing a plan date into the past', async () => {
plansRepository.findById.mockResolvedValue({
...plan,
date: DateTime.create(todayYmd()),
});
await expect(
service.update('pln-1', { date: '2020-01-01', userId: 'user-1' }, user),
).rejects.toMatchObject({ message: 'Date cannot be in the past' });
});
it('generate copies a weekday and skips an existing plan', async () => {
const from = nextWeekdayYmd('monday');
const to = addDaysYmd(from, 7);
plansRepository.findLiveByKey
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(plan);
const result = await service.generate({
employeeId: 'emp-1',
purpose: 'sales',
from: '2026-01-05',
to: '2026-01-12',
from,
to,
userId: 'user-1',
});
expect(result.created).toBe(1);
@@ -205,8 +294,8 @@ describe('PlansService', () => {
service.generate({
employeeId: 'emp-1',
purpose: 'sales',
from: '2026-01-05',
to: '2026-01-05',
from: todayYmd(),
to: todayYmd(),
userId: 'user-1',
}),
).rejects.toBeInstanceOf(BadRequestException);
+44 -12
View File
@@ -173,6 +173,9 @@ export class PlansService {
const date = input.date
? this.assertDate(input.date).startOfDay()
: existing.date;
if (input.date && date.value !== existing.date.value) {
this.assertNotInThePast(date);
}
await this.employeesService.findById(employeeId);
await this.assertUnique(employeeId, purpose, date.value, id);
const startBranchId = input.startBranchId ?? existing.startBranchId;
@@ -189,12 +192,8 @@ export class PlansService {
input.invoiceIds ?? [...existing.invoiceIds],
input.packingSlipIds ?? [...existing.packingSlipIds],
);
if (input.invoiceIds) {
await this.assertInvoices(input.invoiceIds);
}
if (input.packingSlipIds) {
await this.assertPackingSlips(input.packingSlipIds);
}
await this.assertInvoices(attachments.invoiceIds, customerIds);
await this.assertPackingSlips(attachments.packingSlipIds, customerIds);
const updated = await this.plansRepository.update(id, {
employeeId,
purpose,
@@ -278,6 +277,8 @@ export class PlansService {
if (to.value < from.value) {
throw new BadRequestException('Invalid date range');
}
this.assertNotInThePast(from);
this.assertNotInThePast(to);
if (from.value < epoch.startOfDay().value) {
throw new BadRequestException('Date is before the cycle start date');
}
@@ -446,6 +447,7 @@ export class PlansService {
}) {
const purpose = this.assertPurpose(input.purpose);
const date = this.assertDate(input.date).startOfDay();
this.assertNotInThePast(date);
await this.employeesService.findById(input.employeeId);
await this.assertUnique(input.employeeId, purpose, date.value);
const geometry = await this.buildGeometry(
@@ -458,8 +460,11 @@ export class PlansService {
input.invoiceIds ?? [],
input.packingSlipIds ?? [],
);
await this.assertInvoices(attachments.invoiceIds);
await this.assertPackingSlips(attachments.packingSlipIds);
await this.assertInvoices(attachments.invoiceIds, input.customerIds);
await this.assertPackingSlips(
attachments.packingSlipIds,
input.customerIds,
);
return {
employeeId: input.employeeId,
purpose,
@@ -544,15 +549,35 @@ export class PlansService {
await this.salesInvoicesService.markDraftsProcessed(newlyAttached, userId);
}
private async assertInvoices(ids: readonly string[]): Promise<void> {
private async assertInvoices(
ids: readonly string[],
customerIds: readonly string[],
): Promise<void> {
const allowed = new Set(customerIds);
for (const id of ids) {
await this.salesInvoicesService.findById(id);
const invoice = await this.salesInvoicesService.findById(id);
const customerId = invoice.customer?.id;
if (!customerId || !allowed.has(customerId)) {
throw new BadRequestException(
'Invoice does not belong to a selected customer',
);
}
}
}
private async assertPackingSlips(ids: readonly string[]): Promise<void> {
private async assertPackingSlips(
ids: readonly string[],
customerIds: readonly string[],
): Promise<void> {
const allowed = new Set(customerIds);
for (const id of ids) {
await this.packingSlipsService.findById(id);
const packingSlip = await this.packingSlipsService.findById(id);
const customerId = packingSlip.customer?.id;
if (!customerId || !allowed.has(customerId)) {
throw new BadRequestException(
'Packing slip does not belong to a selected customer',
);
}
}
}
@@ -649,6 +674,13 @@ export class PlansService {
}
}
private assertNotInThePast(date: DateTime): void {
const today = DateTime.fromUnixMs(Math.trunc(Date.now())).startOfDay();
if (date.startOfDay().value < today.value) {
throw new BadRequestException('Date cannot be in the past');
}
}
private assertStatus(raw: string): Status {
try {
return Status.create(raw);
@@ -15,7 +15,9 @@ describe('parseGroupNamesQuery', () => {
it('reads array values', () => {
expect(
parseGroupNamesQuery({ groupNames: ['sales_report', 'logistics_report'] }),
parseGroupNamesQuery({
groupNames: ['sales_report', 'logistics_report'],
}),
).toEqual(['sales_report', 'logistics_report']);
});
});
@@ -1,10 +1,6 @@
export function parseGroupNamesQuery(
query: Record<string, unknown>,
): string[] {
export function parseGroupNamesQuery(query: Record<string, unknown>): string[] {
const raw =
query.groupNames ??
query['groupNames[]'] ??
query['groupNames[0]'];
query.groupNames ?? query['groupNames[]'] ?? query['groupNames[0]'];
if (raw === undefined || raw === null || raw === '') {
return [];
@@ -1,5 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { Transform, Type } from 'class-transformer';
import {
ArrayNotEmpty,
IsArray,
@@ -19,6 +19,7 @@ import {
CodeRelationDto,
DefaultRelationDto,
PaginationQueryDto,
parseQueryIdList,
UserRelationDto,
} from '../../../../common/http/response';
import {
@@ -213,6 +214,13 @@ export class ListPackingSlipsQueryDto extends PaginationQueryDto {
@IsUUID('4')
customerId?: string;
@ApiPropertyOptional({ type: [String], format: 'uuid' })
@IsOptional()
@Transform(({ value }) => parseQueryIdList(value))
@IsArray()
@IsUUID('4', { each: true })
customerIds?: string[];
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID('4')
@@ -77,6 +77,7 @@ export type ListPackingSlipsFilters = {
readonly code?: string;
readonly status?: string;
readonly customerId?: string;
readonly customerIds?: readonly string[];
readonly salesOrderId?: string;
readonly search?: string;
readonly orderBy?: string;
@@ -4,7 +4,7 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { and, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm';
import { and, count, eq, ilike, inArray, or, SQL, sql } from 'drizzle-orm';
import { toOrderClauses } from '../../../common/http/response';
import {
catalogRelationFromMap,
@@ -294,7 +294,16 @@ export class PackingSlipsRepository {
if (filters.status) {
parts.push(eq(packingSlips.status, filters.status));
}
if (filters.customerId) {
const scopedCustomerIds = filters.customerIds;
if (scopedCustomerIds) {
if (scopedCustomerIds.length === 0) {
parts.push(sql`false`);
} else if (scopedCustomerIds.length === 1) {
parts.push(eq(packingSlips.customerId, scopedCustomerIds[0]));
} else {
parts.push(inArray(packingSlips.customerId, [...scopedCustomerIds]));
}
} else if (filters.customerId) {
parts.push(eq(packingSlips.customerId, filters.customerId));
}
if (filters.salesOrderId) {
@@ -51,6 +51,7 @@ export type ListPackingSlipsQuery = {
readonly code?: string;
readonly status?: string;
readonly customerId?: string;
readonly customerIds?: readonly string[];
readonly salesOrderId?: string;
readonly search?: string;
readonly orderBy?: string;
@@ -84,6 +85,7 @@ export class PackingSlipsService {
code: query.code,
status: query.status,
customerId: query.customerId,
customerIds: query.customerIds,
salesOrderId: query.salesOrderId,
search: query.search,
orderBy: query.orderBy,
@@ -6,20 +6,25 @@ import {
IsBoolean,
IsIn,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
IsUUID,
Matches,
Max,
MaxLength,
Min,
ValidateNested,
} from 'class-validator';
import {
CodeRelationDto,
DefaultRelationDto,
PaginationQueryDto,
parseQueryIdList,
UserRelationDto,
} from '../../../../common/http/response';
import {
DOCUMENT_ADDRESS_MAX_LENGTH,
DOCUMENT_CODE_MAX_LENGTH,
DOCUMENT_CODE_PATTERN,
DOCUMENT_NOTES_MAX_LENGTH,
@@ -85,6 +90,26 @@ export class CreateSalesInvoiceDto {
@IsUUID('4')
customerId?: string;
@ApiPropertyOptional({ example: 'Jl Sudirman 1' })
@IsOptional()
@IsString()
@MaxLength(DOCUMENT_ADDRESS_MAX_LENGTH)
address?: string;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(-90)
@Max(90)
latitude?: number;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
@Min(-180)
@Max(180)
longitude?: number;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@@ -147,6 +172,22 @@ export class UpdateSalesInvoiceDto {
@IsUUID('4')
customerId?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(DOCUMENT_ADDRESS_MAX_LENGTH)
address?: string;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@IsNumber()
latitude?: number | null;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@IsNumber()
longitude?: number | null;
@ApiPropertyOptional({ nullable: true })
@IsOptional()
@IsString()
@@ -202,6 +243,13 @@ export class ListSalesInvoicesQueryDto extends PaginationQueryDto {
@IsUUID('4')
customerId?: string;
@ApiPropertyOptional({ type: [String], format: 'uuid' })
@IsOptional()
@Transform(({ value }) => parseQueryIdList(value))
@IsArray()
@IsUUID('4', { each: true })
customerIds?: string[];
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID('4')
@@ -258,6 +306,12 @@ export class SalesInvoiceDto {
division!: DefaultRelationDto | null;
@ApiProperty({ type: DefaultRelationDto, nullable: true })
customer!: DefaultRelationDto | null;
@ApiProperty()
address!: string;
@ApiPropertyOptional({ nullable: true })
latitude!: number | null;
@ApiPropertyOptional({ nullable: true })
longitude!: number | null;
@ApiPropertyOptional({ nullable: true })
notes!: string | null;
@ApiProperty()
@@ -27,6 +27,9 @@ export type SalesInvoice = {
readonly branchId: string;
readonly divisionId: string;
readonly customerId: string;
readonly address: string;
readonly latitude: number | null;
readonly longitude: number | null;
readonly notes: string | null;
readonly balance: Decimal;
readonly products: readonly SalesInvoiceLine[];
@@ -62,6 +65,9 @@ export type CreateSalesInvoiceInput = {
readonly branchId: string;
readonly divisionId: string;
readonly customerId: string;
readonly address: string;
readonly latitude?: number | null;
readonly longitude?: number | null;
readonly notes?: string | null;
readonly products: readonly SalesInvoiceLineInput[];
readonly status?: Status;
@@ -79,6 +85,9 @@ export type UpdateSalesInvoiceInput = {
readonly branchId?: string;
readonly divisionId?: string;
readonly customerId?: string;
readonly address?: string;
readonly latitude?: number | null;
readonly longitude?: number | null;
readonly notes?: string | null;
readonly products?: readonly SalesInvoiceLineInput[];
readonly userId: string;
@@ -88,6 +97,7 @@ export type ListSalesInvoicesFilters = {
readonly code?: string;
readonly status?: string;
readonly customerId?: string;
readonly customerIds?: readonly string[];
readonly salesPersonId?: string;
readonly branchId?: string;
readonly divisionId?: string;
@@ -136,6 +136,9 @@ export class SalesInvoicesWriteController {
branchId: dto.branchId,
divisionId: dto.divisionId,
customerId: dto.customerId,
address: dto.address,
latitude: dto.latitude,
longitude: dto.longitude,
notes: dto.notes,
products: dto.products,
status: dto.status,
@@ -179,6 +182,9 @@ export class SalesInvoicesWriteController {
branchId: dto.branchId,
divisionId: dto.divisionId,
customerId: dto.customerId,
address: dto.address,
latitude: dto.latitude,
longitude: dto.longitude,
notes: dto.notes,
products: dto.products,
userId,
@@ -14,6 +14,7 @@ import {
inArray,
or,
SQL,
sql,
sum,
} from 'drizzle-orm';
import { toOrderClauses } from '../../../common/http/response';
@@ -211,6 +212,13 @@ export class SalesInvoicesRepository {
branchId: input.branchId ?? existing.branchId,
divisionId: input.divisionId ?? existing.divisionId,
customerId: input.customerId ?? existing.customerId,
address: input.address ?? existing.address,
latitude:
input.latitude !== undefined ? input.latitude : existing.latitude,
longitude:
input.longitude !== undefined
? input.longitude
: existing.longitude,
notes: input.notes !== undefined ? input.notes : existing.notes,
updatedAt: now.value,
updatedBy: input.userId,
@@ -380,7 +388,16 @@ export class SalesInvoicesRepository {
if (filters.status) {
parts.push(eq(salesInvoices.status, filters.status));
}
if (filters.customerId) {
const scopedCustomerIds = filters.customerIds;
if (scopedCustomerIds) {
if (scopedCustomerIds.length === 0) {
parts.push(sql`false`);
} else if (scopedCustomerIds.length === 1) {
parts.push(eq(salesInvoices.customerId, scopedCustomerIds[0]));
} else {
parts.push(inArray(salesInvoices.customerId, [...scopedCustomerIds]));
}
} else if (filters.customerId) {
parts.push(eq(salesInvoices.customerId, filters.customerId));
}
if (filters.salesPersonId) {
@@ -401,6 +418,7 @@ export class SalesInvoicesRepository {
if (filters.search) {
const search = or(
ilike(salesInvoices.code, `%${filters.search}%`),
ilike(salesInvoices.address, `%${filters.search}%`),
ilike(salesInvoices.notes, `%${filters.search}%`),
);
if (search) {
@@ -449,6 +467,9 @@ export class SalesInvoicesRepository {
branchId: input.branchId,
divisionId: input.divisionId,
customerId: input.customerId,
address: input.address,
latitude: input.latitude ?? null,
longitude: input.longitude ?? null,
balance: '0.0000',
notes: input.notes ?? null,
status: status.value,
@@ -475,6 +496,9 @@ export class SalesInvoicesRepository {
branchId: row.branchId,
divisionId: row.divisionId,
customerId: row.customerId,
address: row.address,
latitude: row.latitude,
longitude: row.longitude,
notes: row.notes,
balance: Decimal.create(row.balance),
products: productRows.map((line) => ({
@@ -68,6 +68,9 @@ describe('SalesInvoicesService', () => {
branchId: 'br-1',
divisionId: 'div-1',
customerId: 'cus-1',
address: 'Jl Sudirman 1',
latitude: -6.2,
longitude: 106.8,
notes: null,
balance: Decimal.create('25000'),
products: [
@@ -100,6 +103,9 @@ describe('SalesInvoicesService', () => {
branchId: 'br-1',
divisionId: 'div-1',
customerId: 'cus-1',
address: 'Jl Sudirman 1',
latitude: -6.2,
longitude: 106.8,
products: [{ productId: 'prd-1', quantity: '2' }],
userId: 'user-1',
};
@@ -138,6 +144,9 @@ describe('SalesInvoicesService', () => {
const [arg] = repository.create.mock.calls[0];
expect(arg.products[0]?.price.value).toBe('12500.0000');
expect(arg.status?.value).toBe('draft');
expect(arg.address).toBe('Jl Sudirman 1');
expect(arg.latitude).toBe(-6.2);
expect(arg.longitude).toBe(106.8);
});
it('create copies header and lines from a sales order', async () => {
@@ -149,6 +158,9 @@ describe('SalesInvoicesService', () => {
branch: { id: 'br-1', code: 'B1', name: 'Jakarta' },
division: { id: 'div-1', code: 'FIN', name: 'Finance' },
customer: { id: 'cus-1', code: 'C1', name: 'Acme' },
address: 'Jl Thamrin 9',
latitude: -6.1,
longitude: 106.9,
notes: 'from order',
products: [
{
@@ -169,9 +181,18 @@ describe('SalesInvoicesService', () => {
const [copied] = repository.create.mock.calls[0];
expect(copied.salesOrderCode).toBe('SO-1');
expect(copied.salesPersonId).toBe('emp-1');
expect(copied.address).toBe('Jl Thamrin 9');
expect(copied.latitude).toBe(-6.1);
expect(copied.longitude).toBe(106.9);
expect(copied.products).toHaveLength(1);
});
it('create rejects an invalid address', async () => {
await expect(
service.create({ ...createBody, address: '' }),
).rejects.toBeInstanceOf(BadRequestException);
});
it('create rejects a missing product line list without a parent', async () => {
await expect(
service.create({ ...createBody, products: [] }),
@@ -28,6 +28,9 @@ import { SalesOrdersService } from '../sales-orders/sales-orders.service';
import {
isValidDocumentCode,
isValidDocumentNotes,
isValidDocumentAddress,
isValidLatitude,
isValidLongitude,
isAllowedStatusTransition,
parseCsvRecord,
SALES_INVOICE_STATUSES,
@@ -51,6 +54,7 @@ export type ListSalesInvoicesQuery = {
readonly code?: string;
readonly status?: string;
readonly customerId?: string;
readonly customerIds?: readonly string[];
readonly salesPersonId?: string;
readonly branchId?: string;
readonly divisionId?: string;
@@ -71,6 +75,7 @@ const CSV_REQUIRED_HEADERS = [
'branchId',
'divisionId',
'customerId',
'address',
] as const;
@Injectable()
@@ -98,6 +103,7 @@ export class SalesInvoicesService {
code: query.code,
status: query.status,
customerId: query.customerId,
customerIds: query.customerIds,
salesPersonId: query.salesPersonId,
branchId: query.branchId,
divisionId: query.divisionId,
@@ -135,6 +141,9 @@ export class SalesInvoicesService {
branchId?: string;
divisionId?: string;
customerId?: string;
address?: string;
latitude?: number | null;
longitude?: number | null;
notes?: string | null;
products?: SalesLineBody[];
status?: string;
@@ -159,6 +168,9 @@ export class SalesInvoicesService {
branchId?: string;
divisionId?: string;
customerId?: string;
address?: string;
latitude?: number | null;
longitude?: number | null;
notes?: string | null;
products?: SalesLineBody[];
status?: unknown;
@@ -194,6 +206,18 @@ export class SalesInvoicesService {
branchId: input.branchId,
divisionId: input.divisionId,
customerId: input.customerId,
address:
input.address !== undefined
? this.assertAddress(input.address)
: undefined,
latitude:
input.latitude !== undefined
? this.assertLatitude(input.latitude)
: undefined,
longitude:
input.longitude !== undefined
? this.assertLongitude(input.longitude)
: undefined,
notes:
input.notes !== undefined ? this.assertNotes(input.notes) : undefined,
products:
@@ -342,6 +366,15 @@ export class SalesInvoicesService {
branchId: cols[idx('branchid')] ?? '',
divisionId: cols[idx('divisionid')] ?? '',
customerId: cols[idx('customerid')] ?? '',
address: cols[idx('address')] ?? '',
latitude:
idx('latitude') >= 0 && cols[idx('latitude')]
? Number(cols[idx('latitude')])
: null,
longitude:
idx('longitude') >= 0 && cols[idx('longitude')]
? Number(cols[idx('longitude')])
: null,
notes: idx('notes') >= 0 ? cols[idx('notes')] : null,
products: [],
status: idx('status') >= 0 ? cols[idx('status')] : undefined,
@@ -375,6 +408,9 @@ export class SalesInvoicesService {
branch: pickRelation(item.branch, DEFAULT_RELATION_FIELDS),
division: pickRelation(item.division, DEFAULT_RELATION_FIELDS),
customer: pickRelation(item.customer, DEFAULT_RELATION_FIELDS),
address: item.address,
latitude: item.latitude,
longitude: item.longitude,
notes: item.notes,
balance: item.balance.value,
status: item.status.value,
@@ -406,6 +442,9 @@ export class SalesInvoicesService {
branchId?: string;
divisionId?: string;
customerId?: string;
address?: string;
latitude?: number | null;
longitude?: number | null;
notes?: string | null;
products?: SalesLineBody[];
status?: string;
@@ -416,6 +455,9 @@ export class SalesInvoicesService {
let branchId = input.branchId ?? '';
let divisionId = input.divisionId ?? '';
let customerId = input.customerId ?? '';
let address = input.address;
let latitude = input.latitude;
let longitude = input.longitude;
let notes = input.notes;
let products = input.products;
let salesOrderCode: string | null = null;
@@ -428,6 +470,9 @@ export class SalesInvoicesService {
branchId = branchId || order.branch?.id || '';
divisionId = divisionId || order.division?.id || '';
customerId = customerId || order.customer?.id || '';
address = address !== undefined ? address : order.address;
latitude = latitude !== undefined ? latitude : order.latitude;
longitude = longitude !== undefined ? longitude : order.longitude;
notes = notes !== undefined ? notes : order.notes;
products =
products ??
@@ -442,6 +487,9 @@ export class SalesInvoicesService {
packingSlipCode = slip.code;
date = date || DateTime.fromUnixMs(slip.date).format();
customerId = customerId || slip.customer?.id || '';
address = address !== undefined ? address : slip.address;
latitude = latitude !== undefined ? latitude : slip.latitude;
longitude = longitude !== undefined ? longitude : slip.longitude;
notes = notes !== undefined ? notes : slip.notes;
products =
input.products ??
@@ -463,6 +511,9 @@ export class SalesInvoicesService {
branchId,
divisionId,
customerId,
address: address ?? '',
latitude,
longitude,
notes: notes ?? null,
products: products ?? [],
};
@@ -479,6 +530,9 @@ export class SalesInvoicesService {
branchId: string;
divisionId: string;
customerId: string;
address: string;
latitude?: number | null;
longitude?: number | null;
notes?: string | null;
products: SalesLineBody[];
status?: string;
@@ -498,6 +552,9 @@ export class SalesInvoicesService {
branchId: input.branchId,
divisionId: input.divisionId,
customerId: input.customerId,
address: this.assertAddress(input.address),
latitude: this.assertLatitude(input.latitude ?? null),
longitude: this.assertLongitude(input.longitude ?? null),
notes: this.assertNotes(input.notes ?? null),
products: await this.assertLines(input.products),
status: input.status
@@ -570,6 +627,14 @@ export class SalesInvoicesService {
}
}
private assertAddress(raw: string): string {
const address = raw.trim();
if (!isValidDocumentAddress(address)) {
throw new BadRequestException('Invalid address');
}
return address;
}
private assertNotes(raw: string | null): string | null {
if (raw === null || raw === '') {
return null;
@@ -580,6 +645,26 @@ export class SalesInvoicesService {
return raw;
}
private assertLatitude(raw: number | null): number | null {
if (raw === null) {
return null;
}
if (!isValidLatitude(raw)) {
throw new BadRequestException('Invalid latitude');
}
return raw;
}
private assertLongitude(raw: number | null): number | null {
if (raw === null) {
return null;
}
if (!isValidLongitude(raw)) {
throw new BadRequestException('Invalid longitude');
}
return raw;
}
private assertStatus(raw: string): Status {
try {
return Status.create(raw, SALES_INVOICE_STATUSES);
+23 -5
View File
@@ -14,6 +14,23 @@ import {
} from '../src/database/schema';
import { PRIVILEGE_ACTIONS } from '../src/modules/privileges/privilege-action';
import { registerAndActivate } from './helpers/activate-user';
import { DateTime } from '../src/common/value-objects/date-time/date-time';
const MS_PER_DAY = 86_400_000;
function nextMondayRange(): { from: string; to: string } {
const start = DateTime.fromUnixMs(Math.trunc(Date.now())).startOfDay();
const mondays: string[] = [];
for (let offset = 0; offset < 21 && mondays.length < 2; offset += 1) {
const day = DateTime.fromUnixMs(
start.value + offset * MS_PER_DAY,
).startOfDay();
if (day.weekdayName() === 'monday') {
mondays.push(day.format().slice(0, 10));
}
}
return { from: mondays[0], to: mondays[1] };
}
describe('Plans (e2e)', () => {
let app: INestApplication<App>;
@@ -203,14 +220,15 @@ describe('Plans (e2e)', () => {
});
it('generates Monday plans, skips existing, and supports D-Day destination edits', async () => {
const { from, to } = nextMondayRange();
const generated = await request(app.getHttpServer())
.post('/plans/generate')
.set('Authorization', `Bearer ${adminAccessToken}`)
.send({
employeeId,
purpose: 'sales',
from: '2026-01-05',
to: '2026-01-12',
from,
to,
})
.expect(201);
expect((generated.body as { created: number }).created).toBe(2);
@@ -222,15 +240,15 @@ describe('Plans (e2e)', () => {
.send({
employeeId,
purpose: 'sales',
from: '2026-01-05',
to: '2026-01-12',
from,
to,
})
.expect(201);
expect((again.body as { created: number }).created).toBe(0);
const list = await request(app.getHttpServer())
.get('/plans')
.query({ employeeId, purpose: 'sales', date: '2026-01-05' })
.query({ employeeId, purpose: 'sales', date: from })
.set('Authorization', `Bearer ${adminAccessToken}`)
.expect(200);
const planId = (list.body.data as Array<{ id: string }>)[0].id;
+6
View File
@@ -165,12 +165,18 @@ describe('Sales invoices (e2e)', () => {
branchId,
divisionId,
customerId,
address: 'Jl Sudirman 1',
latitude: -6.2,
longitude: 106.8,
products: [{ productId, quantity: '2' }],
})
.expect(201);
expect(created.body).toMatchObject({
status: 'draft',
balance: '25000.0000',
address: 'Jl Sudirman 1',
latitude: -6.2,
longitude: 106.8,
});
expect((created.body as { code: string }).code).toMatch(/^SI-/);
const id = (created.body as { id: string }).id;
+1
View File
@@ -138,6 +138,7 @@ describe('Sales payments (e2e)', () => {
branchId: (branch.body as { id: string }).id,
divisionId: (division.body as { id: string }).id,
customerId: (customer.body as { id: string }).id,
address: 'Jl Sudirman 1',
products: [
{
productId: (product.body as { id: string }).id,