From 0e73d14381799cc1f532644270a98bcd59a1b46c Mon Sep 17 00:00:00 2001 From: shancheas Date: Tue, 1 Sep 2026 09:50:19 +0700 Subject: [PATCH] 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. --- .../0014_sales_invoice_location.sql | 4 + drizzle/migrations/meta/_journal.json | 7 ++ src/common/http/response/index.ts | 1 + .../http/response/parse-query-id-list.spec.ts | 20 ++++ .../http/response/parse-query-id-list.ts | 11 +++ src/database/sales-invoices-table.ts | 4 + src/modules/field/plans/plans.service.spec.ts | 99 ++++++++++++++++++- src/modules/field/plans/plans.service.ts | 56 ++++++++--- .../helpers/report-query-params.spec.ts | 4 +- .../shared/helpers/report-query-params.ts | 8 +- .../packing-slips/dto/packing-slip.dto.ts | 10 +- .../sales/packing-slips/packing-slip.ts | 1 + .../packing-slips/packing-slips.repository.ts | 13 ++- .../packing-slips/packing-slips.service.ts | 2 + .../sales-invoices/dto/sales-invoice.dto.ts | 54 ++++++++++ .../sales/sales-invoices/sales-invoice.ts | 10 ++ .../sales-invoices-write.controller.ts | 6 ++ .../sales-invoices.repository.ts | 26 ++++- .../sales-invoices.service.spec.ts | 21 ++++ .../sales-invoices/sales-invoices.service.ts | 85 ++++++++++++++++ test/plans.e2e-spec.ts | 28 +++++- test/sales-invoices.e2e-spec.ts | 6 ++ test/sales-payments.e2e-spec.ts | 1 + 23 files changed, 444 insertions(+), 33 deletions(-) create mode 100644 drizzle/migrations/0014_sales_invoice_location.sql create mode 100644 src/common/http/response/parse-query-id-list.spec.ts create mode 100644 src/common/http/response/parse-query-id-list.ts diff --git a/drizzle/migrations/0014_sales_invoice_location.sql b/drizzle/migrations/0014_sales_invoice_location.sql new file mode 100644 index 0000000..0660168 --- /dev/null +++ b/drizzle/migrations/0014_sales_invoice_location.sql @@ -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; diff --git a/drizzle/migrations/meta/_journal.json b/drizzle/migrations/meta/_journal.json index 8ecf5ab..5793e43 100644 --- a/drizzle/migrations/meta/_journal.json +++ b/drizzle/migrations/meta/_journal.json @@ -99,6 +99,13 @@ "when": 1787561000000, "tag": "0013_reports", "breakpoints": true + }, + { + "idx": 14, + "version": "7", + "when": 1787562000000, + "tag": "0014_sales_invoice_location", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/common/http/response/index.ts b/src/common/http/response/index.ts index bc55cc1..63e92bd 100644 --- a/src/common/http/response/index.ts +++ b/src/common/http/response/index.ts @@ -47,3 +47,4 @@ export { type OrderDefault, type OrderType, } from './order-clause'; +export { parseQueryIdList } from './parse-query-id-list'; diff --git a/src/common/http/response/parse-query-id-list.spec.ts b/src/common/http/response/parse-query-id-list.spec.ts new file mode 100644 index 0000000..d0c0e62 --- /dev/null +++ b/src/common/http/response/parse-query-id-list.spec.ts @@ -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(); + }); +}); diff --git a/src/common/http/response/parse-query-id-list.ts b/src/common/http/response/parse-query-id-list.ts new file mode 100644 index 0000000..019b1fa --- /dev/null +++ b/src/common/http/response/parse-query-id-list.ts @@ -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; +} diff --git a/src/database/sales-invoices-table.ts b/src/database/sales-invoices-table.ts index 332e785..ea0677e 100644 --- a/src/database/sales-invoices-table.ts +++ b/src/database/sales-invoices-table.ts @@ -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), }, diff --git a/src/modules/field/plans/plans.service.spec.ts b/src/modules/field/plans/plans.service.spec.ts index 7e72667..ded3489 100644 --- a/src/modules/field/plans/plans.service.spec.ts +++ b/src/modules/field/plans/plans.service.spec.ts @@ -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); diff --git a/src/modules/field/plans/plans.service.ts b/src/modules/field/plans/plans.service.ts index 956d347..48beb2d 100644 --- a/src/modules/field/plans/plans.service.ts +++ b/src/modules/field/plans/plans.service.ts @@ -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 { + private async assertInvoices( + ids: readonly string[], + customerIds: readonly string[], + ): Promise { + 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 { + private async assertPackingSlips( + ids: readonly string[], + customerIds: readonly string[], + ): Promise { + 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); diff --git a/src/modules/reports/shared/helpers/report-query-params.spec.ts b/src/modules/reports/shared/helpers/report-query-params.spec.ts index 6a9cd7d..ed17fd5 100644 --- a/src/modules/reports/shared/helpers/report-query-params.spec.ts +++ b/src/modules/reports/shared/helpers/report-query-params.spec.ts @@ -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']); }); }); diff --git a/src/modules/reports/shared/helpers/report-query-params.ts b/src/modules/reports/shared/helpers/report-query-params.ts index 8eee774..bc58091 100644 --- a/src/modules/reports/shared/helpers/report-query-params.ts +++ b/src/modules/reports/shared/helpers/report-query-params.ts @@ -1,10 +1,6 @@ -export function parseGroupNamesQuery( - query: Record, -): string[] { +export function parseGroupNamesQuery(query: Record): string[] { const raw = - query.groupNames ?? - query['groupNames[]'] ?? - query['groupNames[0]']; + query.groupNames ?? query['groupNames[]'] ?? query['groupNames[0]']; if (raw === undefined || raw === null || raw === '') { return []; diff --git a/src/modules/sales/packing-slips/dto/packing-slip.dto.ts b/src/modules/sales/packing-slips/dto/packing-slip.dto.ts index c6db562..df9fc3a 100644 --- a/src/modules/sales/packing-slips/dto/packing-slip.dto.ts +++ b/src/modules/sales/packing-slips/dto/packing-slip.dto.ts @@ -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') diff --git a/src/modules/sales/packing-slips/packing-slip.ts b/src/modules/sales/packing-slips/packing-slip.ts index ca4ccee..7f6abad 100644 --- a/src/modules/sales/packing-slips/packing-slip.ts +++ b/src/modules/sales/packing-slips/packing-slip.ts @@ -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; diff --git a/src/modules/sales/packing-slips/packing-slips.repository.ts b/src/modules/sales/packing-slips/packing-slips.repository.ts index 67c60de..9eaef7e 100644 --- a/src/modules/sales/packing-slips/packing-slips.repository.ts +++ b/src/modules/sales/packing-slips/packing-slips.repository.ts @@ -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) { diff --git a/src/modules/sales/packing-slips/packing-slips.service.ts b/src/modules/sales/packing-slips/packing-slips.service.ts index afe5609..4d962a5 100644 --- a/src/modules/sales/packing-slips/packing-slips.service.ts +++ b/src/modules/sales/packing-slips/packing-slips.service.ts @@ -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, diff --git a/src/modules/sales/sales-invoices/dto/sales-invoice.dto.ts b/src/modules/sales/sales-invoices/dto/sales-invoice.dto.ts index 000b411..5a9cad0 100644 --- a/src/modules/sales/sales-invoices/dto/sales-invoice.dto.ts +++ b/src/modules/sales/sales-invoices/dto/sales-invoice.dto.ts @@ -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() diff --git a/src/modules/sales/sales-invoices/sales-invoice.ts b/src/modules/sales/sales-invoices/sales-invoice.ts index f7b3798..ea1008a 100644 --- a/src/modules/sales/sales-invoices/sales-invoice.ts +++ b/src/modules/sales/sales-invoices/sales-invoice.ts @@ -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; diff --git a/src/modules/sales/sales-invoices/sales-invoices-write.controller.ts b/src/modules/sales/sales-invoices/sales-invoices-write.controller.ts index 0c5f174..08ff597 100644 --- a/src/modules/sales/sales-invoices/sales-invoices-write.controller.ts +++ b/src/modules/sales/sales-invoices/sales-invoices-write.controller.ts @@ -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, diff --git a/src/modules/sales/sales-invoices/sales-invoices.repository.ts b/src/modules/sales/sales-invoices/sales-invoices.repository.ts index 7229484..6a3ed8a 100644 --- a/src/modules/sales/sales-invoices/sales-invoices.repository.ts +++ b/src/modules/sales/sales-invoices/sales-invoices.repository.ts @@ -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) => ({ diff --git a/src/modules/sales/sales-invoices/sales-invoices.service.spec.ts b/src/modules/sales/sales-invoices/sales-invoices.service.spec.ts index 9e3aabc..cfc3ae5 100644 --- a/src/modules/sales/sales-invoices/sales-invoices.service.spec.ts +++ b/src/modules/sales/sales-invoices/sales-invoices.service.spec.ts @@ -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: [] }), diff --git a/src/modules/sales/sales-invoices/sales-invoices.service.ts b/src/modules/sales/sales-invoices/sales-invoices.service.ts index efbee88..5e37ae0 100644 --- a/src/modules/sales/sales-invoices/sales-invoices.service.ts +++ b/src/modules/sales/sales-invoices/sales-invoices.service.ts @@ -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); diff --git a/test/plans.e2e-spec.ts b/test/plans.e2e-spec.ts index 3834982..0bfc528 100644 --- a/test/plans.e2e-spec.ts +++ b/test/plans.e2e-spec.ts @@ -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; @@ -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; diff --git a/test/sales-invoices.e2e-spec.ts b/test/sales-invoices.e2e-spec.ts index 9e959f0..ca3e4bf 100644 --- a/test/sales-invoices.e2e-spec.ts +++ b/test/sales-invoices.e2e-spec.ts @@ -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; diff --git a/test/sales-payments.e2e-spec.ts b/test/sales-payments.e2e-spec.ts index ad80d11..0c9c970 100644 --- a/test/sales-payments.e2e-spec.ts +++ b/test/sales-payments.e2e-spec.ts @@ -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,