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.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user