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:
@@ -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;
|
||||||
@@ -99,6 +99,13 @@
|
|||||||
"when": 1787561000000,
|
"when": 1787561000000,
|
||||||
"tag": "0013_reports",
|
"tag": "0013_reports",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 14,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787562000000,
|
||||||
|
"tag": "0014_sales_invoice_location",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -47,3 +47,4 @@ export {
|
|||||||
type OrderDefault,
|
type OrderDefault,
|
||||||
type OrderType,
|
type OrderType,
|
||||||
} from './order-clause';
|
} 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 {
|
import {
|
||||||
bigint,
|
bigint,
|
||||||
|
doublePrecision,
|
||||||
index,
|
index,
|
||||||
numeric,
|
numeric,
|
||||||
pgTable,
|
pgTable,
|
||||||
@@ -44,6 +45,9 @@ export const salesInvoices = pgTable(
|
|||||||
}),
|
}),
|
||||||
packingSlipCode: varchar('packing_slip_code', { length: 32 }),
|
packingSlipCode: varchar('packing_slip_code', { length: 32 }),
|
||||||
balance: numeric('balance', { precision: 18, scale: 4 }).notNull(),
|
balance: numeric('balance', { precision: 18, scale: 4 }).notNull(),
|
||||||
|
address: text('address').notNull(),
|
||||||
|
latitude: doublePrecision('latitude'),
|
||||||
|
longitude: doublePrecision('longitude'),
|
||||||
notes: text('notes'),
|
notes: text('notes'),
|
||||||
...primaryEntityColumns(users),
|
...primaryEntityColumns(users),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,6 +16,37 @@ import type { Plan } from './plan';
|
|||||||
import { PlansRepository } from './plans.repository';
|
import { PlansRepository } from './plans.repository';
|
||||||
import { PlansService } from './plans.service';
|
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', () => {
|
describe('PlansService', () => {
|
||||||
let service: PlansService;
|
let service: PlansService;
|
||||||
let plansRepository: jest.Mocked<
|
let plansRepository: jest.Mocked<
|
||||||
@@ -170,7 +201,7 @@ describe('PlansService', () => {
|
|||||||
service.create({
|
service.create({
|
||||||
employeeId: 'emp-1',
|
employeeId: 'emp-1',
|
||||||
purpose: 'sales',
|
purpose: 'sales',
|
||||||
date: '2026-01-05',
|
date: todayYmd(),
|
||||||
startBranchId: 'br-1',
|
startBranchId: 'br-1',
|
||||||
endBranchId: 'br-2',
|
endBranchId: 'br-2',
|
||||||
customerIds: ['cus-1'],
|
customerIds: ['cus-1'],
|
||||||
@@ -180,15 +211,73 @@ describe('PlansService', () => {
|
|||||||
).rejects.toBeInstanceOf(BadRequestException);
|
).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 () => {
|
it('generate copies a weekday and skips an existing plan', async () => {
|
||||||
|
const from = nextWeekdayYmd('monday');
|
||||||
|
const to = addDaysYmd(from, 7);
|
||||||
plansRepository.findLiveByKey
|
plansRepository.findLiveByKey
|
||||||
.mockResolvedValueOnce(null)
|
.mockResolvedValueOnce(null)
|
||||||
.mockResolvedValueOnce(plan);
|
.mockResolvedValueOnce(plan);
|
||||||
const result = await service.generate({
|
const result = await service.generate({
|
||||||
employeeId: 'emp-1',
|
employeeId: 'emp-1',
|
||||||
purpose: 'sales',
|
purpose: 'sales',
|
||||||
from: '2026-01-05',
|
from,
|
||||||
to: '2026-01-12',
|
to,
|
||||||
userId: 'user-1',
|
userId: 'user-1',
|
||||||
});
|
});
|
||||||
expect(result.created).toBe(1);
|
expect(result.created).toBe(1);
|
||||||
@@ -205,8 +294,8 @@ describe('PlansService', () => {
|
|||||||
service.generate({
|
service.generate({
|
||||||
employeeId: 'emp-1',
|
employeeId: 'emp-1',
|
||||||
purpose: 'sales',
|
purpose: 'sales',
|
||||||
from: '2026-01-05',
|
from: todayYmd(),
|
||||||
to: '2026-01-05',
|
to: todayYmd(),
|
||||||
userId: 'user-1',
|
userId: 'user-1',
|
||||||
}),
|
}),
|
||||||
).rejects.toBeInstanceOf(BadRequestException);
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
|||||||
@@ -173,6 +173,9 @@ export class PlansService {
|
|||||||
const date = input.date
|
const date = input.date
|
||||||
? this.assertDate(input.date).startOfDay()
|
? this.assertDate(input.date).startOfDay()
|
||||||
: existing.date;
|
: existing.date;
|
||||||
|
if (input.date && date.value !== existing.date.value) {
|
||||||
|
this.assertNotInThePast(date);
|
||||||
|
}
|
||||||
await this.employeesService.findById(employeeId);
|
await this.employeesService.findById(employeeId);
|
||||||
await this.assertUnique(employeeId, purpose, date.value, id);
|
await this.assertUnique(employeeId, purpose, date.value, id);
|
||||||
const startBranchId = input.startBranchId ?? existing.startBranchId;
|
const startBranchId = input.startBranchId ?? existing.startBranchId;
|
||||||
@@ -189,12 +192,8 @@ export class PlansService {
|
|||||||
input.invoiceIds ?? [...existing.invoiceIds],
|
input.invoiceIds ?? [...existing.invoiceIds],
|
||||||
input.packingSlipIds ?? [...existing.packingSlipIds],
|
input.packingSlipIds ?? [...existing.packingSlipIds],
|
||||||
);
|
);
|
||||||
if (input.invoiceIds) {
|
await this.assertInvoices(attachments.invoiceIds, customerIds);
|
||||||
await this.assertInvoices(input.invoiceIds);
|
await this.assertPackingSlips(attachments.packingSlipIds, customerIds);
|
||||||
}
|
|
||||||
if (input.packingSlipIds) {
|
|
||||||
await this.assertPackingSlips(input.packingSlipIds);
|
|
||||||
}
|
|
||||||
const updated = await this.plansRepository.update(id, {
|
const updated = await this.plansRepository.update(id, {
|
||||||
employeeId,
|
employeeId,
|
||||||
purpose,
|
purpose,
|
||||||
@@ -278,6 +277,8 @@ export class PlansService {
|
|||||||
if (to.value < from.value) {
|
if (to.value < from.value) {
|
||||||
throw new BadRequestException('Invalid date range');
|
throw new BadRequestException('Invalid date range');
|
||||||
}
|
}
|
||||||
|
this.assertNotInThePast(from);
|
||||||
|
this.assertNotInThePast(to);
|
||||||
if (from.value < epoch.startOfDay().value) {
|
if (from.value < epoch.startOfDay().value) {
|
||||||
throw new BadRequestException('Date is before the cycle start date');
|
throw new BadRequestException('Date is before the cycle start date');
|
||||||
}
|
}
|
||||||
@@ -446,6 +447,7 @@ export class PlansService {
|
|||||||
}) {
|
}) {
|
||||||
const purpose = this.assertPurpose(input.purpose);
|
const purpose = this.assertPurpose(input.purpose);
|
||||||
const date = this.assertDate(input.date).startOfDay();
|
const date = this.assertDate(input.date).startOfDay();
|
||||||
|
this.assertNotInThePast(date);
|
||||||
await this.employeesService.findById(input.employeeId);
|
await this.employeesService.findById(input.employeeId);
|
||||||
await this.assertUnique(input.employeeId, purpose, date.value);
|
await this.assertUnique(input.employeeId, purpose, date.value);
|
||||||
const geometry = await this.buildGeometry(
|
const geometry = await this.buildGeometry(
|
||||||
@@ -458,8 +460,11 @@ export class PlansService {
|
|||||||
input.invoiceIds ?? [],
|
input.invoiceIds ?? [],
|
||||||
input.packingSlipIds ?? [],
|
input.packingSlipIds ?? [],
|
||||||
);
|
);
|
||||||
await this.assertInvoices(attachments.invoiceIds);
|
await this.assertInvoices(attachments.invoiceIds, input.customerIds);
|
||||||
await this.assertPackingSlips(attachments.packingSlipIds);
|
await this.assertPackingSlips(
|
||||||
|
attachments.packingSlipIds,
|
||||||
|
input.customerIds,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
employeeId: input.employeeId,
|
employeeId: input.employeeId,
|
||||||
purpose,
|
purpose,
|
||||||
@@ -544,15 +549,35 @@ export class PlansService {
|
|||||||
await this.salesInvoicesService.markDraftsProcessed(newlyAttached, userId);
|
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) {
|
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) {
|
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 {
|
private assertStatus(raw: string): Status {
|
||||||
try {
|
try {
|
||||||
return Status.create(raw);
|
return Status.create(raw);
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ describe('parseGroupNamesQuery', () => {
|
|||||||
|
|
||||||
it('reads array values', () => {
|
it('reads array values', () => {
|
||||||
expect(
|
expect(
|
||||||
parseGroupNamesQuery({ groupNames: ['sales_report', 'logistics_report'] }),
|
parseGroupNamesQuery({
|
||||||
|
groupNames: ['sales_report', 'logistics_report'],
|
||||||
|
}),
|
||||||
).toEqual(['sales_report', 'logistics_report']);
|
).toEqual(['sales_report', 'logistics_report']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
export function parseGroupNamesQuery(
|
export function parseGroupNamesQuery(query: Record<string, unknown>): string[] {
|
||||||
query: Record<string, unknown>,
|
|
||||||
): string[] {
|
|
||||||
const raw =
|
const raw =
|
||||||
query.groupNames ??
|
query.groupNames ?? query['groupNames[]'] ?? query['groupNames[0]'];
|
||||||
query['groupNames[]'] ??
|
|
||||||
query['groupNames[0]'];
|
|
||||||
|
|
||||||
if (raw === undefined || raw === null || raw === '') {
|
if (raw === undefined || raw === null || raw === '') {
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { Type } from 'class-transformer';
|
import { Transform, Type } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
ArrayNotEmpty,
|
ArrayNotEmpty,
|
||||||
IsArray,
|
IsArray,
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
CodeRelationDto,
|
CodeRelationDto,
|
||||||
DefaultRelationDto,
|
DefaultRelationDto,
|
||||||
PaginationQueryDto,
|
PaginationQueryDto,
|
||||||
|
parseQueryIdList,
|
||||||
UserRelationDto,
|
UserRelationDto,
|
||||||
} from '../../../../common/http/response';
|
} from '../../../../common/http/response';
|
||||||
import {
|
import {
|
||||||
@@ -213,6 +214,13 @@ export class ListPackingSlipsQueryDto extends PaginationQueryDto {
|
|||||||
@IsUUID('4')
|
@IsUUID('4')
|
||||||
customerId?: string;
|
customerId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: [String], format: 'uuid' })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => parseQueryIdList(value))
|
||||||
|
@IsArray()
|
||||||
|
@IsUUID('4', { each: true })
|
||||||
|
customerIds?: string[];
|
||||||
|
|
||||||
@ApiPropertyOptional({ format: 'uuid' })
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID('4')
|
@IsUUID('4')
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ export type ListPackingSlipsFilters = {
|
|||||||
readonly code?: string;
|
readonly code?: string;
|
||||||
readonly status?: string;
|
readonly status?: string;
|
||||||
readonly customerId?: string;
|
readonly customerId?: string;
|
||||||
|
readonly customerIds?: readonly string[];
|
||||||
readonly salesOrderId?: string;
|
readonly salesOrderId?: string;
|
||||||
readonly search?: string;
|
readonly search?: string;
|
||||||
readonly orderBy?: string;
|
readonly orderBy?: string;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} 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 { toOrderClauses } from '../../../common/http/response';
|
||||||
import {
|
import {
|
||||||
catalogRelationFromMap,
|
catalogRelationFromMap,
|
||||||
@@ -294,7 +294,16 @@ export class PackingSlipsRepository {
|
|||||||
if (filters.status) {
|
if (filters.status) {
|
||||||
parts.push(eq(packingSlips.status, 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));
|
parts.push(eq(packingSlips.customerId, filters.customerId));
|
||||||
}
|
}
|
||||||
if (filters.salesOrderId) {
|
if (filters.salesOrderId) {
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export type ListPackingSlipsQuery = {
|
|||||||
readonly code?: string;
|
readonly code?: string;
|
||||||
readonly status?: string;
|
readonly status?: string;
|
||||||
readonly customerId?: string;
|
readonly customerId?: string;
|
||||||
|
readonly customerIds?: readonly string[];
|
||||||
readonly salesOrderId?: string;
|
readonly salesOrderId?: string;
|
||||||
readonly search?: string;
|
readonly search?: string;
|
||||||
readonly orderBy?: string;
|
readonly orderBy?: string;
|
||||||
@@ -84,6 +85,7 @@ export class PackingSlipsService {
|
|||||||
code: query.code,
|
code: query.code,
|
||||||
status: query.status,
|
status: query.status,
|
||||||
customerId: query.customerId,
|
customerId: query.customerId,
|
||||||
|
customerIds: query.customerIds,
|
||||||
salesOrderId: query.salesOrderId,
|
salesOrderId: query.salesOrderId,
|
||||||
search: query.search,
|
search: query.search,
|
||||||
orderBy: query.orderBy,
|
orderBy: query.orderBy,
|
||||||
|
|||||||
@@ -6,20 +6,25 @@ import {
|
|||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsIn,
|
IsIn,
|
||||||
IsNotEmpty,
|
IsNotEmpty,
|
||||||
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
IsUUID,
|
IsUUID,
|
||||||
Matches,
|
Matches,
|
||||||
|
Max,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
|
Min,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import {
|
import {
|
||||||
CodeRelationDto,
|
CodeRelationDto,
|
||||||
DefaultRelationDto,
|
DefaultRelationDto,
|
||||||
PaginationQueryDto,
|
PaginationQueryDto,
|
||||||
|
parseQueryIdList,
|
||||||
UserRelationDto,
|
UserRelationDto,
|
||||||
} from '../../../../common/http/response';
|
} from '../../../../common/http/response';
|
||||||
import {
|
import {
|
||||||
|
DOCUMENT_ADDRESS_MAX_LENGTH,
|
||||||
DOCUMENT_CODE_MAX_LENGTH,
|
DOCUMENT_CODE_MAX_LENGTH,
|
||||||
DOCUMENT_CODE_PATTERN,
|
DOCUMENT_CODE_PATTERN,
|
||||||
DOCUMENT_NOTES_MAX_LENGTH,
|
DOCUMENT_NOTES_MAX_LENGTH,
|
||||||
@@ -85,6 +90,26 @@ export class CreateSalesInvoiceDto {
|
|||||||
@IsUUID('4')
|
@IsUUID('4')
|
||||||
customerId?: string;
|
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()
|
@ApiPropertyOptional()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -147,6 +172,22 @@ export class UpdateSalesInvoiceDto {
|
|||||||
@IsUUID('4')
|
@IsUUID('4')
|
||||||
customerId?: string;
|
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 })
|
@ApiPropertyOptional({ nullable: true })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -202,6 +243,13 @@ export class ListSalesInvoicesQueryDto extends PaginationQueryDto {
|
|||||||
@IsUUID('4')
|
@IsUUID('4')
|
||||||
customerId?: string;
|
customerId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ type: [String], format: 'uuid' })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => parseQueryIdList(value))
|
||||||
|
@IsArray()
|
||||||
|
@IsUUID('4', { each: true })
|
||||||
|
customerIds?: string[];
|
||||||
|
|
||||||
@ApiPropertyOptional({ format: 'uuid' })
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID('4')
|
@IsUUID('4')
|
||||||
@@ -258,6 +306,12 @@ export class SalesInvoiceDto {
|
|||||||
division!: DefaultRelationDto | null;
|
division!: DefaultRelationDto | null;
|
||||||
@ApiProperty({ type: DefaultRelationDto, nullable: true })
|
@ApiProperty({ type: DefaultRelationDto, nullable: true })
|
||||||
customer!: DefaultRelationDto | null;
|
customer!: DefaultRelationDto | null;
|
||||||
|
@ApiProperty()
|
||||||
|
address!: string;
|
||||||
|
@ApiPropertyOptional({ nullable: true })
|
||||||
|
latitude!: number | null;
|
||||||
|
@ApiPropertyOptional({ nullable: true })
|
||||||
|
longitude!: number | null;
|
||||||
@ApiPropertyOptional({ nullable: true })
|
@ApiPropertyOptional({ nullable: true })
|
||||||
notes!: string | null;
|
notes!: string | null;
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ export type SalesInvoice = {
|
|||||||
readonly branchId: string;
|
readonly branchId: string;
|
||||||
readonly divisionId: string;
|
readonly divisionId: string;
|
||||||
readonly customerId: string;
|
readonly customerId: string;
|
||||||
|
readonly address: string;
|
||||||
|
readonly latitude: number | null;
|
||||||
|
readonly longitude: number | null;
|
||||||
readonly notes: string | null;
|
readonly notes: string | null;
|
||||||
readonly balance: Decimal;
|
readonly balance: Decimal;
|
||||||
readonly products: readonly SalesInvoiceLine[];
|
readonly products: readonly SalesInvoiceLine[];
|
||||||
@@ -62,6 +65,9 @@ export type CreateSalesInvoiceInput = {
|
|||||||
readonly branchId: string;
|
readonly branchId: string;
|
||||||
readonly divisionId: string;
|
readonly divisionId: string;
|
||||||
readonly customerId: string;
|
readonly customerId: string;
|
||||||
|
readonly address: string;
|
||||||
|
readonly latitude?: number | null;
|
||||||
|
readonly longitude?: number | null;
|
||||||
readonly notes?: string | null;
|
readonly notes?: string | null;
|
||||||
readonly products: readonly SalesInvoiceLineInput[];
|
readonly products: readonly SalesInvoiceLineInput[];
|
||||||
readonly status?: Status;
|
readonly status?: Status;
|
||||||
@@ -79,6 +85,9 @@ export type UpdateSalesInvoiceInput = {
|
|||||||
readonly branchId?: string;
|
readonly branchId?: string;
|
||||||
readonly divisionId?: string;
|
readonly divisionId?: string;
|
||||||
readonly customerId?: string;
|
readonly customerId?: string;
|
||||||
|
readonly address?: string;
|
||||||
|
readonly latitude?: number | null;
|
||||||
|
readonly longitude?: number | null;
|
||||||
readonly notes?: string | null;
|
readonly notes?: string | null;
|
||||||
readonly products?: readonly SalesInvoiceLineInput[];
|
readonly products?: readonly SalesInvoiceLineInput[];
|
||||||
readonly userId: string;
|
readonly userId: string;
|
||||||
@@ -88,6 +97,7 @@ export type ListSalesInvoicesFilters = {
|
|||||||
readonly code?: string;
|
readonly code?: string;
|
||||||
readonly status?: string;
|
readonly status?: string;
|
||||||
readonly customerId?: string;
|
readonly customerId?: string;
|
||||||
|
readonly customerIds?: readonly string[];
|
||||||
readonly salesPersonId?: string;
|
readonly salesPersonId?: string;
|
||||||
readonly branchId?: string;
|
readonly branchId?: string;
|
||||||
readonly divisionId?: string;
|
readonly divisionId?: string;
|
||||||
|
|||||||
@@ -136,6 +136,9 @@ export class SalesInvoicesWriteController {
|
|||||||
branchId: dto.branchId,
|
branchId: dto.branchId,
|
||||||
divisionId: dto.divisionId,
|
divisionId: dto.divisionId,
|
||||||
customerId: dto.customerId,
|
customerId: dto.customerId,
|
||||||
|
address: dto.address,
|
||||||
|
latitude: dto.latitude,
|
||||||
|
longitude: dto.longitude,
|
||||||
notes: dto.notes,
|
notes: dto.notes,
|
||||||
products: dto.products,
|
products: dto.products,
|
||||||
status: dto.status,
|
status: dto.status,
|
||||||
@@ -179,6 +182,9 @@ export class SalesInvoicesWriteController {
|
|||||||
branchId: dto.branchId,
|
branchId: dto.branchId,
|
||||||
divisionId: dto.divisionId,
|
divisionId: dto.divisionId,
|
||||||
customerId: dto.customerId,
|
customerId: dto.customerId,
|
||||||
|
address: dto.address,
|
||||||
|
latitude: dto.latitude,
|
||||||
|
longitude: dto.longitude,
|
||||||
notes: dto.notes,
|
notes: dto.notes,
|
||||||
products: dto.products,
|
products: dto.products,
|
||||||
userId,
|
userId,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
inArray,
|
inArray,
|
||||||
or,
|
or,
|
||||||
SQL,
|
SQL,
|
||||||
|
sql,
|
||||||
sum,
|
sum,
|
||||||
} from 'drizzle-orm';
|
} from 'drizzle-orm';
|
||||||
import { toOrderClauses } from '../../../common/http/response';
|
import { toOrderClauses } from '../../../common/http/response';
|
||||||
@@ -211,6 +212,13 @@ export class SalesInvoicesRepository {
|
|||||||
branchId: input.branchId ?? existing.branchId,
|
branchId: input.branchId ?? existing.branchId,
|
||||||
divisionId: input.divisionId ?? existing.divisionId,
|
divisionId: input.divisionId ?? existing.divisionId,
|
||||||
customerId: input.customerId ?? existing.customerId,
|
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,
|
notes: input.notes !== undefined ? input.notes : existing.notes,
|
||||||
updatedAt: now.value,
|
updatedAt: now.value,
|
||||||
updatedBy: input.userId,
|
updatedBy: input.userId,
|
||||||
@@ -380,7 +388,16 @@ export class SalesInvoicesRepository {
|
|||||||
if (filters.status) {
|
if (filters.status) {
|
||||||
parts.push(eq(salesInvoices.status, 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));
|
parts.push(eq(salesInvoices.customerId, filters.customerId));
|
||||||
}
|
}
|
||||||
if (filters.salesPersonId) {
|
if (filters.salesPersonId) {
|
||||||
@@ -401,6 +418,7 @@ export class SalesInvoicesRepository {
|
|||||||
if (filters.search) {
|
if (filters.search) {
|
||||||
const search = or(
|
const search = or(
|
||||||
ilike(salesInvoices.code, `%${filters.search}%`),
|
ilike(salesInvoices.code, `%${filters.search}%`),
|
||||||
|
ilike(salesInvoices.address, `%${filters.search}%`),
|
||||||
ilike(salesInvoices.notes, `%${filters.search}%`),
|
ilike(salesInvoices.notes, `%${filters.search}%`),
|
||||||
);
|
);
|
||||||
if (search) {
|
if (search) {
|
||||||
@@ -449,6 +467,9 @@ export class SalesInvoicesRepository {
|
|||||||
branchId: input.branchId,
|
branchId: input.branchId,
|
||||||
divisionId: input.divisionId,
|
divisionId: input.divisionId,
|
||||||
customerId: input.customerId,
|
customerId: input.customerId,
|
||||||
|
address: input.address,
|
||||||
|
latitude: input.latitude ?? null,
|
||||||
|
longitude: input.longitude ?? null,
|
||||||
balance: '0.0000',
|
balance: '0.0000',
|
||||||
notes: input.notes ?? null,
|
notes: input.notes ?? null,
|
||||||
status: status.value,
|
status: status.value,
|
||||||
@@ -475,6 +496,9 @@ export class SalesInvoicesRepository {
|
|||||||
branchId: row.branchId,
|
branchId: row.branchId,
|
||||||
divisionId: row.divisionId,
|
divisionId: row.divisionId,
|
||||||
customerId: row.customerId,
|
customerId: row.customerId,
|
||||||
|
address: row.address,
|
||||||
|
latitude: row.latitude,
|
||||||
|
longitude: row.longitude,
|
||||||
notes: row.notes,
|
notes: row.notes,
|
||||||
balance: Decimal.create(row.balance),
|
balance: Decimal.create(row.balance),
|
||||||
products: productRows.map((line) => ({
|
products: productRows.map((line) => ({
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ describe('SalesInvoicesService', () => {
|
|||||||
branchId: 'br-1',
|
branchId: 'br-1',
|
||||||
divisionId: 'div-1',
|
divisionId: 'div-1',
|
||||||
customerId: 'cus-1',
|
customerId: 'cus-1',
|
||||||
|
address: 'Jl Sudirman 1',
|
||||||
|
latitude: -6.2,
|
||||||
|
longitude: 106.8,
|
||||||
notes: null,
|
notes: null,
|
||||||
balance: Decimal.create('25000'),
|
balance: Decimal.create('25000'),
|
||||||
products: [
|
products: [
|
||||||
@@ -100,6 +103,9 @@ describe('SalesInvoicesService', () => {
|
|||||||
branchId: 'br-1',
|
branchId: 'br-1',
|
||||||
divisionId: 'div-1',
|
divisionId: 'div-1',
|
||||||
customerId: 'cus-1',
|
customerId: 'cus-1',
|
||||||
|
address: 'Jl Sudirman 1',
|
||||||
|
latitude: -6.2,
|
||||||
|
longitude: 106.8,
|
||||||
products: [{ productId: 'prd-1', quantity: '2' }],
|
products: [{ productId: 'prd-1', quantity: '2' }],
|
||||||
userId: 'user-1',
|
userId: 'user-1',
|
||||||
};
|
};
|
||||||
@@ -138,6 +144,9 @@ describe('SalesInvoicesService', () => {
|
|||||||
const [arg] = repository.create.mock.calls[0];
|
const [arg] = repository.create.mock.calls[0];
|
||||||
expect(arg.products[0]?.price.value).toBe('12500.0000');
|
expect(arg.products[0]?.price.value).toBe('12500.0000');
|
||||||
expect(arg.status?.value).toBe('draft');
|
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 () => {
|
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' },
|
branch: { id: 'br-1', code: 'B1', name: 'Jakarta' },
|
||||||
division: { id: 'div-1', code: 'FIN', name: 'Finance' },
|
division: { id: 'div-1', code: 'FIN', name: 'Finance' },
|
||||||
customer: { id: 'cus-1', code: 'C1', name: 'Acme' },
|
customer: { id: 'cus-1', code: 'C1', name: 'Acme' },
|
||||||
|
address: 'Jl Thamrin 9',
|
||||||
|
latitude: -6.1,
|
||||||
|
longitude: 106.9,
|
||||||
notes: 'from order',
|
notes: 'from order',
|
||||||
products: [
|
products: [
|
||||||
{
|
{
|
||||||
@@ -169,9 +181,18 @@ describe('SalesInvoicesService', () => {
|
|||||||
const [copied] = repository.create.mock.calls[0];
|
const [copied] = repository.create.mock.calls[0];
|
||||||
expect(copied.salesOrderCode).toBe('SO-1');
|
expect(copied.salesOrderCode).toBe('SO-1');
|
||||||
expect(copied.salesPersonId).toBe('emp-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);
|
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 () => {
|
it('create rejects a missing product line list without a parent', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
service.create({ ...createBody, products: [] }),
|
service.create({ ...createBody, products: [] }),
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ import { SalesOrdersService } from '../sales-orders/sales-orders.service';
|
|||||||
import {
|
import {
|
||||||
isValidDocumentCode,
|
isValidDocumentCode,
|
||||||
isValidDocumentNotes,
|
isValidDocumentNotes,
|
||||||
|
isValidDocumentAddress,
|
||||||
|
isValidLatitude,
|
||||||
|
isValidLongitude,
|
||||||
isAllowedStatusTransition,
|
isAllowedStatusTransition,
|
||||||
parseCsvRecord,
|
parseCsvRecord,
|
||||||
SALES_INVOICE_STATUSES,
|
SALES_INVOICE_STATUSES,
|
||||||
@@ -51,6 +54,7 @@ export type ListSalesInvoicesQuery = {
|
|||||||
readonly code?: string;
|
readonly code?: string;
|
||||||
readonly status?: string;
|
readonly status?: string;
|
||||||
readonly customerId?: string;
|
readonly customerId?: string;
|
||||||
|
readonly customerIds?: readonly string[];
|
||||||
readonly salesPersonId?: string;
|
readonly salesPersonId?: string;
|
||||||
readonly branchId?: string;
|
readonly branchId?: string;
|
||||||
readonly divisionId?: string;
|
readonly divisionId?: string;
|
||||||
@@ -71,6 +75,7 @@ const CSV_REQUIRED_HEADERS = [
|
|||||||
'branchId',
|
'branchId',
|
||||||
'divisionId',
|
'divisionId',
|
||||||
'customerId',
|
'customerId',
|
||||||
|
'address',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -98,6 +103,7 @@ export class SalesInvoicesService {
|
|||||||
code: query.code,
|
code: query.code,
|
||||||
status: query.status,
|
status: query.status,
|
||||||
customerId: query.customerId,
|
customerId: query.customerId,
|
||||||
|
customerIds: query.customerIds,
|
||||||
salesPersonId: query.salesPersonId,
|
salesPersonId: query.salesPersonId,
|
||||||
branchId: query.branchId,
|
branchId: query.branchId,
|
||||||
divisionId: query.divisionId,
|
divisionId: query.divisionId,
|
||||||
@@ -135,6 +141,9 @@ export class SalesInvoicesService {
|
|||||||
branchId?: string;
|
branchId?: string;
|
||||||
divisionId?: string;
|
divisionId?: string;
|
||||||
customerId?: string;
|
customerId?: string;
|
||||||
|
address?: string;
|
||||||
|
latitude?: number | null;
|
||||||
|
longitude?: number | null;
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
products?: SalesLineBody[];
|
products?: SalesLineBody[];
|
||||||
status?: string;
|
status?: string;
|
||||||
@@ -159,6 +168,9 @@ export class SalesInvoicesService {
|
|||||||
branchId?: string;
|
branchId?: string;
|
||||||
divisionId?: string;
|
divisionId?: string;
|
||||||
customerId?: string;
|
customerId?: string;
|
||||||
|
address?: string;
|
||||||
|
latitude?: number | null;
|
||||||
|
longitude?: number | null;
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
products?: SalesLineBody[];
|
products?: SalesLineBody[];
|
||||||
status?: unknown;
|
status?: unknown;
|
||||||
@@ -194,6 +206,18 @@ export class SalesInvoicesService {
|
|||||||
branchId: input.branchId,
|
branchId: input.branchId,
|
||||||
divisionId: input.divisionId,
|
divisionId: input.divisionId,
|
||||||
customerId: input.customerId,
|
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:
|
notes:
|
||||||
input.notes !== undefined ? this.assertNotes(input.notes) : undefined,
|
input.notes !== undefined ? this.assertNotes(input.notes) : undefined,
|
||||||
products:
|
products:
|
||||||
@@ -342,6 +366,15 @@ export class SalesInvoicesService {
|
|||||||
branchId: cols[idx('branchid')] ?? '',
|
branchId: cols[idx('branchid')] ?? '',
|
||||||
divisionId: cols[idx('divisionid')] ?? '',
|
divisionId: cols[idx('divisionid')] ?? '',
|
||||||
customerId: cols[idx('customerid')] ?? '',
|
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,
|
notes: idx('notes') >= 0 ? cols[idx('notes')] : null,
|
||||||
products: [],
|
products: [],
|
||||||
status: idx('status') >= 0 ? cols[idx('status')] : undefined,
|
status: idx('status') >= 0 ? cols[idx('status')] : undefined,
|
||||||
@@ -375,6 +408,9 @@ export class SalesInvoicesService {
|
|||||||
branch: pickRelation(item.branch, DEFAULT_RELATION_FIELDS),
|
branch: pickRelation(item.branch, DEFAULT_RELATION_FIELDS),
|
||||||
division: pickRelation(item.division, DEFAULT_RELATION_FIELDS),
|
division: pickRelation(item.division, DEFAULT_RELATION_FIELDS),
|
||||||
customer: pickRelation(item.customer, DEFAULT_RELATION_FIELDS),
|
customer: pickRelation(item.customer, DEFAULT_RELATION_FIELDS),
|
||||||
|
address: item.address,
|
||||||
|
latitude: item.latitude,
|
||||||
|
longitude: item.longitude,
|
||||||
notes: item.notes,
|
notes: item.notes,
|
||||||
balance: item.balance.value,
|
balance: item.balance.value,
|
||||||
status: item.status.value,
|
status: item.status.value,
|
||||||
@@ -406,6 +442,9 @@ export class SalesInvoicesService {
|
|||||||
branchId?: string;
|
branchId?: string;
|
||||||
divisionId?: string;
|
divisionId?: string;
|
||||||
customerId?: string;
|
customerId?: string;
|
||||||
|
address?: string;
|
||||||
|
latitude?: number | null;
|
||||||
|
longitude?: number | null;
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
products?: SalesLineBody[];
|
products?: SalesLineBody[];
|
||||||
status?: string;
|
status?: string;
|
||||||
@@ -416,6 +455,9 @@ export class SalesInvoicesService {
|
|||||||
let branchId = input.branchId ?? '';
|
let branchId = input.branchId ?? '';
|
||||||
let divisionId = input.divisionId ?? '';
|
let divisionId = input.divisionId ?? '';
|
||||||
let customerId = input.customerId ?? '';
|
let customerId = input.customerId ?? '';
|
||||||
|
let address = input.address;
|
||||||
|
let latitude = input.latitude;
|
||||||
|
let longitude = input.longitude;
|
||||||
let notes = input.notes;
|
let notes = input.notes;
|
||||||
let products = input.products;
|
let products = input.products;
|
||||||
let salesOrderCode: string | null = null;
|
let salesOrderCode: string | null = null;
|
||||||
@@ -428,6 +470,9 @@ export class SalesInvoicesService {
|
|||||||
branchId = branchId || order.branch?.id || '';
|
branchId = branchId || order.branch?.id || '';
|
||||||
divisionId = divisionId || order.division?.id || '';
|
divisionId = divisionId || order.division?.id || '';
|
||||||
customerId = customerId || order.customer?.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;
|
notes = notes !== undefined ? notes : order.notes;
|
||||||
products =
|
products =
|
||||||
products ??
|
products ??
|
||||||
@@ -442,6 +487,9 @@ export class SalesInvoicesService {
|
|||||||
packingSlipCode = slip.code;
|
packingSlipCode = slip.code;
|
||||||
date = date || DateTime.fromUnixMs(slip.date).format();
|
date = date || DateTime.fromUnixMs(slip.date).format();
|
||||||
customerId = customerId || slip.customer?.id || '';
|
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;
|
notes = notes !== undefined ? notes : slip.notes;
|
||||||
products =
|
products =
|
||||||
input.products ??
|
input.products ??
|
||||||
@@ -463,6 +511,9 @@ export class SalesInvoicesService {
|
|||||||
branchId,
|
branchId,
|
||||||
divisionId,
|
divisionId,
|
||||||
customerId,
|
customerId,
|
||||||
|
address: address ?? '',
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
notes: notes ?? null,
|
notes: notes ?? null,
|
||||||
products: products ?? [],
|
products: products ?? [],
|
||||||
};
|
};
|
||||||
@@ -479,6 +530,9 @@ export class SalesInvoicesService {
|
|||||||
branchId: string;
|
branchId: string;
|
||||||
divisionId: string;
|
divisionId: string;
|
||||||
customerId: string;
|
customerId: string;
|
||||||
|
address: string;
|
||||||
|
latitude?: number | null;
|
||||||
|
longitude?: number | null;
|
||||||
notes?: string | null;
|
notes?: string | null;
|
||||||
products: SalesLineBody[];
|
products: SalesLineBody[];
|
||||||
status?: string;
|
status?: string;
|
||||||
@@ -498,6 +552,9 @@ export class SalesInvoicesService {
|
|||||||
branchId: input.branchId,
|
branchId: input.branchId,
|
||||||
divisionId: input.divisionId,
|
divisionId: input.divisionId,
|
||||||
customerId: input.customerId,
|
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),
|
notes: this.assertNotes(input.notes ?? null),
|
||||||
products: await this.assertLines(input.products),
|
products: await this.assertLines(input.products),
|
||||||
status: input.status
|
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 {
|
private assertNotes(raw: string | null): string | null {
|
||||||
if (raw === null || raw === '') {
|
if (raw === null || raw === '') {
|
||||||
return null;
|
return null;
|
||||||
@@ -580,6 +645,26 @@ export class SalesInvoicesService {
|
|||||||
return raw;
|
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 {
|
private assertStatus(raw: string): Status {
|
||||||
try {
|
try {
|
||||||
return Status.create(raw, SALES_INVOICE_STATUSES);
|
return Status.create(raw, SALES_INVOICE_STATUSES);
|
||||||
|
|||||||
+23
-5
@@ -14,6 +14,23 @@ import {
|
|||||||
} from '../src/database/schema';
|
} from '../src/database/schema';
|
||||||
import { PRIVILEGE_ACTIONS } from '../src/modules/privileges/privilege-action';
|
import { PRIVILEGE_ACTIONS } from '../src/modules/privileges/privilege-action';
|
||||||
import { registerAndActivate } from './helpers/activate-user';
|
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)', () => {
|
describe('Plans (e2e)', () => {
|
||||||
let app: INestApplication<App>;
|
let app: INestApplication<App>;
|
||||||
@@ -203,14 +220,15 @@ describe('Plans (e2e)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('generates Monday plans, skips existing, and supports D-Day destination edits', async () => {
|
it('generates Monday plans, skips existing, and supports D-Day destination edits', async () => {
|
||||||
|
const { from, to } = nextMondayRange();
|
||||||
const generated = await request(app.getHttpServer())
|
const generated = await request(app.getHttpServer())
|
||||||
.post('/plans/generate')
|
.post('/plans/generate')
|
||||||
.set('Authorization', `Bearer ${adminAccessToken}`)
|
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||||
.send({
|
.send({
|
||||||
employeeId,
|
employeeId,
|
||||||
purpose: 'sales',
|
purpose: 'sales',
|
||||||
from: '2026-01-05',
|
from,
|
||||||
to: '2026-01-12',
|
to,
|
||||||
})
|
})
|
||||||
.expect(201);
|
.expect(201);
|
||||||
expect((generated.body as { created: number }).created).toBe(2);
|
expect((generated.body as { created: number }).created).toBe(2);
|
||||||
@@ -222,15 +240,15 @@ describe('Plans (e2e)', () => {
|
|||||||
.send({
|
.send({
|
||||||
employeeId,
|
employeeId,
|
||||||
purpose: 'sales',
|
purpose: 'sales',
|
||||||
from: '2026-01-05',
|
from,
|
||||||
to: '2026-01-12',
|
to,
|
||||||
})
|
})
|
||||||
.expect(201);
|
.expect(201);
|
||||||
expect((again.body as { created: number }).created).toBe(0);
|
expect((again.body as { created: number }).created).toBe(0);
|
||||||
|
|
||||||
const list = await request(app.getHttpServer())
|
const list = await request(app.getHttpServer())
|
||||||
.get('/plans')
|
.get('/plans')
|
||||||
.query({ employeeId, purpose: 'sales', date: '2026-01-05' })
|
.query({ employeeId, purpose: 'sales', date: from })
|
||||||
.set('Authorization', `Bearer ${adminAccessToken}`)
|
.set('Authorization', `Bearer ${adminAccessToken}`)
|
||||||
.expect(200);
|
.expect(200);
|
||||||
const planId = (list.body.data as Array<{ id: string }>)[0].id;
|
const planId = (list.body.data as Array<{ id: string }>)[0].id;
|
||||||
|
|||||||
@@ -165,12 +165,18 @@ describe('Sales invoices (e2e)', () => {
|
|||||||
branchId,
|
branchId,
|
||||||
divisionId,
|
divisionId,
|
||||||
customerId,
|
customerId,
|
||||||
|
address: 'Jl Sudirman 1',
|
||||||
|
latitude: -6.2,
|
||||||
|
longitude: 106.8,
|
||||||
products: [{ productId, quantity: '2' }],
|
products: [{ productId, quantity: '2' }],
|
||||||
})
|
})
|
||||||
.expect(201);
|
.expect(201);
|
||||||
expect(created.body).toMatchObject({
|
expect(created.body).toMatchObject({
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
balance: '25000.0000',
|
balance: '25000.0000',
|
||||||
|
address: 'Jl Sudirman 1',
|
||||||
|
latitude: -6.2,
|
||||||
|
longitude: 106.8,
|
||||||
});
|
});
|
||||||
expect((created.body as { code: string }).code).toMatch(/^SI-/);
|
expect((created.body as { code: string }).code).toMatch(/^SI-/);
|
||||||
const id = (created.body as { id: string }).id;
|
const id = (created.body as { id: string }).id;
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ describe('Sales payments (e2e)', () => {
|
|||||||
branchId: (branch.body as { id: string }).id,
|
branchId: (branch.body as { id: string }).id,
|
||||||
divisionId: (division.body as { id: string }).id,
|
divisionId: (division.body as { id: string }).id,
|
||||||
customerId: (customer.body as { id: string }).id,
|
customerId: (customer.body as { id: string }).id,
|
||||||
|
address: 'Jl Sudirman 1',
|
||||||
products: [
|
products: [
|
||||||
{
|
{
|
||||||
productId: (product.body as { id: string }).id,
|
productId: (product.body as { id: string }).id,
|
||||||
|
|||||||
Reference in New Issue
Block a user