Add products and sales management modules with database schema and validation
- Introduced `ProductsModule` to manage product data, including read and write controllers. - Created database migrations for the `products`, `sales_requests`, `sales_orders`, `sales_invoices`, and related tables, including constraints and unique indexes. - Implemented validation for product fields such as code, name, unit, and brand with corresponding utility functions. - Developed service and repository layers for handling product and sales data operations. - Added unit tests for the products and sales services, repositories, and controllers to ensure functionality and correctness. - Updated application module to include the new `ProductsModule` and related sales modules for better organization.
This commit is contained in:
@@ -6,6 +6,7 @@ import loadEnv from './config/env';
|
||||
import { DatabaseModule } from './database/database.module';
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
import { ConfigurationModule } from './modules/configuration/configuration.module';
|
||||
import { SalesModule } from './modules/sales/sales.module';
|
||||
import { PrivilegesModule } from './modules/privileges/privileges.module';
|
||||
import { UsersModule } from './modules/users/users.module';
|
||||
|
||||
@@ -20,6 +21,7 @@ import { UsersModule } from './modules/users/users.module';
|
||||
AuthModule,
|
||||
PrivilegesModule,
|
||||
ConfigurationModule,
|
||||
SalesModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Decimal } from './decimal';
|
||||
import { InvalidDecimalError } from './invalid-decimal.error';
|
||||
|
||||
describe('Decimal', () => {
|
||||
describe('create', () => {
|
||||
it('accepts canonical scale-4 strings', () => {
|
||||
expect(Decimal.create('10.5000').value).toBe('10.5000');
|
||||
expect(Decimal.create('0').value).toBe('0.0000');
|
||||
expect(Decimal.create('0.5').value).toBe('0.5000');
|
||||
});
|
||||
|
||||
it('trims surrounding whitespace', () => {
|
||||
expect(Decimal.create(' 12.34 ').value).toBe('12.3400');
|
||||
});
|
||||
|
||||
it('accepts integer-looking numbers at the HTTP edge', () => {
|
||||
expect(Decimal.create(10).value).toBe('10.0000');
|
||||
expect(Decimal.create(0).value).toBe('0.0000');
|
||||
});
|
||||
|
||||
it('accepts a leading plus or minus', () => {
|
||||
expect(Decimal.create('+2.5').value).toBe('2.5000');
|
||||
expect(Decimal.create('-2.5').value).toBe('-2.5000');
|
||||
});
|
||||
|
||||
it('rejects more than 4 fractional digits', () => {
|
||||
expect(() => Decimal.create('1.23456')).toThrow(InvalidDecimalError);
|
||||
});
|
||||
|
||||
it('rejects non-finite numbers and non-numeric strings', () => {
|
||||
expect(() => Decimal.create(Number.NaN)).toThrow(InvalidDecimalError);
|
||||
expect(() => Decimal.create(Number.POSITIVE_INFINITY)).toThrow(
|
||||
InvalidDecimalError,
|
||||
);
|
||||
expect(() => Decimal.create('abc')).toThrow(InvalidDecimalError);
|
||||
expect(() => Decimal.create('')).toThrow(InvalidDecimalError);
|
||||
expect(() => Decimal.create(' ')).toThrow(InvalidDecimalError);
|
||||
expect(() => Decimal.create('1e3')).toThrow(InvalidDecimalError);
|
||||
});
|
||||
|
||||
it('rejects values that exceed precision 18', () => {
|
||||
expect(() => Decimal.create('123456789012345.0000')).toThrow(
|
||||
InvalidDecimalError,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not echo raw input in the error message', () => {
|
||||
expect(() => Decimal.create('secret-1.23')).toThrow('Invalid decimal');
|
||||
try {
|
||||
Decimal.create('secret-1.23');
|
||||
} catch (error) {
|
||||
expect((error as Error).message).not.toContain('secret-1.23');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('arithmetic', () => {
|
||||
it('adds, subtracts, and multiplies at scale 4', () => {
|
||||
const a = Decimal.create('2.5000');
|
||||
const b = Decimal.create('1.2500');
|
||||
expect(a.add(b).value).toBe('3.7500');
|
||||
expect(a.subtract(b).value).toBe('1.2500');
|
||||
expect(a.multiply(b).value).toBe('3.1250');
|
||||
});
|
||||
|
||||
it('compares values', () => {
|
||||
const a = Decimal.create('1.0000');
|
||||
const b = Decimal.create('2.0000');
|
||||
expect(a.compare(b)).toBe(-1);
|
||||
expect(b.compare(a)).toBe(1);
|
||||
expect(a.compare(Decimal.create('1'))).toBe(0);
|
||||
expect(a.equals(Decimal.create('1.0000'))).toBe(true);
|
||||
expect(a.equals(b)).toBe(false);
|
||||
expect(Decimal.create('0').isZero()).toBe(true);
|
||||
expect(Decimal.create('-1').isNegative()).toBe(true);
|
||||
expect(Decimal.create('0.0001').isPositive()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('serialization', () => {
|
||||
it('toString and toJSON return the canonical string', () => {
|
||||
const value = Decimal.create('9.1');
|
||||
expect(value.toString()).toBe('9.1000');
|
||||
expect(value.toJSON()).toBe('9.1000');
|
||||
expect(JSON.stringify({ price: value })).toBe('{"price":"9.1000"}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('construction', () => {
|
||||
it('cannot be constructed with new Decimal()', () => {
|
||||
expect(
|
||||
() => new (Decimal as unknown as new (...args: unknown[]) => Decimal)(),
|
||||
).toThrow(TypeError);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import { InvalidDecimalError } from './invalid-decimal.error';
|
||||
|
||||
export const DECIMAL_SCALE = 4;
|
||||
export const DECIMAL_PRECISION = 18;
|
||||
const DECIMAL_FACTOR = 10n ** BigInt(DECIMAL_SCALE);
|
||||
const MAX_UNSCALED =
|
||||
10n ** BigInt(DECIMAL_PRECISION) - 1n; /* 18 digits of unscaled integer */
|
||||
|
||||
const DECIMAL_PATTERN = /^[+-]?(?:\d+|\d+\.\d{1,4}|\.\d{1,4})$/;
|
||||
|
||||
export class Decimal {
|
||||
private static readonly createToken = Symbol('Decimal.create');
|
||||
|
||||
private constructor(
|
||||
private readonly unscaled: bigint,
|
||||
token: symbol,
|
||||
) {
|
||||
if (token !== Decimal.createToken) {
|
||||
throw new TypeError('Decimal can only be created via Decimal.create()');
|
||||
}
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Decimal from a string or an integer-looking number.
|
||||
* Canonical scale is 4 (e.g. 10.5 → 10.5000). Precision is 18.
|
||||
*/
|
||||
static create(raw: string | number): Decimal {
|
||||
const text = Decimal.normalizeRaw(raw);
|
||||
if (!DECIMAL_PATTERN.test(text)) {
|
||||
throw new InvalidDecimalError();
|
||||
}
|
||||
|
||||
const negative = text.startsWith('-');
|
||||
const unsigned =
|
||||
text.startsWith('+') || text.startsWith('-') ? text.slice(1) : text;
|
||||
const [wholePart, fractionPart = ''] = unsigned.split('.');
|
||||
const whole = wholePart === '' ? '0' : wholePart;
|
||||
const fraction = fractionPart.padEnd(DECIMAL_SCALE, '0');
|
||||
const digits = `${whole}${fraction}`.replace(/^0+(?=\d)/, '');
|
||||
let unscaled = BigInt(digits);
|
||||
if (negative) {
|
||||
unscaled = -unscaled;
|
||||
}
|
||||
if (unscaled > MAX_UNSCALED || unscaled < -MAX_UNSCALED) {
|
||||
throw new InvalidDecimalError();
|
||||
}
|
||||
return new Decimal(unscaled, Decimal.createToken);
|
||||
}
|
||||
|
||||
static zero(): Decimal {
|
||||
return new Decimal(0n, Decimal.createToken);
|
||||
}
|
||||
|
||||
get value(): string {
|
||||
return this.format();
|
||||
}
|
||||
|
||||
add(other: Decimal): Decimal {
|
||||
return Decimal.fromUnscaled(this.unscaled + other.unscaled);
|
||||
}
|
||||
|
||||
subtract(other: Decimal): Decimal {
|
||||
return Decimal.fromUnscaled(this.unscaled - other.unscaled);
|
||||
}
|
||||
|
||||
multiply(other: Decimal): Decimal {
|
||||
const product = this.unscaled * other.unscaled;
|
||||
const half = DECIMAL_FACTOR / 2n;
|
||||
const remainder = product % DECIMAL_FACTOR;
|
||||
let quotient = product / DECIMAL_FACTOR;
|
||||
const absRemainder = remainder < 0n ? -remainder : remainder;
|
||||
if (absRemainder >= half) {
|
||||
quotient += product < 0n ? -1n : 1n;
|
||||
}
|
||||
return Decimal.fromUnscaled(quotient);
|
||||
}
|
||||
|
||||
compare(other: Decimal): -1 | 0 | 1 {
|
||||
if (this.unscaled < other.unscaled) {
|
||||
return -1;
|
||||
}
|
||||
if (this.unscaled > other.unscaled) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
equals(other: Decimal): boolean {
|
||||
return other instanceof Decimal && this.unscaled === other.unscaled;
|
||||
}
|
||||
|
||||
isZero(): boolean {
|
||||
return this.unscaled === 0n;
|
||||
}
|
||||
|
||||
isNegative(): boolean {
|
||||
return this.unscaled < 0n;
|
||||
}
|
||||
|
||||
isPositive(): boolean {
|
||||
return this.unscaled > 0n;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return this.format();
|
||||
}
|
||||
|
||||
toJSON(): string {
|
||||
return this.format();
|
||||
}
|
||||
|
||||
private format(): string {
|
||||
const negative = this.unscaled < 0n;
|
||||
const abs = negative ? -this.unscaled : this.unscaled;
|
||||
const padded = abs.toString().padStart(DECIMAL_SCALE + 1, '0');
|
||||
const whole = padded.slice(0, -DECIMAL_SCALE);
|
||||
const fraction = padded.slice(-DECIMAL_SCALE);
|
||||
return `${negative ? '-' : ''}${whole}.${fraction}`;
|
||||
}
|
||||
|
||||
private static fromUnscaled(unscaled: bigint): Decimal {
|
||||
if (unscaled > MAX_UNSCALED || unscaled < -MAX_UNSCALED) {
|
||||
throw new InvalidDecimalError();
|
||||
}
|
||||
return new Decimal(unscaled, Decimal.createToken);
|
||||
}
|
||||
|
||||
private static normalizeRaw(raw: string | number): string {
|
||||
if (typeof raw === 'number') {
|
||||
if (!Number.isInteger(raw) || !Number.isSafeInteger(raw)) {
|
||||
throw new InvalidDecimalError();
|
||||
}
|
||||
return String(raw);
|
||||
}
|
||||
if (typeof raw !== 'string') {
|
||||
throw new InvalidDecimalError();
|
||||
}
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed === '') {
|
||||
throw new InvalidDecimalError();
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class InvalidDecimalError extends Error {
|
||||
constructor() {
|
||||
super('Invalid decimal');
|
||||
this.name = 'InvalidDecimalError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { integer, pgTable, primaryKey, varchar } from 'drizzle-orm/pg-core';
|
||||
|
||||
/**
|
||||
* Per-prefix daily counters used to generate document codes.
|
||||
*/
|
||||
export const documentSequences = pgTable(
|
||||
'document_sequences',
|
||||
{
|
||||
prefix: varchar('prefix', { length: 8 }).notNull(),
|
||||
period: varchar('period', { length: 8 }).notNull(),
|
||||
lastValue: integer('last_value').notNull(),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.prefix, t.period] })],
|
||||
);
|
||||
|
||||
export type DocumentSequenceRow = typeof documentSequences.$inferSelect;
|
||||
export type NewDocumentSequenceRow = typeof documentSequences.$inferInsert;
|
||||
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
bigint,
|
||||
doublePrecision,
|
||||
index,
|
||||
numeric,
|
||||
pgTable,
|
||||
text,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
varchar,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { customers } from './customers-table';
|
||||
import { products } from './products-table';
|
||||
import { primaryEntityColumns } from './primary-entity-columns';
|
||||
import { salesOrders } from './sales-orders-table';
|
||||
import { users } from './schema';
|
||||
|
||||
export const packingSlips = pgTable(
|
||||
'packing_slips',
|
||||
{
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
code: varchar('code', { length: 32 }).notNull(),
|
||||
salesOrderId: uuid('sales_order_id').references(() => salesOrders.id, {
|
||||
onDelete: 'restrict',
|
||||
}),
|
||||
salesOrderNumber: varchar('sales_order_number', { length: 32 }),
|
||||
date: bigint('date', { mode: 'number' }).notNull(),
|
||||
customerId: uuid('customer_id')
|
||||
.notNull()
|
||||
.references(() => customers.id, { onDelete: 'restrict' }),
|
||||
address: text('address').notNull(),
|
||||
latitude: doublePrecision('latitude'),
|
||||
longitude: doublePrecision('longitude'),
|
||||
notes: text('notes'),
|
||||
...primaryEntityColumns(users),
|
||||
},
|
||||
(t) => [uniqueIndex('packing_slips_code_unique').on(t.code)],
|
||||
);
|
||||
|
||||
export const packingSlipProducts = pgTable(
|
||||
'packing_slip_products',
|
||||
{
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
packingSlipId: uuid('packing_slip_id')
|
||||
.notNull()
|
||||
.references(() => packingSlips.id, { onDelete: 'cascade' }),
|
||||
productId: uuid('product_id')
|
||||
.notNull()
|
||||
.references(() => products.id, { onDelete: 'restrict' }),
|
||||
quantity: numeric('quantity', { precision: 18, scale: 4 }).notNull(),
|
||||
price: numeric('price', { precision: 18, scale: 4 }).notNull(),
|
||||
},
|
||||
(t) => [
|
||||
index('packing_slip_products_packing_slip_id_idx').on(t.packingSlipId),
|
||||
],
|
||||
);
|
||||
|
||||
export type PackingSlipRow = typeof packingSlips.$inferSelect;
|
||||
export type NewPackingSlipRow = typeof packingSlips.$inferInsert;
|
||||
export type PackingSlipProductRow = typeof packingSlipProducts.$inferSelect;
|
||||
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
numeric,
|
||||
pgTable,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
varchar,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { primaryEntityColumns } from './primary-entity-columns';
|
||||
import { users } from './schema';
|
||||
|
||||
/**
|
||||
* Products (primary aggregate).
|
||||
* Kept in a separate module so Drizzle's table type stays resolvable.
|
||||
*/
|
||||
export const products = pgTable(
|
||||
'products',
|
||||
{
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
code: varchar('code', { length: 32 }).notNull(),
|
||||
name: varchar('name', { length: 128 }).notNull(),
|
||||
unit: varchar('unit', { length: 16 }),
|
||||
price: numeric('price', { precision: 18, scale: 4 }),
|
||||
brand: varchar('brand', { length: 64 }),
|
||||
...primaryEntityColumns(users),
|
||||
},
|
||||
(t) => [uniqueIndex('products_code_unique').on(t.code)],
|
||||
);
|
||||
|
||||
export type ProductRow = typeof products.$inferSelect;
|
||||
export type NewProductRow = typeof products.$inferInsert;
|
||||
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
bigint,
|
||||
index,
|
||||
numeric,
|
||||
pgTable,
|
||||
text,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
varchar,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { branches } from './branches-table';
|
||||
import { customers } from './customers-table';
|
||||
import { employees } from './employees-table';
|
||||
import { packingSlips } from './packing-slips-table';
|
||||
import { products } from './products-table';
|
||||
import { primaryEntityColumns } from './primary-entity-columns';
|
||||
import { salesOrders } from './sales-orders-table';
|
||||
import { divisions, users } from './schema';
|
||||
|
||||
export const salesInvoices = pgTable(
|
||||
'sales_invoices',
|
||||
{
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
code: varchar('code', { length: 32 }).notNull(),
|
||||
date: bigint('date', { mode: 'number' }).notNull(),
|
||||
salesPersonId: uuid('sales_person_id')
|
||||
.notNull()
|
||||
.references(() => employees.id, { onDelete: 'restrict' }),
|
||||
branchId: uuid('branch_id')
|
||||
.notNull()
|
||||
.references(() => branches.id, { onDelete: 'restrict' }),
|
||||
divisionId: uuid('division_id')
|
||||
.notNull()
|
||||
.references(() => divisions.id, { onDelete: 'restrict' }),
|
||||
customerId: uuid('customer_id')
|
||||
.notNull()
|
||||
.references(() => customers.id, { onDelete: 'restrict' }),
|
||||
salesOrderId: uuid('sales_order_id').references(() => salesOrders.id, {
|
||||
onDelete: 'restrict',
|
||||
}),
|
||||
salesOrderCode: varchar('sales_order_code', { length: 32 }),
|
||||
packingSlipId: uuid('packing_slip_id').references(() => packingSlips.id, {
|
||||
onDelete: 'restrict',
|
||||
}),
|
||||
packingSlipCode: varchar('packing_slip_code', { length: 32 }),
|
||||
balance: numeric('balance', { precision: 18, scale: 4 }).notNull(),
|
||||
notes: text('notes'),
|
||||
...primaryEntityColumns(users),
|
||||
},
|
||||
(t) => [uniqueIndex('sales_invoices_code_unique').on(t.code)],
|
||||
);
|
||||
|
||||
export const salesInvoiceProducts = pgTable(
|
||||
'sales_invoice_products',
|
||||
{
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
salesInvoiceId: uuid('sales_invoice_id')
|
||||
.notNull()
|
||||
.references(() => salesInvoices.id, { onDelete: 'cascade' }),
|
||||
productId: uuid('product_id')
|
||||
.notNull()
|
||||
.references(() => products.id, { onDelete: 'restrict' }),
|
||||
quantity: numeric('quantity', { precision: 18, scale: 4 }).notNull(),
|
||||
price: numeric('price', { precision: 18, scale: 4 }).notNull(),
|
||||
},
|
||||
(t) => [index('sales_invoice_products_invoice_id_idx').on(t.salesInvoiceId)],
|
||||
);
|
||||
|
||||
export type SalesInvoiceRow = typeof salesInvoices.$inferSelect;
|
||||
export type NewSalesInvoiceRow = typeof salesInvoices.$inferInsert;
|
||||
export type SalesInvoiceProductRow = typeof salesInvoiceProducts.$inferSelect;
|
||||
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
bigint,
|
||||
doublePrecision,
|
||||
index,
|
||||
numeric,
|
||||
pgTable,
|
||||
text,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
varchar,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { branches } from './branches-table';
|
||||
import { customers } from './customers-table';
|
||||
import { employees } from './employees-table';
|
||||
import { products } from './products-table';
|
||||
import { salesRequests } from './sales-requests-table';
|
||||
import { primaryEntityColumns } from './primary-entity-columns';
|
||||
import { divisions, users } from './schema';
|
||||
|
||||
export const salesOrders = pgTable(
|
||||
'sales_orders',
|
||||
{
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
code: varchar('code', { length: 32 }).notNull(),
|
||||
salesRequestId: uuid('sales_request_id').references(
|
||||
() => salesRequests.id,
|
||||
{
|
||||
onDelete: 'restrict',
|
||||
},
|
||||
),
|
||||
date: bigint('date', { mode: 'number' }).notNull(),
|
||||
salesPersonId: uuid('sales_person_id')
|
||||
.notNull()
|
||||
.references(() => employees.id, { onDelete: 'restrict' }),
|
||||
branchId: uuid('branch_id')
|
||||
.notNull()
|
||||
.references(() => branches.id, { onDelete: 'restrict' }),
|
||||
divisionId: uuid('division_id')
|
||||
.notNull()
|
||||
.references(() => divisions.id, { onDelete: 'restrict' }),
|
||||
customerId: uuid('customer_id')
|
||||
.notNull()
|
||||
.references(() => customers.id, { onDelete: 'restrict' }),
|
||||
address: text('address').notNull(),
|
||||
latitude: doublePrecision('latitude'),
|
||||
longitude: doublePrecision('longitude'),
|
||||
notes: text('notes'),
|
||||
...primaryEntityColumns(users),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('sales_orders_code_unique').on(t.code),
|
||||
index('sales_orders_customer_id_idx').on(t.customerId),
|
||||
],
|
||||
);
|
||||
|
||||
export const salesOrderProducts = pgTable(
|
||||
'sales_order_products',
|
||||
{
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
salesOrderId: uuid('sales_order_id')
|
||||
.notNull()
|
||||
.references(() => salesOrders.id, { onDelete: 'cascade' }),
|
||||
productId: uuid('product_id')
|
||||
.notNull()
|
||||
.references(() => products.id, { onDelete: 'restrict' }),
|
||||
quantity: numeric('quantity', { precision: 18, scale: 4 }).notNull(),
|
||||
price: numeric('price', { precision: 18, scale: 4 }).notNull(),
|
||||
},
|
||||
(t) => [index('sales_order_products_request_id_idx').on(t.salesOrderId)],
|
||||
);
|
||||
|
||||
export const salesOrderImages = pgTable(
|
||||
'sales_order_images',
|
||||
{
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
salesOrderId: uuid('sales_order_id')
|
||||
.notNull()
|
||||
.references(() => salesOrders.id, { onDelete: 'cascade' }),
|
||||
url: varchar('url', { length: 2048 }).notNull(),
|
||||
description: varchar('description', { length: 255 }),
|
||||
},
|
||||
(t) => [index('sales_order_images_request_id_idx').on(t.salesOrderId)],
|
||||
);
|
||||
|
||||
export type SalesOrderRow = typeof salesOrders.$inferSelect;
|
||||
export type NewSalesOrderRow = typeof salesOrders.$inferInsert;
|
||||
export type SalesOrderProductRow = typeof salesOrderProducts.$inferSelect;
|
||||
export type SalesOrderImageRow = typeof salesOrderImages.$inferSelect;
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
bigint,
|
||||
index,
|
||||
numeric,
|
||||
pgTable,
|
||||
text,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
varchar,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { primaryEntityColumns } from './primary-entity-columns';
|
||||
import { salesInvoices } from './sales-invoices-table';
|
||||
import { users } from './schema';
|
||||
|
||||
export const salesPayments = pgTable(
|
||||
'sales_payments',
|
||||
{
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
code: varchar('code', { length: 32 }).notNull(),
|
||||
date: bigint('date', { mode: 'number' }).notNull(),
|
||||
notes: text('notes'),
|
||||
...primaryEntityColumns(users),
|
||||
},
|
||||
(t) => [uniqueIndex('sales_payments_code_unique').on(t.code)],
|
||||
);
|
||||
|
||||
export const salesPaymentImages = pgTable(
|
||||
'sales_payment_images',
|
||||
{
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
salesPaymentId: uuid('sales_payment_id')
|
||||
.notNull()
|
||||
.references(() => salesPayments.id, { onDelete: 'cascade' }),
|
||||
url: varchar('url', { length: 2048 }).notNull(),
|
||||
description: varchar('description', { length: 255 }),
|
||||
},
|
||||
(t) => [index('sales_payment_images_payment_id_idx').on(t.salesPaymentId)],
|
||||
);
|
||||
|
||||
export const salesPaymentInvoices = pgTable(
|
||||
'sales_payment_invoices',
|
||||
{
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
salesPaymentId: uuid('sales_payment_id')
|
||||
.notNull()
|
||||
.references(() => salesPayments.id, { onDelete: 'cascade' }),
|
||||
salesInvoiceId: uuid('sales_invoice_id')
|
||||
.notNull()
|
||||
.references(() => salesInvoices.id, { onDelete: 'restrict' }),
|
||||
amount: numeric('amount', { precision: 18, scale: 4 }).notNull(),
|
||||
},
|
||||
(t) => [index('sales_payment_invoices_payment_id_idx').on(t.salesPaymentId)],
|
||||
);
|
||||
|
||||
export type SalesPaymentRow = typeof salesPayments.$inferSelect;
|
||||
export type NewSalesPaymentRow = typeof salesPayments.$inferInsert;
|
||||
export type SalesPaymentImageRow = typeof salesPaymentImages.$inferSelect;
|
||||
export type SalesPaymentInvoiceRow = typeof salesPaymentInvoices.$inferSelect;
|
||||
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
bigint,
|
||||
doublePrecision,
|
||||
index,
|
||||
numeric,
|
||||
pgTable,
|
||||
text,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
varchar,
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { branches } from './branches-table';
|
||||
import { customers } from './customers-table';
|
||||
import { employees } from './employees-table';
|
||||
import { products } from './products-table';
|
||||
import { primaryEntityColumns } from './primary-entity-columns';
|
||||
import { divisions, users } from './schema';
|
||||
|
||||
export const salesRequests = pgTable(
|
||||
'sales_requests',
|
||||
{
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
code: varchar('code', { length: 32 }).notNull(),
|
||||
date: bigint('date', { mode: 'number' }).notNull(),
|
||||
salesPersonId: uuid('sales_person_id')
|
||||
.notNull()
|
||||
.references(() => employees.id, { onDelete: 'restrict' }),
|
||||
branchId: uuid('branch_id')
|
||||
.notNull()
|
||||
.references(() => branches.id, { onDelete: 'restrict' }),
|
||||
divisionId: uuid('division_id')
|
||||
.notNull()
|
||||
.references(() => divisions.id, { onDelete: 'restrict' }),
|
||||
customerId: uuid('customer_id')
|
||||
.notNull()
|
||||
.references(() => customers.id, { onDelete: 'restrict' }),
|
||||
address: text('address').notNull(),
|
||||
latitude: doublePrecision('latitude'),
|
||||
longitude: doublePrecision('longitude'),
|
||||
notes: text('notes'),
|
||||
...primaryEntityColumns(users),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('sales_requests_code_unique').on(t.code),
|
||||
index('sales_requests_customer_id_idx').on(t.customerId),
|
||||
],
|
||||
);
|
||||
|
||||
export const salesRequestProducts = pgTable(
|
||||
'sales_request_products',
|
||||
{
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
salesRequestId: uuid('sales_request_id')
|
||||
.notNull()
|
||||
.references(() => salesRequests.id, { onDelete: 'cascade' }),
|
||||
productId: uuid('product_id')
|
||||
.notNull()
|
||||
.references(() => products.id, { onDelete: 'restrict' }),
|
||||
quantity: numeric('quantity', { precision: 18, scale: 4 }).notNull(),
|
||||
price: numeric('price', { precision: 18, scale: 4 }).notNull(),
|
||||
},
|
||||
(t) => [index('sales_request_products_request_id_idx').on(t.salesRequestId)],
|
||||
);
|
||||
|
||||
export const salesRequestImages = pgTable(
|
||||
'sales_request_images',
|
||||
{
|
||||
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||
salesRequestId: uuid('sales_request_id')
|
||||
.notNull()
|
||||
.references(() => salesRequests.id, { onDelete: 'cascade' }),
|
||||
url: varchar('url', { length: 2048 }).notNull(),
|
||||
description: varchar('description', { length: 255 }),
|
||||
},
|
||||
(t) => [index('sales_request_images_request_id_idx').on(t.salesRequestId)],
|
||||
);
|
||||
|
||||
export type SalesRequestRow = typeof salesRequests.$inferSelect;
|
||||
export type NewSalesRequestRow = typeof salesRequests.$inferInsert;
|
||||
export type SalesRequestProductRow = typeof salesRequestProducts.$inferSelect;
|
||||
export type SalesRequestImageRow = typeof salesRequestImages.$inferSelect;
|
||||
@@ -156,3 +156,57 @@ export {
|
||||
type EmployeeRow,
|
||||
type NewEmployeeRow,
|
||||
} from './employees-table';
|
||||
|
||||
export {
|
||||
products,
|
||||
type ProductRow,
|
||||
type NewProductRow,
|
||||
} from './products-table';
|
||||
|
||||
export {
|
||||
documentSequences,
|
||||
type DocumentSequenceRow,
|
||||
type NewDocumentSequenceRow,
|
||||
} from './document-sequences-table';
|
||||
export {
|
||||
salesRequestImages,
|
||||
salesRequestProducts,
|
||||
salesRequests,
|
||||
type NewSalesRequestRow,
|
||||
type SalesRequestImageRow,
|
||||
type SalesRequestProductRow,
|
||||
type SalesRequestRow,
|
||||
} from './sales-requests-table';
|
||||
export {
|
||||
salesOrderImages,
|
||||
salesOrderProducts,
|
||||
salesOrders,
|
||||
type NewSalesOrderRow,
|
||||
type SalesOrderImageRow,
|
||||
type SalesOrderProductRow,
|
||||
type SalesOrderRow,
|
||||
} from './sales-orders-table';
|
||||
|
||||
export {
|
||||
packingSlipProducts,
|
||||
packingSlips,
|
||||
type NewPackingSlipRow,
|
||||
type PackingSlipProductRow,
|
||||
type PackingSlipRow,
|
||||
} from './packing-slips-table';
|
||||
export {
|
||||
salesInvoiceProducts,
|
||||
salesInvoices,
|
||||
type NewSalesInvoiceRow,
|
||||
type SalesInvoiceProductRow,
|
||||
type SalesInvoiceRow,
|
||||
} from './sales-invoices-table';
|
||||
export {
|
||||
salesPaymentImages,
|
||||
salesPaymentInvoices,
|
||||
salesPayments,
|
||||
type NewSalesPaymentRow,
|
||||
type SalesPaymentImageRow,
|
||||
type SalesPaymentInvoiceRow,
|
||||
type SalesPaymentRow,
|
||||
} from './sales-payments-table';
|
||||
|
||||
@@ -3,9 +3,22 @@ import { BranchesModule } from './branches/branches.module';
|
||||
import { CustomersModule } from './customers/customers.module';
|
||||
import { DivisionsModule } from './divisions/divisions.module';
|
||||
import { EmployeesModule } from './employees/employees.module';
|
||||
import { ProductsModule } from './products/products.module';
|
||||
|
||||
@Module({
|
||||
imports: [DivisionsModule, BranchesModule, CustomersModule, EmployeesModule],
|
||||
exports: [DivisionsModule, BranchesModule, CustomersModule, EmployeesModule],
|
||||
imports: [
|
||||
DivisionsModule,
|
||||
BranchesModule,
|
||||
CustomersModule,
|
||||
EmployeesModule,
|
||||
ProductsModule,
|
||||
],
|
||||
exports: [
|
||||
DivisionsModule,
|
||||
BranchesModule,
|
||||
CustomersModule,
|
||||
EmployeesModule,
|
||||
ProductsModule,
|
||||
],
|
||||
})
|
||||
export class ConfigurationModule {}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
ArrayNotEmpty,
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Matches,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
import { PaginationQueryDto } from '../../../../common/http/response';
|
||||
import { CORE_STATUSES } from '../../../../common/value-objects/status/status';
|
||||
import {
|
||||
PRODUCT_BRAND_MAX_LENGTH,
|
||||
PRODUCT_CODE_MAX_LENGTH,
|
||||
PRODUCT_CODE_PATTERN,
|
||||
PRODUCT_NAME_MAX_LENGTH,
|
||||
PRODUCT_NAME_PATTERN,
|
||||
PRODUCT_UNIT_MAX_LENGTH,
|
||||
PRODUCT_UNIT_PATTERN,
|
||||
} from '../product-fields';
|
||||
|
||||
export class CreateProductDto {
|
||||
@ApiProperty({ example: 'FUEL_95', maxLength: PRODUCT_CODE_MAX_LENGTH })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(PRODUCT_CODE_MAX_LENGTH)
|
||||
@Matches(PRODUCT_CODE_PATTERN, {
|
||||
message: 'code must contain only letters, numbers, and underscores',
|
||||
})
|
||||
code!: string;
|
||||
|
||||
@ApiProperty({
|
||||
example: 'Fuel 95',
|
||||
maxLength: PRODUCT_NAME_MAX_LENGTH,
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(PRODUCT_NAME_MAX_LENGTH)
|
||||
@Matches(PRODUCT_NAME_PATTERN, {
|
||||
message: 'name must contain only letters, digits, and common punctuation',
|
||||
})
|
||||
name!: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'L', maxLength: PRODUCT_UNIT_MAX_LENGTH })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(PRODUCT_UNIT_MAX_LENGTH)
|
||||
@Matches(PRODUCT_UNIT_PATTERN, {
|
||||
message: 'unit must contain only letters and numbers',
|
||||
})
|
||||
unit?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '12500.0000' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
price?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
example: 'Pertamina',
|
||||
maxLength: PRODUCT_BRAND_MAX_LENGTH,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(PRODUCT_BRAND_MAX_LENGTH)
|
||||
brand?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: CORE_STATUSES })
|
||||
@IsOptional()
|
||||
@IsIn([...CORE_STATUSES])
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export class UpdateProductDto {
|
||||
@ApiPropertyOptional({ example: 'FUEL_95' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(PRODUCT_CODE_MAX_LENGTH)
|
||||
@Matches(PRODUCT_CODE_PATTERN, {
|
||||
message: 'code must contain only letters, numbers, and underscores',
|
||||
})
|
||||
code?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Fuel 95' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(PRODUCT_NAME_MAX_LENGTH)
|
||||
@Matches(PRODUCT_NAME_PATTERN, {
|
||||
message: 'name must contain only letters, digits, and common punctuation',
|
||||
})
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'L', nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(PRODUCT_UNIT_MAX_LENGTH)
|
||||
unit?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ example: '12500.0000', nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
price?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Pertamina', nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(PRODUCT_BRAND_MAX_LENGTH)
|
||||
brand?: string | null;
|
||||
}
|
||||
|
||||
export class UpdateProductStatusDto {
|
||||
@ApiProperty({ enum: CORE_STATUSES })
|
||||
@IsIn([...CORE_STATUSES])
|
||||
status!: string;
|
||||
}
|
||||
|
||||
export class BulkIdsDto {
|
||||
@ApiProperty({ type: [String], format: 'uuid' })
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsUUID('4', { each: true })
|
||||
ids!: string[];
|
||||
}
|
||||
|
||||
export class BulkStatusDto {
|
||||
@ApiProperty({ type: [String], format: 'uuid' })
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsUUID('4', { each: true })
|
||||
ids!: string[];
|
||||
|
||||
@ApiProperty({ enum: CORE_STATUSES })
|
||||
@IsIn([...CORE_STATUSES])
|
||||
status!: string;
|
||||
}
|
||||
|
||||
export class ListProductsQueryDto extends PaginationQueryDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
code?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
unit?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
brand?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: CORE_STATUSES })
|
||||
@IsOptional()
|
||||
@IsIn([...CORE_STATUSES])
|
||||
status?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Case-insensitive match on code or name',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export class ProductDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@ApiProperty()
|
||||
code!: string;
|
||||
|
||||
@ApiProperty()
|
||||
name!: string;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
unit!: string | null;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true, example: '12500.0000' })
|
||||
price!: string | null;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
brand!: string | null;
|
||||
|
||||
@ApiProperty({ enum: CORE_STATUSES })
|
||||
status!: string;
|
||||
|
||||
@ApiProperty({ description: 'Unix ms' })
|
||||
createdAt!: number;
|
||||
|
||||
@ApiProperty({ description: 'Unix ms' })
|
||||
updatedAt!: number;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
createdBy!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
updatedBy!: string;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
PRODUCT_CODE_MAX_LENGTH,
|
||||
PRODUCT_NAME_MAX_LENGTH,
|
||||
isAllowedCsvUpload,
|
||||
isValidProductBrand,
|
||||
isValidProductCode,
|
||||
isValidProductName,
|
||||
isValidProductUnit,
|
||||
parseCsvRecord,
|
||||
} from './product-fields';
|
||||
|
||||
describe('product fields', () => {
|
||||
describe('isValidProductName', () => {
|
||||
it.each(['Fuel', 'Fuel 95', 'Oil (SAE 40)', 'A'])('accepts %s', (name) => {
|
||||
expect(isValidProductName(name)).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['', ' Fuel', 'Fuel ', 'Fuel 95'])('rejects %s', (name) => {
|
||||
expect(isValidProductName(name)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects names longer than the max length', () => {
|
||||
expect(isValidProductName('A'.repeat(PRODUCT_NAME_MAX_LENGTH + 1))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidProductCode', () => {
|
||||
it.each(['FUEL_95', 'A', 'p1'])('accepts %s', (code) => {
|
||||
expect(isValidProductCode(code)).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['', 'FUEL 95', 'FUEL-95'])('rejects %s', (code) => {
|
||||
expect(isValidProductCode(code)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects codes longer than the max length', () => {
|
||||
expect(isValidProductCode('A'.repeat(PRODUCT_CODE_MAX_LENGTH + 1))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidProductUnit and brand', () => {
|
||||
it('accepts unit and brand values', () => {
|
||||
expect(isValidProductUnit('L')).toBe(true);
|
||||
expect(isValidProductBrand('Pertamina')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects empty unit or brand', () => {
|
||||
expect(isValidProductUnit('')).toBe(false);
|
||||
expect(isValidProductBrand('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseCsvRecord', () => {
|
||||
it('keeps commas inside quoted fields', () => {
|
||||
expect(parseCsvRecord('FUEL_95,"Fuel, 95",L')).toEqual([
|
||||
'FUEL_95',
|
||||
'Fuel, 95',
|
||||
'L',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAllowedCsvUpload', () => {
|
||||
it('accepts csv mime or .csv names', () => {
|
||||
expect(
|
||||
isAllowedCsvUpload({
|
||||
mimetype: 'text/csv',
|
||||
originalname: 'x.txt',
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isAllowedCsvUpload({
|
||||
mimetype: 'application/octet-stream',
|
||||
originalname: 'products.csv',
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects non-csv files', () => {
|
||||
expect(
|
||||
isAllowedCsvUpload({
|
||||
mimetype: 'application/pdf',
|
||||
originalname: 'x.pdf',
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
export const PRODUCT_NAME_MAX_LENGTH = 128;
|
||||
export const PRODUCT_CODE_MAX_LENGTH = 32;
|
||||
export const PRODUCT_UNIT_MAX_LENGTH = 16;
|
||||
export const PRODUCT_BRAND_MAX_LENGTH = 64;
|
||||
|
||||
/** Alphanumeric and underscore; no spaces. */
|
||||
export const PRODUCT_CODE_PATTERN = /^[A-Za-z0-9_]+$/;
|
||||
|
||||
/** Letters, digits, and common punctuation with single spaces between tokens. */
|
||||
export const PRODUCT_NAME_PATTERN =
|
||||
/^[A-Za-z0-9][A-Za-z0-9+\-./()]*?(?: [A-Za-z0-9+\-./()]+)*$/;
|
||||
|
||||
export const PRODUCT_UNIT_PATTERN = /^[A-Za-z0-9]+$/;
|
||||
|
||||
export function isValidProductName(raw: string): boolean {
|
||||
return (
|
||||
typeof raw === 'string' &&
|
||||
raw.length > 0 &&
|
||||
raw.length <= PRODUCT_NAME_MAX_LENGTH &&
|
||||
PRODUCT_NAME_PATTERN.test(raw)
|
||||
);
|
||||
}
|
||||
|
||||
export function isValidProductCode(raw: string): boolean {
|
||||
return (
|
||||
typeof raw === 'string' &&
|
||||
raw.length > 0 &&
|
||||
raw.length <= PRODUCT_CODE_MAX_LENGTH &&
|
||||
PRODUCT_CODE_PATTERN.test(raw)
|
||||
);
|
||||
}
|
||||
|
||||
export function isValidProductUnit(raw: string): boolean {
|
||||
return (
|
||||
typeof raw === 'string' &&
|
||||
raw.length > 0 &&
|
||||
raw.length <= PRODUCT_UNIT_MAX_LENGTH &&
|
||||
PRODUCT_UNIT_PATTERN.test(raw)
|
||||
);
|
||||
}
|
||||
|
||||
export function isValidProductBrand(raw: string): boolean {
|
||||
return (
|
||||
typeof raw === 'string' &&
|
||||
raw.length > 0 &&
|
||||
raw.length <= PRODUCT_BRAND_MAX_LENGTH
|
||||
);
|
||||
}
|
||||
|
||||
/** RFC 4180-style record split that preserves commas inside quotes. */
|
||||
export function parseCsvRecord(line: string): string[] {
|
||||
const cells: string[] = [];
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
if (inQuotes) {
|
||||
if (ch === '"') {
|
||||
if (line[i + 1] === '"') {
|
||||
current += '"';
|
||||
i += 1;
|
||||
} else {
|
||||
inQuotes = false;
|
||||
}
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
} else if (ch === '"') {
|
||||
inQuotes = true;
|
||||
} else if (ch === ',') {
|
||||
cells.push(current.trim());
|
||||
current = '';
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
}
|
||||
cells.push(current.trim());
|
||||
return cells;
|
||||
}
|
||||
|
||||
export function isAllowedCsvUpload(file: {
|
||||
mimetype: string;
|
||||
originalname: string;
|
||||
}): boolean {
|
||||
return (
|
||||
file.mimetype.includes('csv') ||
|
||||
file.originalname.toLowerCase().endsWith('.csv')
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
|
||||
export type Product = {
|
||||
readonly id: string;
|
||||
readonly code: string;
|
||||
readonly name: string;
|
||||
readonly unit: string | null;
|
||||
readonly price: Decimal | null;
|
||||
readonly brand: string | null;
|
||||
readonly status: Status;
|
||||
readonly createdAt: DateTime;
|
||||
readonly updatedAt: DateTime;
|
||||
readonly createdBy: string;
|
||||
readonly updatedBy: string;
|
||||
};
|
||||
|
||||
export type CreateProductInput = {
|
||||
readonly code: string;
|
||||
readonly name: string;
|
||||
readonly unit?: string | null;
|
||||
readonly price?: Decimal | null;
|
||||
readonly brand?: string | null;
|
||||
readonly status?: Status;
|
||||
readonly userId: string;
|
||||
};
|
||||
|
||||
export type UpdateProductInput = {
|
||||
readonly code?: string;
|
||||
readonly name?: string;
|
||||
readonly unit?: string | null;
|
||||
readonly price?: Decimal | null;
|
||||
readonly brand?: string | null;
|
||||
readonly userId: string;
|
||||
};
|
||||
|
||||
export type ListProductsFilters = {
|
||||
readonly code?: string;
|
||||
readonly name?: string;
|
||||
readonly unit?: string;
|
||||
readonly brand?: string;
|
||||
readonly status?: string;
|
||||
readonly search?: string;
|
||||
readonly limit: number;
|
||||
readonly offset: number;
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ProductsReadController } from './products-read.controller';
|
||||
import { ProductsService } from './products.service';
|
||||
|
||||
describe('ProductsReadController', () => {
|
||||
let controller: ProductsReadController;
|
||||
const service = {
|
||||
list: jest.fn(),
|
||||
findById: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
controllers: [ProductsReadController],
|
||||
providers: [{ provide: ProductsService, useValue: service }],
|
||||
}).compile();
|
||||
controller = moduleRef.get(ProductsReadController);
|
||||
});
|
||||
|
||||
it('list delegates to the service', async () => {
|
||||
service.list.mockResolvedValue({ data: [], total: 0 });
|
||||
await expect(controller.list({ page: 1 })).resolves.toEqual({
|
||||
data: [],
|
||||
total: 0,
|
||||
});
|
||||
expect(service.list).toHaveBeenCalledWith({ page: 1 });
|
||||
});
|
||||
|
||||
it('findOne delegates to the service', async () => {
|
||||
service.findById.mockResolvedValue({ id: 'emp-1' });
|
||||
await expect(controller.findOne('emp-1')).resolves.toEqual({
|
||||
id: 'emp-1',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiForbiddenResponse,
|
||||
ApiNotFoundResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
ApiUnauthorizedResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { RequirePrivilege } from '../../../common/decorators/require-privilege.decorator';
|
||||
import {
|
||||
Pagination,
|
||||
type PaginationResponse,
|
||||
PaginationMetaDto,
|
||||
} from '../../../common/http/response';
|
||||
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
|
||||
import { ProductDto, ListProductsQueryDto } from './dto/product.dto';
|
||||
import { ProductsService } from './products.service';
|
||||
|
||||
export const PRODUCT_PRIVILEGE_KEY = 'CONFIGURATION.PRODUCT';
|
||||
|
||||
@ApiTags('products')
|
||||
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||
@Controller('products')
|
||||
export class ProductsReadController {
|
||||
constructor(private readonly productsService: ProductsService) {}
|
||||
|
||||
@Get()
|
||||
@Pagination()
|
||||
@RequirePrivilege(PRODUCT_PRIVILEGE_KEY, 'view')
|
||||
@ApiOperation({ summary: 'List products' })
|
||||
@ApiOkResponse({
|
||||
schema: {
|
||||
properties: {
|
||||
data: {
|
||||
type: 'array',
|
||||
items: { $ref: '#/components/schemas/ProductDto' },
|
||||
},
|
||||
meta: { $ref: '#/components/schemas/PaginationMetaDto' },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
list(
|
||||
@Query() query: ListProductsQueryDto,
|
||||
): Promise<PaginationResponse<ProductDto>> {
|
||||
return this.productsService.list(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePrivilege(PRODUCT_PRIVILEGE_KEY, 'view')
|
||||
@ApiOperation({ summary: 'Get product detail' })
|
||||
@ApiOkResponse({ type: ProductDto })
|
||||
@ApiNotFoundResponse()
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string): Promise<ProductDto> {
|
||||
return this.productsService.findById(id);
|
||||
}
|
||||
}
|
||||
|
||||
void PaginationMetaDto;
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ProductsWriteController } from './products-write.controller';
|
||||
import { ProductsService } from './products.service';
|
||||
|
||||
const createDto = {
|
||||
code: 'PRD_01',
|
||||
name: 'Ada Lovelace',
|
||||
unit: 'L',
|
||||
price: '12500.0000',
|
||||
brand: 'Pertamina',
|
||||
};
|
||||
|
||||
describe('ProductsWriteController', () => {
|
||||
let controller: ProductsWriteController;
|
||||
const service = {
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
updateStatus: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
bulkDelete: jest.fn(),
|
||||
bulkUpdateStatus: jest.fn(),
|
||||
importCsv: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
controllers: [ProductsWriteController],
|
||||
providers: [{ provide: ProductsService, useValue: service }],
|
||||
}).compile();
|
||||
controller = moduleRef.get(ProductsWriteController);
|
||||
});
|
||||
|
||||
it('create passes dto fields and user id', async () => {
|
||||
service.create.mockResolvedValue({ id: 'emp-1' });
|
||||
await controller.create(createDto, 'user-1');
|
||||
expect(service.create).toHaveBeenCalledWith({
|
||||
...createDto,
|
||||
status: undefined,
|
||||
userId: 'user-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('update, updateStatus, and delete delegate', async () => {
|
||||
service.update.mockResolvedValue({ id: 'emp-1' });
|
||||
service.updateStatus.mockResolvedValue({ id: 'emp-1' });
|
||||
service.delete.mockResolvedValue(undefined);
|
||||
await controller.update('emp-1', { name: 'Ada Lovelace' }, 'user-1');
|
||||
await controller.updateStatus('emp-1', { status: 'active' }, 'user-1');
|
||||
await controller.delete('emp-1');
|
||||
expect(service.updateStatus).toHaveBeenCalledWith(
|
||||
'emp-1',
|
||||
'active',
|
||||
'user-1',
|
||||
);
|
||||
expect(service.delete).toHaveBeenCalledWith('emp-1');
|
||||
});
|
||||
|
||||
it('bulk and import delegate', async () => {
|
||||
service.bulkDelete.mockResolvedValue({ deleted: 1 });
|
||||
service.bulkUpdateStatus.mockResolvedValue({ updated: 1 });
|
||||
service.importCsv.mockResolvedValue({ imported: 1 });
|
||||
await controller.bulkDelete({ ids: ['emp-1'] });
|
||||
await controller.bulkStatus(
|
||||
{ ids: ['emp-1'], status: 'archived' },
|
||||
'user-1',
|
||||
);
|
||||
await controller.importCsv(
|
||||
{
|
||||
buffer: Buffer.from(
|
||||
'code,name,phone,position\nPRD_01,Ada,+6281234567890,sales',
|
||||
),
|
||||
},
|
||||
'user-1',
|
||||
);
|
||||
expect(service.importCsv).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('importCsv uses empty string when file is missing', async () => {
|
||||
service.importCsv.mockResolvedValue({ imported: 0 });
|
||||
await controller.importCsv(undefined, 'user-1');
|
||||
expect(service.importCsv).toHaveBeenCalledWith('', 'user-1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
HttpCode,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
ApiConsumes,
|
||||
ApiCreatedResponse,
|
||||
ApiForbiddenResponse,
|
||||
ApiNoContentResponse,
|
||||
ApiNotFoundResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
ApiUnauthorizedResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { CurrentUser } from '../../../common/decorators/current-user.decorator';
|
||||
import { RequirePrivilege } from '../../../common/decorators/require-privilege.decorator';
|
||||
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
|
||||
import { isAllowedCsvUpload } from './product-fields';
|
||||
import { PRODUCT_PRIVILEGE_KEY } from './products-read.controller';
|
||||
import { ProductsService } from './products.service';
|
||||
import {
|
||||
BulkIdsDto,
|
||||
BulkStatusDto,
|
||||
CreateProductDto,
|
||||
ProductDto,
|
||||
UpdateProductDto,
|
||||
UpdateProductStatusDto,
|
||||
} from './dto/product.dto';
|
||||
|
||||
@ApiTags('products')
|
||||
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||
@Controller('products')
|
||||
export class ProductsWriteController {
|
||||
constructor(private readonly productsService: ProductsService) {}
|
||||
|
||||
@Post('import')
|
||||
@RequirePrivilege(PRODUCT_PRIVILEGE_KEY, 'import')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
limits: { fileSize: 1_048_576 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (!isAllowedCsvUpload(file)) {
|
||||
cb(new BadRequestException('Only CSV files are allowed'), false);
|
||||
return;
|
||||
}
|
||||
cb(null, true);
|
||||
},
|
||||
}),
|
||||
)
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
file: { type: 'string', format: 'binary' },
|
||||
},
|
||||
required: ['file'],
|
||||
},
|
||||
})
|
||||
@ApiOperation({ summary: 'Import products from CSV' })
|
||||
@ApiOkResponse({
|
||||
schema: { properties: { imported: { type: 'number' } } },
|
||||
})
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
importCsv(
|
||||
@UploadedFile() file: { buffer?: Buffer } | undefined,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<{ imported: number }> {
|
||||
const csv = file?.buffer?.toString('utf8') ?? '';
|
||||
return this.productsService.importCsv(csv, userId);
|
||||
}
|
||||
|
||||
@Post('bulk-delete')
|
||||
@HttpCode(200)
|
||||
@RequirePrivilege(PRODUCT_PRIVILEGE_KEY, 'delete')
|
||||
@ApiOperation({ summary: 'Bulk delete products' })
|
||||
@ApiOkResponse({
|
||||
schema: { properties: { deleted: { type: 'number' } } },
|
||||
})
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
bulkDelete(@Body() dto: BulkIdsDto): Promise<{ deleted: number }> {
|
||||
return this.productsService.bulkDelete(dto.ids);
|
||||
}
|
||||
|
||||
@Post('bulk-status')
|
||||
@HttpCode(200)
|
||||
@RequirePrivilege(PRODUCT_PRIVILEGE_KEY, 'update')
|
||||
@ApiOperation({ summary: 'Bulk update product status' })
|
||||
@ApiOkResponse({
|
||||
schema: { properties: { updated: { type: 'number' } } },
|
||||
})
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
bulkStatus(
|
||||
@Body() dto: BulkStatusDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<{ updated: number }> {
|
||||
return this.productsService.bulkUpdateStatus(dto.ids, dto.status, userId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePrivilege(PRODUCT_PRIVILEGE_KEY, 'create')
|
||||
@ApiOperation({ summary: 'Create product' })
|
||||
@ApiCreatedResponse({ type: ProductDto })
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
create(
|
||||
@Body() dto: CreateProductDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<ProductDto> {
|
||||
return this.productsService.create({
|
||||
code: dto.code,
|
||||
name: dto.name,
|
||||
unit: dto.unit,
|
||||
price: dto.price,
|
||||
brand: dto.brand,
|
||||
status: dto.status,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
@Patch(':id/status')
|
||||
@RequirePrivilege(PRODUCT_PRIVILEGE_KEY, 'update')
|
||||
@ApiOperation({ summary: 'Update product status' })
|
||||
@ApiOkResponse({ type: ProductDto })
|
||||
@ApiNotFoundResponse()
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
updateStatus(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateProductStatusDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<ProductDto> {
|
||||
return this.productsService.updateStatus(id, dto.status, userId);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePrivilege(PRODUCT_PRIVILEGE_KEY, 'update')
|
||||
@ApiOperation({ summary: 'Update product (not status)' })
|
||||
@ApiOkResponse({ type: ProductDto })
|
||||
@ApiNotFoundResponse()
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
update(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateProductDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<ProductDto> {
|
||||
return this.productsService.update(id, {
|
||||
code: dto.code,
|
||||
name: dto.name,
|
||||
unit: dto.unit,
|
||||
price: dto.price,
|
||||
brand: dto.brand,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(204)
|
||||
@RequirePrivilege(PRODUCT_PRIVILEGE_KEY, 'delete')
|
||||
@ApiOperation({ summary: 'Delete product' })
|
||||
@ApiNoContentResponse()
|
||||
@ApiNotFoundResponse()
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
async delete(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
|
||||
await this.productsService.delete(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ProductsReadController } from './products-read.controller';
|
||||
import { ProductsWriteController } from './products-write.controller';
|
||||
import { ProductsRepository } from './products.repository';
|
||||
import { ProductsService } from './products.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ProductsReadController, ProductsWriteController],
|
||||
providers: [ProductsRepository, ProductsService],
|
||||
exports: [ProductsService],
|
||||
})
|
||||
export class ProductsModule {}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { ConflictException, NotFoundException } from '@nestjs/common';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
import { DRIZZLE } from '../../../database/database.module';
|
||||
import { ProductsRepository } from './products.repository';
|
||||
|
||||
describe('ProductsRepository', () => {
|
||||
let repository: ProductsRepository;
|
||||
|
||||
const limit = jest.fn();
|
||||
const orderBy = jest.fn();
|
||||
const offset = jest.fn();
|
||||
const where = jest.fn();
|
||||
const from = jest.fn();
|
||||
const select = jest.fn();
|
||||
const returning = jest.fn();
|
||||
const values = jest.fn();
|
||||
const insert = jest.fn();
|
||||
const set = jest.fn();
|
||||
const update = jest.fn();
|
||||
const del = jest.fn();
|
||||
const transaction = jest.fn();
|
||||
const $dynamic = jest.fn();
|
||||
|
||||
const db = {
|
||||
select,
|
||||
insert,
|
||||
update,
|
||||
delete: del,
|
||||
transaction,
|
||||
};
|
||||
|
||||
const row = {
|
||||
id: 'prd-1',
|
||||
code: 'FUEL_95',
|
||||
name: 'Fuel 95',
|
||||
unit: 'L',
|
||||
price: '12500.0000',
|
||||
brand: 'Pertamina',
|
||||
status: 'draft',
|
||||
createdAt: 1_700_000_000_000,
|
||||
updatedAt: 1_700_000_000_000,
|
||||
createdBy: 'user-1',
|
||||
updatedBy: 'user-1',
|
||||
};
|
||||
|
||||
const createInput = {
|
||||
code: 'FUEL_95',
|
||||
name: 'Fuel 95',
|
||||
unit: 'L' as string | null,
|
||||
price: Decimal.create('12500.0000'),
|
||||
brand: 'Pertamina' as string | null,
|
||||
userId: 'user-1',
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
where.mockImplementation(() => ({ limit, orderBy, returning }));
|
||||
orderBy.mockImplementation(() => ({ limit }));
|
||||
limit.mockImplementation(() => ({ offset }));
|
||||
offset.mockResolvedValue([row]);
|
||||
from.mockImplementation(() => ({
|
||||
where,
|
||||
$dynamic,
|
||||
}));
|
||||
$dynamic.mockReturnValue({ where });
|
||||
select.mockImplementation(() => ({ from }));
|
||||
values.mockReturnValue({ returning });
|
||||
insert.mockReturnValue({ values });
|
||||
set.mockReturnValue({ where });
|
||||
update.mockReturnValue({ set });
|
||||
del.mockReturnValue({ where });
|
||||
returning.mockResolvedValue([row]);
|
||||
transaction.mockImplementation((fn: (tx: typeof db) => unknown) => fn(db));
|
||||
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
providers: [ProductsRepository, { provide: DRIZZLE, useValue: db }],
|
||||
}).compile();
|
||||
repository = moduleRef.get(ProductsRepository);
|
||||
});
|
||||
|
||||
it('findById maps a row to domain Product', async () => {
|
||||
limit.mockResolvedValueOnce([row]);
|
||||
const product = await repository.findById('prd-1');
|
||||
expect(product).toMatchObject({
|
||||
id: 'prd-1',
|
||||
code: 'FUEL_95',
|
||||
name: 'Fuel 95',
|
||||
unit: 'L',
|
||||
brand: 'Pertamina',
|
||||
createdBy: 'user-1',
|
||||
});
|
||||
expect(product?.price?.value).toBe('12500.0000');
|
||||
expect(product?.status.value).toBe('draft');
|
||||
expect(product?.createdAt.value).toBe(1_700_000_000_000);
|
||||
});
|
||||
|
||||
it('findById returns null when missing', async () => {
|
||||
limit.mockResolvedValueOnce([]);
|
||||
await expect(repository.findById('missing')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('list returns mapped rows and total', async () => {
|
||||
select
|
||||
.mockImplementationOnce(() => ({
|
||||
from: () => ({
|
||||
where: () => Promise.resolve([{ total: 1 }]),
|
||||
}),
|
||||
}))
|
||||
.mockImplementationOnce(() => ({
|
||||
from: () => ({
|
||||
$dynamic: () => ({
|
||||
where: () => ({
|
||||
orderBy: () => ({
|
||||
limit: () => ({
|
||||
offset: () => Promise.resolve([row]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
const result = await repository.list({
|
||||
name: 'Fuel',
|
||||
code: 'FUEL',
|
||||
unit: 'L',
|
||||
brand: 'Pertamina',
|
||||
status: 'draft',
|
||||
search: 'fuel',
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
});
|
||||
expect(result.total).toBe(1);
|
||||
expect(result.data[0].code).toBe('FUEL_95');
|
||||
expect(result.data[0].price?.value).toBe('12500.0000');
|
||||
});
|
||||
|
||||
it('create inserts and maps unique violations', async () => {
|
||||
returning.mockResolvedValueOnce([row]);
|
||||
const created = await repository.create(createInput);
|
||||
expect(created.code).toBe('FUEL_95');
|
||||
|
||||
returning.mockRejectedValueOnce({ code: '23505' });
|
||||
await expect(repository.create(createInput)).rejects.toBeInstanceOf(
|
||||
ConflictException,
|
||||
);
|
||||
});
|
||||
|
||||
it('create rethrows unknown errors', async () => {
|
||||
returning.mockRejectedValue(new Error('db down'));
|
||||
await expect(repository.create(createInput)).rejects.toThrow('db down');
|
||||
});
|
||||
|
||||
it('createMany returns 0 for an empty batch', async () => {
|
||||
await expect(repository.createMany([])).resolves.toBe(0);
|
||||
});
|
||||
|
||||
it('update throws when missing and maps unique violations', async () => {
|
||||
limit.mockResolvedValueOnce([]);
|
||||
await expect(
|
||||
repository.update('missing', { userId: 'user-1' }),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
|
||||
limit.mockResolvedValueOnce([row]);
|
||||
returning.mockRejectedValueOnce({ code: '23505' });
|
||||
await expect(
|
||||
repository.update('prd-1', { code: 'FUEL_98', userId: 'user-1' }),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('updateStatus throws when missing', async () => {
|
||||
returning.mockResolvedValueOnce([]);
|
||||
await expect(
|
||||
repository.updateStatus('missing', Status.create('active'), 'user-1'),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('delete throws when missing', async () => {
|
||||
returning.mockResolvedValueOnce([]);
|
||||
await expect(repository.delete('missing')).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('bulkUpdateStatus and bulkDelete return 0 for empty ids', async () => {
|
||||
await expect(
|
||||
repository.bulkUpdateStatus([], Status.create('active'), 'user-1'),
|
||||
).resolves.toBe(0);
|
||||
await expect(repository.bulkDelete([])).resolves.toBe(0);
|
||||
});
|
||||
|
||||
it('bulkUpdateStatus and bulkDelete return affected counts', async () => {
|
||||
returning.mockResolvedValue([{ id: 'prd-1' }, { id: 'prd-2' }]);
|
||||
await expect(
|
||||
repository.bulkUpdateStatus(
|
||||
['prd-1', 'prd-2'],
|
||||
Status.create('active'),
|
||||
'user-1',
|
||||
),
|
||||
).resolves.toBe(2);
|
||||
returning.mockResolvedValue([{ id: 'prd-1' }]);
|
||||
await expect(repository.bulkDelete(['prd-1'])).resolves.toBe(1);
|
||||
});
|
||||
|
||||
it('extendListQuery is a passthrough hook', () => {
|
||||
const qb = { join: true };
|
||||
expect(repository.extendListQuery(qb, { limit: 10, offset: 0 })).toBe(qb);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,306 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { and, asc, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
import { DRIZZLE, type DrizzleDB } from '../../../database/database.module';
|
||||
import { products, type ProductRow } from '../../../database/products-table';
|
||||
import type {
|
||||
CreateProductInput,
|
||||
ListProductsFilters,
|
||||
Product,
|
||||
UpdateProductInput,
|
||||
} from './product';
|
||||
|
||||
@Injectable()
|
||||
export class ProductsRepository {
|
||||
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
|
||||
|
||||
async list(
|
||||
filters: ListProductsFilters,
|
||||
): Promise<{ data: Product[]; total: number }> {
|
||||
const where = this.buildListWhere(filters);
|
||||
const totalRows = await this.db
|
||||
.select({ total: count() })
|
||||
.from(products)
|
||||
.where(where);
|
||||
const totalRow = totalRows[0];
|
||||
|
||||
let qb = this.db.select().from(products).$dynamic();
|
||||
qb = this.extendListQuery(qb, filters);
|
||||
const rows = await qb
|
||||
.where(where)
|
||||
.orderBy(asc(products.code))
|
||||
.limit(filters.limit)
|
||||
.offset(filters.offset);
|
||||
|
||||
return {
|
||||
data: rows.map((row) => this.toDomain(row)),
|
||||
total: Number(totalRow?.total ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for modules to add joins/extra predicates without forking list.
|
||||
*/
|
||||
extendListQuery<T>(qb: T, filters: ListProductsFilters): T {
|
||||
void filters;
|
||||
return qb;
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Product | null> {
|
||||
const rows: ProductRow[] = await this.db
|
||||
.select()
|
||||
.from(products)
|
||||
.where(eq(products.id, id))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
return row ? this.toDomain(row) : null;
|
||||
}
|
||||
|
||||
async findByCode(code: string): Promise<Product | null> {
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(products)
|
||||
.where(eq(products.code, code))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
return row ? this.toDomain(row) : null;
|
||||
}
|
||||
|
||||
async create(input: CreateProductInput): Promise<Product> {
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const status = input.status ?? Status.create(Status.DEFAULT);
|
||||
try {
|
||||
const inserted = await this.db
|
||||
.insert(products)
|
||||
.values(this.toInsertValues(input, status, now, input.userId))
|
||||
.returning();
|
||||
const row = inserted[0];
|
||||
return this.toDomain(row);
|
||||
} catch (error) {
|
||||
this.rethrowConstraintViolation(error);
|
||||
}
|
||||
}
|
||||
|
||||
async createMany(inputs: CreateProductInput[]): Promise<number> {
|
||||
if (inputs.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
try {
|
||||
await this.db.transaction(async (tx) => {
|
||||
for (const input of inputs) {
|
||||
const status = input.status ?? Status.create(Status.DEFAULT);
|
||||
await tx
|
||||
.insert(products)
|
||||
.values(this.toInsertValues(input, status, now, input.userId));
|
||||
}
|
||||
});
|
||||
return inputs.length;
|
||||
} catch (error) {
|
||||
this.rethrowConstraintViolation(error);
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: string, input: UpdateProductInput): Promise<Product> {
|
||||
const existing = await this.findById(id);
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Product not found');
|
||||
}
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
try {
|
||||
const updated = await this.db
|
||||
.update(products)
|
||||
.set({
|
||||
code: input.code ?? existing.code,
|
||||
name: input.name ?? existing.name,
|
||||
unit: input.unit !== undefined ? input.unit : existing.unit,
|
||||
price:
|
||||
input.price !== undefined
|
||||
? (input.price?.value ?? null)
|
||||
: (existing.price?.value ?? null),
|
||||
brand: input.brand !== undefined ? input.brand : existing.brand,
|
||||
updatedAt: now.value,
|
||||
updatedBy: input.userId,
|
||||
})
|
||||
.where(eq(products.id, id))
|
||||
.returning();
|
||||
const row = updated[0];
|
||||
return this.toDomain(row);
|
||||
} catch (error) {
|
||||
this.rethrowConstraintViolation(error);
|
||||
}
|
||||
}
|
||||
|
||||
async updateStatus(
|
||||
id: string,
|
||||
status: Status,
|
||||
userId: string,
|
||||
): Promise<Product> {
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const updated = await this.db
|
||||
.update(products)
|
||||
.set({
|
||||
status: status.value,
|
||||
updatedAt: now.value,
|
||||
updatedBy: userId,
|
||||
})
|
||||
.where(eq(products.id, id))
|
||||
.returning();
|
||||
const row = updated[0];
|
||||
if (!row) {
|
||||
throw new NotFoundException('Product not found');
|
||||
}
|
||||
return this.toDomain(row);
|
||||
}
|
||||
|
||||
async bulkUpdateStatus(
|
||||
ids: string[],
|
||||
status: Status,
|
||||
userId: string,
|
||||
): Promise<number> {
|
||||
if (ids.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const rows = await this.db
|
||||
.update(products)
|
||||
.set({
|
||||
status: status.value,
|
||||
updatedAt: now.value,
|
||||
updatedBy: userId,
|
||||
})
|
||||
.where(inArray(products.id, ids))
|
||||
.returning({ id: products.id });
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
try {
|
||||
const deleted = await this.db
|
||||
.delete(products)
|
||||
.where(eq(products.id, id))
|
||||
.returning({ id: products.id });
|
||||
if (deleted.length === 0) {
|
||||
throw new NotFoundException('Product not found');
|
||||
}
|
||||
} catch (error) {
|
||||
this.rethrowConstraintViolation(error);
|
||||
}
|
||||
}
|
||||
|
||||
async bulkDelete(ids: string[]): Promise<number> {
|
||||
if (ids.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
const deleted = await this.db
|
||||
.delete(products)
|
||||
.where(inArray(products.id, ids))
|
||||
.returning({ id: products.id });
|
||||
return deleted.length;
|
||||
} catch (error) {
|
||||
this.rethrowConstraintViolation(error);
|
||||
}
|
||||
}
|
||||
|
||||
private buildListWhere(filters: ListProductsFilters): SQL | undefined {
|
||||
const parts: SQL[] = [];
|
||||
if (filters.code) {
|
||||
parts.push(ilike(products.code, `%${filters.code}%`));
|
||||
}
|
||||
if (filters.name) {
|
||||
parts.push(ilike(products.name, `%${filters.name}%`));
|
||||
}
|
||||
if (filters.unit) {
|
||||
parts.push(ilike(products.unit, `%${filters.unit}%`));
|
||||
}
|
||||
if (filters.brand) {
|
||||
parts.push(ilike(products.brand, `%${filters.brand}%`));
|
||||
}
|
||||
if (filters.status) {
|
||||
parts.push(eq(products.status, filters.status));
|
||||
}
|
||||
if (filters.search) {
|
||||
const search = or(
|
||||
ilike(products.code, `%${filters.search}%`),
|
||||
ilike(products.name, `%${filters.search}%`),
|
||||
);
|
||||
if (search) {
|
||||
parts.push(search);
|
||||
}
|
||||
}
|
||||
if (parts.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return parts.length === 1 ? parts[0] : and(...parts);
|
||||
}
|
||||
|
||||
private toInsertValues(
|
||||
input: CreateProductInput,
|
||||
status: Status,
|
||||
now: DateTime,
|
||||
userId: string,
|
||||
) {
|
||||
return {
|
||||
code: input.code,
|
||||
name: input.name,
|
||||
unit: input.unit ?? null,
|
||||
price: input.price?.value ?? null,
|
||||
brand: input.brand ?? null,
|
||||
status: status.value,
|
||||
createdAt: now.value,
|
||||
updatedAt: now.value,
|
||||
createdBy: userId,
|
||||
updatedBy: userId,
|
||||
};
|
||||
}
|
||||
|
||||
private toDomain(row: ProductRow): Product {
|
||||
return {
|
||||
id: row.id,
|
||||
code: row.code,
|
||||
name: row.name,
|
||||
unit: row.unit,
|
||||
price: row.price === null ? null : Decimal.create(row.price),
|
||||
brand: row.brand,
|
||||
status: Status.create(row.status),
|
||||
createdAt: DateTime.fromUnixMs(row.createdAt),
|
||||
updatedAt: DateTime.fromUnixMs(row.updatedAt),
|
||||
createdBy: row.createdBy,
|
||||
updatedBy: row.updatedBy,
|
||||
};
|
||||
}
|
||||
|
||||
private rethrowConstraintViolation(error: unknown): never {
|
||||
const err = this.unwrapDbError(error);
|
||||
if (err.code === '23505') {
|
||||
throw new ConflictException('Product code already exists');
|
||||
}
|
||||
if (err.code === '23503') {
|
||||
throw new ConflictException('Product is referenced by sales documents');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
private unwrapDbError(error: unknown): { code?: string } {
|
||||
let current: unknown = error;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (!current || typeof current !== 'object') {
|
||||
break;
|
||||
}
|
||||
const obj = current as { code?: string; cause?: unknown };
|
||||
if (obj.code === '23505' || obj.code === '23503') {
|
||||
return { code: obj.code };
|
||||
}
|
||||
current = obj.cause;
|
||||
}
|
||||
return error as { code?: string };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
import type { Product } from './product';
|
||||
import { ProductsRepository } from './products.repository';
|
||||
import { ProductsService } from './products.service';
|
||||
|
||||
describe('ProductsService', () => {
|
||||
let service: ProductsService;
|
||||
let repository: jest.Mocked<
|
||||
Pick<
|
||||
ProductsRepository,
|
||||
| 'list'
|
||||
| 'findById'
|
||||
| 'create'
|
||||
| 'createMany'
|
||||
| 'update'
|
||||
| 'updateStatus'
|
||||
| 'bulkUpdateStatus'
|
||||
| 'delete'
|
||||
| 'bulkDelete'
|
||||
>
|
||||
>;
|
||||
|
||||
const now = DateTime.fromUnixMs(1_700_000_000_000);
|
||||
const sample: Product = {
|
||||
id: 'prd-1',
|
||||
code: 'FUEL_95',
|
||||
name: 'Fuel 95',
|
||||
unit: 'L',
|
||||
price: Decimal.create('12500.0000'),
|
||||
brand: 'Pertamina',
|
||||
status: Status.create('draft'),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: 'user-1',
|
||||
updatedBy: 'user-1',
|
||||
};
|
||||
|
||||
const createInput = {
|
||||
code: 'FUEL_95',
|
||||
name: 'Fuel 95',
|
||||
unit: 'L',
|
||||
price: '12500.0000',
|
||||
brand: 'Pertamina',
|
||||
userId: 'user-1',
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
repository = {
|
||||
list: jest.fn(),
|
||||
findById: jest.fn(),
|
||||
create: jest.fn(),
|
||||
createMany: jest.fn(),
|
||||
update: jest.fn(),
|
||||
updateStatus: jest.fn(),
|
||||
bulkUpdateStatus: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
bulkDelete: jest.fn(),
|
||||
};
|
||||
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ProductsService,
|
||||
{ provide: ProductsRepository, useValue: repository },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = moduleRef.get(ProductsService);
|
||||
});
|
||||
|
||||
it('list maps visible fields including unit, price, and brand', async () => {
|
||||
repository.list.mockResolvedValue({ data: [sample], total: 1 });
|
||||
const result = await service.list({ page: 1, limit: 10 });
|
||||
expect(result.total).toBe(1);
|
||||
expect(result.data[0]).toMatchObject({
|
||||
id: 'prd-1',
|
||||
code: 'FUEL_95',
|
||||
name: 'Fuel 95',
|
||||
unit: 'L',
|
||||
price: '12500.0000',
|
||||
brand: 'Pertamina',
|
||||
status: 'draft',
|
||||
});
|
||||
expect(service.visibleFields).toEqual(
|
||||
expect.arrayContaining(['unit', 'price', 'brand', 'status']),
|
||||
);
|
||||
});
|
||||
|
||||
it('findById throws when missing', async () => {
|
||||
repository.findById.mockResolvedValue(null);
|
||||
await expect(service.findById('missing')).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('create defaults status to draft and maps price', async () => {
|
||||
repository.create.mockResolvedValue(sample);
|
||||
await service.create(createInput);
|
||||
const arg = repository.create.mock.calls[0][0];
|
||||
expect(arg.status?.value).toBe('draft');
|
||||
expect(arg.price?.value).toBe('12500.0000');
|
||||
expect(arg.unit).toBe('L');
|
||||
});
|
||||
|
||||
it('create rejects invalid name, code, unit, or price', async () => {
|
||||
await expect(
|
||||
service.create({ ...createInput, name: 'Fuel 95' }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(
|
||||
service.create({ ...createInput, code: 'FUEL 95' }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(
|
||||
service.create({ ...createInput, price: '1.23456' }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(repository.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('update rejects status field', async () => {
|
||||
await expect(
|
||||
service.update('prd-1', { status: 'active', userId: 'user-1' }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('updateStatus updates via repository', async () => {
|
||||
repository.updateStatus.mockResolvedValue(sample);
|
||||
await service.updateStatus('prd-1', 'active', 'user-1');
|
||||
expect(repository.updateStatus).toHaveBeenCalledWith(
|
||||
'prd-1',
|
||||
expect.objectContaining({ value: 'active' }),
|
||||
'user-1',
|
||||
);
|
||||
});
|
||||
|
||||
it('delete, bulkDelete, and bulkUpdateStatus delegate', async () => {
|
||||
repository.delete.mockResolvedValue(undefined);
|
||||
repository.bulkDelete.mockResolvedValue(2);
|
||||
repository.bulkUpdateStatus.mockResolvedValue(2);
|
||||
await service.delete('prd-1');
|
||||
await expect(service.bulkDelete(['a', 'b'])).resolves.toEqual({
|
||||
deleted: 2,
|
||||
});
|
||||
await expect(
|
||||
service.bulkUpdateStatus(['a', 'b'], 'archived', 'user-1'),
|
||||
).resolves.toEqual({ updated: 2 });
|
||||
});
|
||||
|
||||
it('importCsv imports valid rows', async () => {
|
||||
repository.createMany.mockResolvedValue(1);
|
||||
const result = await service.importCsv(
|
||||
'code,name,unit,price,brand,status\nFUEL_95,Fuel 95,L,12500.0000,Pertamina,draft',
|
||||
'user-1',
|
||||
);
|
||||
expect(result.imported).toBe(1);
|
||||
expect(repository.createMany).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('importCsv rejects empty files and invalid rows', async () => {
|
||||
await expect(service.importCsv('', 'user-1')).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
await expect(
|
||||
service.importCsv('code,name\nFUEL 95,Fuel 95', 'user-1'),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,329 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type { PaginationResponse } from '../../../common/http/response';
|
||||
import { toListPage } from '../../../common/http/response';
|
||||
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||
import { InvalidDecimalError } from '../../../common/value-objects/decimal/invalid-decimal.error';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
import type {
|
||||
CreateProductInput,
|
||||
Product,
|
||||
UpdateProductInput,
|
||||
} from './product';
|
||||
import {
|
||||
isValidProductBrand,
|
||||
isValidProductCode,
|
||||
isValidProductName,
|
||||
isValidProductUnit,
|
||||
parseCsvRecord,
|
||||
} from './product-fields';
|
||||
import { ProductsRepository } from './products.repository';
|
||||
|
||||
export type ListProductsQuery = {
|
||||
readonly code?: string;
|
||||
readonly name?: string;
|
||||
readonly unit?: string;
|
||||
readonly brand?: string;
|
||||
readonly status?: string;
|
||||
readonly search?: string;
|
||||
readonly page?: number;
|
||||
readonly limit?: number;
|
||||
readonly offset?: number;
|
||||
};
|
||||
|
||||
const VISIBLE_FIELDS = [
|
||||
'id',
|
||||
'code',
|
||||
'name',
|
||||
'unit',
|
||||
'price',
|
||||
'brand',
|
||||
'status',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'createdBy',
|
||||
'updatedBy',
|
||||
] as const;
|
||||
|
||||
const CSV_REQUIRED_HEADERS = ['code', 'name'] as const;
|
||||
|
||||
@Injectable()
|
||||
export class ProductsService {
|
||||
constructor(private readonly productsRepository: ProductsRepository) {}
|
||||
|
||||
async list(
|
||||
query: ListProductsQuery,
|
||||
): Promise<PaginationResponse<ReturnType<ProductsService['toListItem']>>> {
|
||||
const page = toListPage(query);
|
||||
const { data, total } = await this.productsRepository.list({
|
||||
code: query.code,
|
||||
name: query.name,
|
||||
unit: query.unit,
|
||||
brand: query.brand,
|
||||
status: query.status,
|
||||
search: query.search,
|
||||
limit: page.limit,
|
||||
offset: page.offset,
|
||||
});
|
||||
return {
|
||||
data: data.map((item) => this.toListItem(item)),
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
async findById(
|
||||
id: string,
|
||||
): Promise<ReturnType<ProductsService['toListItem']>> {
|
||||
const product = await this.productsRepository.findById(id);
|
||||
if (!product) {
|
||||
throw new NotFoundException('Product not found');
|
||||
}
|
||||
return this.toListItem(product);
|
||||
}
|
||||
|
||||
async create(input: {
|
||||
code: string;
|
||||
name: string;
|
||||
unit?: string | null;
|
||||
price?: string | null;
|
||||
brand?: string | null;
|
||||
status?: string;
|
||||
userId: string;
|
||||
}): Promise<ReturnType<ProductsService['toListItem']>> {
|
||||
const created = await this.productsRepository.create(
|
||||
this.toCreateInput(input),
|
||||
);
|
||||
return this.toListItem(created);
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
input: {
|
||||
code?: string;
|
||||
name?: string;
|
||||
unit?: string | null;
|
||||
price?: string | null;
|
||||
brand?: string | null;
|
||||
status?: unknown;
|
||||
userId: string;
|
||||
},
|
||||
): Promise<ReturnType<ProductsService['toListItem']>> {
|
||||
if (input.status !== undefined) {
|
||||
throw new BadRequestException('status cannot be updated via PATCH');
|
||||
}
|
||||
const payload: UpdateProductInput = {
|
||||
code: input.code !== undefined ? this.assertCode(input.code) : undefined,
|
||||
name: input.name !== undefined ? this.assertName(input.name) : undefined,
|
||||
unit:
|
||||
input.unit !== undefined
|
||||
? this.assertOptionalUnit(input.unit)
|
||||
: undefined,
|
||||
price:
|
||||
input.price !== undefined
|
||||
? this.assertOptionalPrice(input.price)
|
||||
: undefined,
|
||||
brand:
|
||||
input.brand !== undefined
|
||||
? this.assertOptionalBrand(input.brand)
|
||||
: undefined,
|
||||
userId: input.userId,
|
||||
};
|
||||
const updated = await this.productsRepository.update(id, payload);
|
||||
return this.toListItem(updated);
|
||||
}
|
||||
|
||||
async updateStatus(
|
||||
id: string,
|
||||
statusRaw: string,
|
||||
userId: string,
|
||||
): Promise<ReturnType<ProductsService['toListItem']>> {
|
||||
const status = Status.create(statusRaw);
|
||||
const updated = await this.productsRepository.updateStatus(
|
||||
id,
|
||||
status,
|
||||
userId,
|
||||
);
|
||||
return this.toListItem(updated);
|
||||
}
|
||||
|
||||
async bulkUpdateStatus(
|
||||
ids: string[],
|
||||
statusRaw: string,
|
||||
userId: string,
|
||||
): Promise<{ updated: number }> {
|
||||
const status = Status.create(statusRaw);
|
||||
const updated = await this.productsRepository.bulkUpdateStatus(
|
||||
ids,
|
||||
status,
|
||||
userId,
|
||||
);
|
||||
return { updated };
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.productsRepository.delete(id);
|
||||
}
|
||||
|
||||
async bulkDelete(ids: string[]): Promise<{ deleted: number }> {
|
||||
const deleted = await this.productsRepository.bulkDelete(ids);
|
||||
return { deleted };
|
||||
}
|
||||
|
||||
async importCsv(csv: string, userId: string): Promise<{ imported: number }> {
|
||||
const rawLines = csv.split(/\r?\n/);
|
||||
const filled = rawLines
|
||||
.map((line, index) => ({ line: line.trim(), lineNo: index + 1 }))
|
||||
.filter((entry) => entry.line.length > 0);
|
||||
if (filled.length === 0) {
|
||||
throw new BadRequestException('CSV is empty');
|
||||
}
|
||||
if (filled.length > 501) {
|
||||
throw new BadRequestException('CSV exceeds maximum of 500 data rows');
|
||||
}
|
||||
|
||||
const header = parseCsvRecord(filled[0].line).map((h) =>
|
||||
h.trim().toLowerCase(),
|
||||
);
|
||||
const missing = CSV_REQUIRED_HEADERS.filter((h) => header.indexOf(h) < 0);
|
||||
if (missing.length > 0) {
|
||||
throw new BadRequestException('CSV must include required headers');
|
||||
}
|
||||
|
||||
const idx = (key: string) => header.indexOf(key);
|
||||
const errors: string[] = [];
|
||||
const rows: CreateProductInput[] = [];
|
||||
for (let i = 1; i < filled.length; i++) {
|
||||
const cols = parseCsvRecord(filled[i].line);
|
||||
const rowNum = filled[i].lineNo;
|
||||
try {
|
||||
const statusRaw = idx('status') >= 0 ? cols[idx('status')] : '';
|
||||
rows.push(
|
||||
this.toCreateInput({
|
||||
code: cols[idx('code')] ?? '',
|
||||
name: cols[idx('name')] ?? '',
|
||||
unit: idx('unit') >= 0 ? cols[idx('unit')] : undefined,
|
||||
price: idx('price') >= 0 ? cols[idx('price')] : undefined,
|
||||
brand: idx('brand') >= 0 ? cols[idx('brand')] : undefined,
|
||||
status: statusRaw || undefined,
|
||||
userId,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
const reason =
|
||||
error instanceof BadRequestException ? error.message : 'invalid data';
|
||||
errors.push(`row ${rowNum}: ${reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new BadRequestException({
|
||||
message: 'CSV validation failed',
|
||||
errors,
|
||||
});
|
||||
}
|
||||
|
||||
await this.productsRepository.createMany(rows);
|
||||
return { imported: rows.length };
|
||||
}
|
||||
|
||||
toListItem(product: Product) {
|
||||
return {
|
||||
id: product.id,
|
||||
code: product.code,
|
||||
name: product.name,
|
||||
unit: product.unit,
|
||||
price: product.price?.value ?? null,
|
||||
brand: product.brand,
|
||||
status: product.status.value,
|
||||
createdAt: product.createdAt.value,
|
||||
updatedAt: product.updatedAt.value,
|
||||
createdBy: product.createdBy,
|
||||
updatedBy: product.updatedBy,
|
||||
};
|
||||
}
|
||||
|
||||
get visibleFields(): readonly string[] {
|
||||
return VISIBLE_FIELDS;
|
||||
}
|
||||
|
||||
private toCreateInput(input: {
|
||||
code: string;
|
||||
name: string;
|
||||
unit?: string | null;
|
||||
price?: string | null;
|
||||
brand?: string | null;
|
||||
status?: string;
|
||||
userId: string;
|
||||
}): CreateProductInput {
|
||||
return {
|
||||
code: this.assertCode(input.code),
|
||||
name: this.assertName(input.name),
|
||||
unit: this.assertOptionalUnit(input.unit ?? null),
|
||||
price: this.assertOptionalPrice(input.price ?? null),
|
||||
brand: this.assertOptionalBrand(input.brand ?? null),
|
||||
status: input.status
|
||||
? Status.create(input.status)
|
||||
: Status.create(Status.DEFAULT),
|
||||
userId: input.userId,
|
||||
};
|
||||
}
|
||||
|
||||
private assertName(raw: string): string {
|
||||
const name = raw.trim();
|
||||
if (!isValidProductName(name)) {
|
||||
throw new BadRequestException('Invalid product name');
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
private assertCode(raw: string): string {
|
||||
const code = raw.trim();
|
||||
if (!isValidProductCode(code)) {
|
||||
throw new BadRequestException('Invalid product code');
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
private assertOptionalUnit(raw: string | null): string | null {
|
||||
if (raw === null || raw === '') {
|
||||
return null;
|
||||
}
|
||||
const unit = raw.trim();
|
||||
if (!isValidProductUnit(unit)) {
|
||||
throw new BadRequestException('Invalid product unit');
|
||||
}
|
||||
return unit;
|
||||
}
|
||||
|
||||
private assertOptionalBrand(raw: string | null): string | null {
|
||||
if (raw === null || raw === '') {
|
||||
return null;
|
||||
}
|
||||
const brand = raw.trim();
|
||||
if (!isValidProductBrand(brand)) {
|
||||
throw new BadRequestException('Invalid product brand');
|
||||
}
|
||||
return brand;
|
||||
}
|
||||
|
||||
private assertOptionalPrice(raw: string | null): Decimal | null {
|
||||
if (raw === null || raw === '') {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const price = Decimal.create(raw);
|
||||
if (price.isNegative()) {
|
||||
throw new BadRequestException('Invalid product price');
|
||||
}
|
||||
return price;
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidDecimalError) {
|
||||
throw new BadRequestException('Invalid product price');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayNotEmpty,
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Matches,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { PaginationQueryDto } from '../../../../common/http/response';
|
||||
import {
|
||||
DOCUMENT_ADDRESS_MAX_LENGTH,
|
||||
DOCUMENT_CODE_MAX_LENGTH,
|
||||
DOCUMENT_CODE_PATTERN,
|
||||
DOCUMENT_NOTES_MAX_LENGTH,
|
||||
PACKING_SLIP_STATUSES,
|
||||
} from '../../shared/sales-fields';
|
||||
|
||||
export class SalesLineDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID('4')
|
||||
productId!: string;
|
||||
|
||||
@ApiProperty({ example: '2.0000' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
quantity!: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '12500.0000' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
price?: string;
|
||||
}
|
||||
|
||||
export class CreatePackingSlipDto {
|
||||
@ApiPropertyOptional({ example: 'PS-20260824-0001' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(DOCUMENT_CODE_MAX_LENGTH)
|
||||
@Matches(DOCUMENT_CODE_PATTERN)
|
||||
code?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
salesOrderId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
salesOrderNumber?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '2026-08-24T10:00:00+07:00' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
date?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@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()
|
||||
@MaxLength(DOCUMENT_NOTES_MAX_LENGTH)
|
||||
notes?: string;
|
||||
|
||||
@ApiPropertyOptional({ type: [SalesLineDto] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => SalesLineDto)
|
||||
products?: SalesLineDto[];
|
||||
|
||||
@ApiPropertyOptional({ enum: PACKING_SLIP_STATUSES })
|
||||
@IsOptional()
|
||||
@IsIn([...PACKING_SLIP_STATUSES])
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export class UpdatePackingSlipDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(DOCUMENT_CODE_MAX_LENGTH)
|
||||
@Matches(DOCUMENT_CODE_PATTERN)
|
||||
code?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid', nullable: true })
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
salesOrderId?: string | null;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
date?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@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()
|
||||
notes?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ type: [SalesLineDto] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => SalesLineDto)
|
||||
products?: SalesLineDto[];
|
||||
}
|
||||
|
||||
export class UpdatePackingSlipStatusDto {
|
||||
@ApiProperty({ enum: PACKING_SLIP_STATUSES })
|
||||
@IsIn([...PACKING_SLIP_STATUSES])
|
||||
status!: string;
|
||||
}
|
||||
|
||||
export class BulkIdsDto {
|
||||
@ApiProperty({ type: [String], format: 'uuid' })
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsUUID('4', { each: true })
|
||||
ids!: string[];
|
||||
}
|
||||
|
||||
export class BulkStatusDto {
|
||||
@ApiProperty({ type: [String], format: 'uuid' })
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsUUID('4', { each: true })
|
||||
ids!: string[];
|
||||
|
||||
@ApiProperty({ enum: PACKING_SLIP_STATUSES })
|
||||
@IsIn([...PACKING_SLIP_STATUSES])
|
||||
status!: string;
|
||||
}
|
||||
|
||||
export class ListPackingSlipsQueryDto extends PaginationQueryDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
code?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: PACKING_SLIP_STATUSES })
|
||||
@IsOptional()
|
||||
@IsIn([...PACKING_SLIP_STATUSES])
|
||||
status?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
customerId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
salesOrderId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export class PackingSlipDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
id!: string;
|
||||
@ApiProperty()
|
||||
code!: string;
|
||||
@ApiPropertyOptional({ nullable: true, format: 'uuid' })
|
||||
salesOrderId!: string | null;
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
salesOrderNumber!: string | null;
|
||||
@ApiProperty()
|
||||
date!: number;
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
customerId!: string;
|
||||
@ApiProperty()
|
||||
address!: string;
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
latitude!: number | null;
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
longitude!: number | null;
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
notes!: string | null;
|
||||
@ApiProperty({ enum: PACKING_SLIP_STATUSES })
|
||||
status!: string;
|
||||
@ApiProperty()
|
||||
createdAt!: number;
|
||||
@ApiProperty()
|
||||
updatedAt!: number;
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
createdBy!: string;
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
updatedBy!: string;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
|
||||
export type PackingSlipLine = {
|
||||
readonly id: string;
|
||||
readonly productId: string;
|
||||
readonly quantity: Decimal;
|
||||
readonly price: Decimal;
|
||||
};
|
||||
|
||||
export type PackingSlip = {
|
||||
readonly id: string;
|
||||
readonly code: string;
|
||||
readonly salesOrderId: string | null;
|
||||
readonly salesOrderNumber: string | null;
|
||||
readonly date: DateTime;
|
||||
readonly customerId: string;
|
||||
readonly address: string;
|
||||
readonly latitude: number | null;
|
||||
readonly longitude: number | null;
|
||||
readonly notes: string | null;
|
||||
readonly products: readonly PackingSlipLine[];
|
||||
readonly status: Status;
|
||||
readonly createdAt: DateTime;
|
||||
readonly updatedAt: DateTime;
|
||||
readonly createdBy: string;
|
||||
readonly updatedBy: string;
|
||||
};
|
||||
|
||||
export type PackingSlipLineInput = {
|
||||
readonly productId: string;
|
||||
readonly quantity: Decimal;
|
||||
readonly price: Decimal;
|
||||
};
|
||||
|
||||
export type CreatePackingSlipInput = {
|
||||
readonly code?: string;
|
||||
readonly salesOrderId?: string | null;
|
||||
readonly salesOrderNumber?: string | null;
|
||||
readonly date: DateTime;
|
||||
readonly customerId: string;
|
||||
readonly address: string;
|
||||
readonly latitude?: number | null;
|
||||
readonly longitude?: number | null;
|
||||
readonly notes?: string | null;
|
||||
readonly products: readonly PackingSlipLineInput[];
|
||||
readonly status?: Status;
|
||||
readonly userId: string;
|
||||
};
|
||||
|
||||
export type UpdatePackingSlipInput = {
|
||||
readonly code?: string;
|
||||
readonly salesOrderId?: string | null;
|
||||
readonly salesOrderNumber?: string | null;
|
||||
readonly date?: DateTime;
|
||||
readonly customerId?: string;
|
||||
readonly address?: string;
|
||||
readonly latitude?: number | null;
|
||||
readonly longitude?: number | null;
|
||||
readonly notes?: string | null;
|
||||
readonly products?: readonly PackingSlipLineInput[];
|
||||
readonly userId: string;
|
||||
};
|
||||
|
||||
export type ListPackingSlipsFilters = {
|
||||
readonly code?: string;
|
||||
readonly status?: string;
|
||||
readonly customerId?: string;
|
||||
readonly salesOrderId?: string;
|
||||
readonly search?: string;
|
||||
readonly limit: number;
|
||||
readonly offset: number;
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiForbiddenResponse,
|
||||
ApiNotFoundResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
ApiUnauthorizedResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { RequirePrivilege } from '../../../common/decorators/require-privilege.decorator';
|
||||
import {
|
||||
Pagination,
|
||||
type PaginationResponse,
|
||||
PaginationMetaDto,
|
||||
} from '../../../common/http/response';
|
||||
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
|
||||
import {
|
||||
PackingSlipDto,
|
||||
ListPackingSlipsQueryDto,
|
||||
} from './dto/packing-slip.dto';
|
||||
import { PackingSlipsService } from './packing-slips.service';
|
||||
|
||||
export const PACKING_SLIP_PRIVILEGE_KEY = 'SALES.PACKING_SLIP';
|
||||
|
||||
@ApiTags('packing-slips')
|
||||
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||
@Controller('packing-slips')
|
||||
export class PackingSlipsReadController {
|
||||
constructor(private readonly packingSlipsService: PackingSlipsService) {}
|
||||
|
||||
@Get()
|
||||
@Pagination()
|
||||
@RequirePrivilege(PACKING_SLIP_PRIVILEGE_KEY, 'view')
|
||||
@ApiOperation({ summary: 'List packing slips' })
|
||||
@ApiOkResponse({
|
||||
schema: {
|
||||
properties: {
|
||||
data: {
|
||||
type: 'array',
|
||||
items: { $ref: '#/components/schemas/PackingSlipDto' },
|
||||
},
|
||||
meta: { $ref: '#/components/schemas/PaginationMetaDto' },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
list(
|
||||
@Query() query: ListPackingSlipsQueryDto,
|
||||
): Promise<PaginationResponse<PackingSlipDto>> {
|
||||
return this.packingSlipsService.list(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePrivilege(PACKING_SLIP_PRIVILEGE_KEY, 'view')
|
||||
@ApiOperation({ summary: 'Get packing slip detail' })
|
||||
@ApiOkResponse({ type: PackingSlipDto })
|
||||
@ApiNotFoundResponse()
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string): Promise<PackingSlipDto> {
|
||||
return this.packingSlipsService.findById(id);
|
||||
}
|
||||
}
|
||||
|
||||
void PaginationMetaDto;
|
||||
@@ -0,0 +1,198 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
HttpCode,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
ApiConsumes,
|
||||
ApiCreatedResponse,
|
||||
ApiForbiddenResponse,
|
||||
ApiNoContentResponse,
|
||||
ApiNotFoundResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
ApiUnauthorizedResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { CurrentUser } from '../../../common/decorators/current-user.decorator';
|
||||
import { RequirePrivilege } from '../../../common/decorators/require-privilege.decorator';
|
||||
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
|
||||
import { isAllowedCsvUpload } from '../shared/sales-fields';
|
||||
import { PACKING_SLIP_PRIVILEGE_KEY } from './packing-slips-read.controller';
|
||||
import { PackingSlipsService } from './packing-slips.service';
|
||||
import {
|
||||
BulkIdsDto,
|
||||
BulkStatusDto,
|
||||
CreatePackingSlipDto,
|
||||
PackingSlipDto,
|
||||
UpdatePackingSlipDto,
|
||||
UpdatePackingSlipStatusDto,
|
||||
} from './dto/packing-slip.dto';
|
||||
|
||||
@ApiTags('packing-slips')
|
||||
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||
@Controller('packing-slips')
|
||||
export class PackingSlipsWriteController {
|
||||
constructor(private readonly packingSlipsService: PackingSlipsService) {}
|
||||
|
||||
@Post('import')
|
||||
@RequirePrivilege(PACKING_SLIP_PRIVILEGE_KEY, 'import')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
limits: { fileSize: 1_048_576 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (!isAllowedCsvUpload(file)) {
|
||||
cb(new BadRequestException('Only CSV files are allowed'), false);
|
||||
return;
|
||||
}
|
||||
cb(null, true);
|
||||
},
|
||||
}),
|
||||
)
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
file: { type: 'string', format: 'binary' },
|
||||
},
|
||||
required: ['file'],
|
||||
},
|
||||
})
|
||||
@ApiOperation({ summary: 'Import packing slips from CSV' })
|
||||
@ApiOkResponse({
|
||||
schema: { properties: { imported: { type: 'number' } } },
|
||||
})
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
importCsv(
|
||||
@UploadedFile() file: { buffer?: Buffer } | undefined,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<{ imported: number }> {
|
||||
const csv = file?.buffer?.toString('utf8') ?? '';
|
||||
return this.packingSlipsService.importCsv(csv, userId);
|
||||
}
|
||||
|
||||
@Post('bulk-delete')
|
||||
@HttpCode(200)
|
||||
@RequirePrivilege(PACKING_SLIP_PRIVILEGE_KEY, 'delete')
|
||||
@ApiOperation({ summary: 'Bulk delete packing slips' })
|
||||
@ApiOkResponse({
|
||||
schema: { properties: { deleted: { type: 'number' } } },
|
||||
})
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
bulkDelete(@Body() dto: BulkIdsDto): Promise<{ deleted: number }> {
|
||||
return this.packingSlipsService.bulkDelete(dto.ids);
|
||||
}
|
||||
|
||||
@Post('bulk-status')
|
||||
@HttpCode(200)
|
||||
@RequirePrivilege(PACKING_SLIP_PRIVILEGE_KEY, 'update')
|
||||
@ApiOperation({ summary: 'Bulk update packing slip status' })
|
||||
@ApiOkResponse({
|
||||
schema: { properties: { updated: { type: 'number' } } },
|
||||
})
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
bulkStatus(
|
||||
@Body() dto: BulkStatusDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<{ updated: number }> {
|
||||
return this.packingSlipsService.bulkUpdateStatus(
|
||||
dto.ids,
|
||||
dto.status,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePrivilege(PACKING_SLIP_PRIVILEGE_KEY, 'create')
|
||||
@ApiOperation({ summary: 'Create packing slip' })
|
||||
@ApiCreatedResponse({ type: PackingSlipDto })
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
create(
|
||||
@Body() dto: CreatePackingSlipDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<PackingSlipDto> {
|
||||
return this.packingSlipsService.create({
|
||||
code: dto.code,
|
||||
salesOrderId: dto.salesOrderId,
|
||||
salesOrderNumber: dto.salesOrderNumber,
|
||||
date: dto.date,
|
||||
customerId: dto.customerId,
|
||||
address: dto.address,
|
||||
latitude: dto.latitude,
|
||||
longitude: dto.longitude,
|
||||
notes: dto.notes,
|
||||
products: dto.products,
|
||||
status: dto.status,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
@Patch(':id/status')
|
||||
@RequirePrivilege(PACKING_SLIP_PRIVILEGE_KEY, 'update')
|
||||
@ApiOperation({ summary: 'Update packing slip status' })
|
||||
@ApiOkResponse({ type: PackingSlipDto })
|
||||
@ApiNotFoundResponse()
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
updateStatus(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdatePackingSlipStatusDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<PackingSlipDto> {
|
||||
return this.packingSlipsService.updateStatus(id, dto.status, userId);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePrivilege(PACKING_SLIP_PRIVILEGE_KEY, 'update')
|
||||
@ApiOperation({ summary: 'Update packing slip (not status)' })
|
||||
@ApiOkResponse({ type: PackingSlipDto })
|
||||
@ApiNotFoundResponse()
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
update(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdatePackingSlipDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<PackingSlipDto> {
|
||||
return this.packingSlipsService.update(id, {
|
||||
code: dto.code,
|
||||
salesOrderId: dto.salesOrderId,
|
||||
date: dto.date,
|
||||
customerId: dto.customerId,
|
||||
address: dto.address,
|
||||
latitude: dto.latitude,
|
||||
longitude: dto.longitude,
|
||||
notes: dto.notes,
|
||||
products: dto.products,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(204)
|
||||
@RequirePrivilege(PACKING_SLIP_PRIVILEGE_KEY, 'delete')
|
||||
@ApiOperation({ summary: 'Delete packing slip' })
|
||||
@ApiNoContentResponse()
|
||||
@ApiNotFoundResponse()
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
async delete(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
|
||||
await this.packingSlipsService.delete(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CustomersModule } from '../../configuration/customers/customers.module';
|
||||
import { ProductsModule } from '../../configuration/products/products.module';
|
||||
import { SalesOrdersModule } from '../sales-orders/sales-orders.module';
|
||||
import { DocumentCodeService } from '../shared/document-code.service';
|
||||
import { PackingSlipsReadController } from './packing-slips-read.controller';
|
||||
import { PackingSlipsWriteController } from './packing-slips-write.controller';
|
||||
import { PackingSlipsRepository } from './packing-slips.repository';
|
||||
import { PackingSlipsService } from './packing-slips.service';
|
||||
|
||||
@Module({
|
||||
imports: [CustomersModule, ProductsModule, SalesOrdersModule],
|
||||
controllers: [PackingSlipsReadController, PackingSlipsWriteController],
|
||||
providers: [DocumentCodeService, PackingSlipsRepository, PackingSlipsService],
|
||||
exports: [PackingSlipsService, DocumentCodeService],
|
||||
})
|
||||
export class PackingSlipsModule {}
|
||||
@@ -0,0 +1,371 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { and, asc, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
import { DRIZZLE, type DrizzleDB } from '../../../database/database.module';
|
||||
import {
|
||||
packingSlipProducts,
|
||||
packingSlips,
|
||||
type PackingSlipProductRow,
|
||||
type PackingSlipRow,
|
||||
} from '../../../database/packing-slips-table';
|
||||
import { DocumentCodeService } from '../shared/document-code.service';
|
||||
import { DOCUMENT_PREFIXES } from '../shared/document-prefixes';
|
||||
import { PACKING_SLIP_STATUSES } from '../shared/sales-fields';
|
||||
import type {
|
||||
CreatePackingSlipInput,
|
||||
ListPackingSlipsFilters,
|
||||
PackingSlip,
|
||||
PackingSlipLineInput,
|
||||
UpdatePackingSlipInput,
|
||||
} from './packing-slip';
|
||||
|
||||
type QueryExecutor = Pick<DrizzleDB, 'select' | 'insert' | 'delete'>;
|
||||
|
||||
@Injectable()
|
||||
export class PackingSlipsRepository {
|
||||
constructor(
|
||||
@Inject(DRIZZLE) private readonly db: DrizzleDB,
|
||||
private readonly documentCodeService: DocumentCodeService,
|
||||
) {}
|
||||
|
||||
async list(
|
||||
filters: ListPackingSlipsFilters,
|
||||
): Promise<{ data: PackingSlip[]; total: number }> {
|
||||
const where = this.buildListWhere(filters);
|
||||
const totalRows = await this.db
|
||||
.select({ total: count() })
|
||||
.from(packingSlips)
|
||||
.where(where);
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(packingSlips)
|
||||
.where(where)
|
||||
.orderBy(asc(packingSlips.code))
|
||||
.limit(filters.limit)
|
||||
.offset(filters.offset);
|
||||
return {
|
||||
data: rows.map((row) => this.toDomain(row, [])),
|
||||
total: Number(totalRows[0]?.total ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<PackingSlip | null> {
|
||||
const rows: PackingSlipRow[] = await this.db
|
||||
.select()
|
||||
.from(packingSlips)
|
||||
.where(eq(packingSlips.id, id))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
const products = await this.selectProducts(this.db, id);
|
||||
return this.toDomain(row, products);
|
||||
}
|
||||
|
||||
async create(input: CreatePackingSlipInput): Promise<PackingSlip> {
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const status =
|
||||
input.status ?? Status.create('draft', PACKING_SLIP_STATUSES);
|
||||
try {
|
||||
return await this.db.transaction(async (tx) => {
|
||||
const code =
|
||||
input.code ??
|
||||
(await this.documentCodeService.nextCode(
|
||||
DOCUMENT_PREFIXES.packingSlip,
|
||||
input.date,
|
||||
tx,
|
||||
));
|
||||
const inserted = await tx
|
||||
.insert(packingSlips)
|
||||
.values(this.toInsertValues(input, code, status, now, input.userId))
|
||||
.returning();
|
||||
const row = inserted[0];
|
||||
await this.replaceProducts(tx, row.id, input.products);
|
||||
const products = await this.selectProducts(tx, row.id);
|
||||
return this.toDomain(row, products);
|
||||
});
|
||||
} catch (error) {
|
||||
this.rethrowConstraintViolation(error);
|
||||
}
|
||||
}
|
||||
|
||||
async createMany(inputs: CreatePackingSlipInput[]): Promise<number> {
|
||||
if (inputs.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
for (const input of inputs) {
|
||||
await this.create(input);
|
||||
}
|
||||
return inputs.length;
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
input: UpdatePackingSlipInput,
|
||||
): Promise<PackingSlip> {
|
||||
const existing = await this.findById(id);
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Packing slip not found');
|
||||
}
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
try {
|
||||
return await this.db.transaction(async (tx) => {
|
||||
const updated = await tx
|
||||
.update(packingSlips)
|
||||
.set({
|
||||
code: input.code ?? existing.code,
|
||||
salesOrderId:
|
||||
input.salesOrderId !== undefined
|
||||
? input.salesOrderId
|
||||
: existing.salesOrderId,
|
||||
salesOrderNumber:
|
||||
input.salesOrderNumber !== undefined
|
||||
? input.salesOrderNumber
|
||||
: existing.salesOrderNumber,
|
||||
date: input.date?.value ?? existing.date.value,
|
||||
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,
|
||||
})
|
||||
.where(eq(packingSlips.id, id))
|
||||
.returning();
|
||||
const row = updated[0];
|
||||
if (!row) {
|
||||
throw new NotFoundException('Packing slip not found');
|
||||
}
|
||||
if (input.products !== undefined) {
|
||||
await this.replaceProducts(tx, id, input.products);
|
||||
}
|
||||
const products = await this.selectProducts(tx, id);
|
||||
return this.toDomain(row, products);
|
||||
});
|
||||
} catch (error) {
|
||||
this.rethrowConstraintViolation(error);
|
||||
}
|
||||
}
|
||||
|
||||
async updateStatus(
|
||||
id: string,
|
||||
status: Status,
|
||||
userId: string,
|
||||
): Promise<PackingSlip> {
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const updated = await this.db
|
||||
.update(packingSlips)
|
||||
.set({
|
||||
status: status.value,
|
||||
updatedAt: now.value,
|
||||
updatedBy: userId,
|
||||
})
|
||||
.where(eq(packingSlips.id, id))
|
||||
.returning();
|
||||
const row = updated[0];
|
||||
if (!row) {
|
||||
throw new NotFoundException('Packing slip not found');
|
||||
}
|
||||
const products = await this.selectProducts(this.db, id);
|
||||
return this.toDomain(row, products);
|
||||
}
|
||||
|
||||
async bulkUpdateStatus(
|
||||
ids: string[],
|
||||
status: Status,
|
||||
userId: string,
|
||||
): Promise<number> {
|
||||
if (ids.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const rows = await this.db
|
||||
.update(packingSlips)
|
||||
.set({
|
||||
status: status.value,
|
||||
updatedAt: now.value,
|
||||
updatedBy: userId,
|
||||
})
|
||||
.where(inArray(packingSlips.id, ids))
|
||||
.returning({ id: packingSlips.id });
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
const deleted = await this.db
|
||||
.delete(packingSlips)
|
||||
.where(eq(packingSlips.id, id))
|
||||
.returning({ id: packingSlips.id });
|
||||
if (deleted.length === 0) {
|
||||
throw new NotFoundException('Packing slip not found');
|
||||
}
|
||||
}
|
||||
|
||||
async bulkDelete(ids: string[]): Promise<number> {
|
||||
if (ids.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const deleted = await this.db
|
||||
.delete(packingSlips)
|
||||
.where(inArray(packingSlips.id, ids))
|
||||
.returning({ id: packingSlips.id });
|
||||
return deleted.length;
|
||||
}
|
||||
|
||||
private async selectProducts(
|
||||
executor: QueryExecutor,
|
||||
packingSlipId: string,
|
||||
): Promise<PackingSlipProductRow[]> {
|
||||
return executor
|
||||
.select()
|
||||
.from(packingSlipProducts)
|
||||
.where(eq(packingSlipProducts.packingSlipId, packingSlipId));
|
||||
}
|
||||
|
||||
private async replaceProducts(
|
||||
executor: QueryExecutor,
|
||||
packingSlipId: string,
|
||||
products: readonly PackingSlipLineInput[],
|
||||
): Promise<void> {
|
||||
await executor
|
||||
.delete(packingSlipProducts)
|
||||
.where(eq(packingSlipProducts.packingSlipId, packingSlipId));
|
||||
if (products.length === 0) {
|
||||
return;
|
||||
}
|
||||
await executor.insert(packingSlipProducts).values(
|
||||
products.map((line) => ({
|
||||
packingSlipId,
|
||||
productId: line.productId,
|
||||
quantity: line.quantity.value,
|
||||
price: line.price.value,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
private buildListWhere(filters: ListPackingSlipsFilters): SQL | undefined {
|
||||
const parts: SQL[] = [];
|
||||
if (filters.code) {
|
||||
parts.push(ilike(packingSlips.code, `%${filters.code}%`));
|
||||
}
|
||||
if (filters.status) {
|
||||
parts.push(eq(packingSlips.status, filters.status));
|
||||
}
|
||||
if (filters.customerId) {
|
||||
parts.push(eq(packingSlips.customerId, filters.customerId));
|
||||
}
|
||||
if (filters.salesOrderId) {
|
||||
parts.push(eq(packingSlips.salesOrderId, filters.salesOrderId));
|
||||
}
|
||||
if (filters.search) {
|
||||
const search = or(
|
||||
ilike(packingSlips.code, `%${filters.search}%`),
|
||||
ilike(packingSlips.address, `%${filters.search}%`),
|
||||
ilike(packingSlips.notes, `%${filters.search}%`),
|
||||
);
|
||||
if (search) {
|
||||
parts.push(search);
|
||||
}
|
||||
}
|
||||
if (parts.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return parts.length === 1 ? parts[0] : and(...parts);
|
||||
}
|
||||
|
||||
private toInsertValues(
|
||||
input: CreatePackingSlipInput,
|
||||
code: string,
|
||||
status: Status,
|
||||
now: DateTime,
|
||||
userId: string,
|
||||
) {
|
||||
return {
|
||||
code,
|
||||
salesOrderId: input.salesOrderId ?? null,
|
||||
salesOrderNumber: input.salesOrderNumber ?? null,
|
||||
date: input.date.value,
|
||||
customerId: input.customerId,
|
||||
address: input.address,
|
||||
latitude: input.latitude ?? null,
|
||||
longitude: input.longitude ?? null,
|
||||
notes: input.notes ?? null,
|
||||
status: status.value,
|
||||
createdAt: now.value,
|
||||
updatedAt: now.value,
|
||||
createdBy: userId,
|
||||
updatedBy: userId,
|
||||
};
|
||||
}
|
||||
|
||||
private toDomain(
|
||||
row: PackingSlipRow,
|
||||
productRows: PackingSlipProductRow[],
|
||||
): PackingSlip {
|
||||
return {
|
||||
id: row.id,
|
||||
code: row.code,
|
||||
salesOrderId: row.salesOrderId,
|
||||
salesOrderNumber: row.salesOrderNumber,
|
||||
date: DateTime.fromUnixMs(row.date),
|
||||
customerId: row.customerId,
|
||||
address: row.address,
|
||||
latitude: row.latitude,
|
||||
longitude: row.longitude,
|
||||
notes: row.notes,
|
||||
products: productRows.map((line) => ({
|
||||
id: line.id,
|
||||
productId: line.productId,
|
||||
quantity: Decimal.create(line.quantity),
|
||||
price: Decimal.create(line.price),
|
||||
})),
|
||||
status: Status.create(row.status, PACKING_SLIP_STATUSES),
|
||||
createdAt: DateTime.fromUnixMs(row.createdAt),
|
||||
updatedAt: DateTime.fromUnixMs(row.updatedAt),
|
||||
createdBy: row.createdBy,
|
||||
updatedBy: row.updatedBy,
|
||||
};
|
||||
}
|
||||
|
||||
private rethrowConstraintViolation(error: unknown): never {
|
||||
if (error instanceof NotFoundException) {
|
||||
throw error;
|
||||
}
|
||||
const err = this.unwrapDbError(error);
|
||||
if (err.code === '23505') {
|
||||
throw new ConflictException('Packing slip code already exists');
|
||||
}
|
||||
if (err.code === '23503') {
|
||||
throw new ConflictException('Related record was not found');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
private unwrapDbError(error: unknown): { code?: string } {
|
||||
let current: unknown = error;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (!current || typeof current !== 'object') {
|
||||
break;
|
||||
}
|
||||
const obj = current as { code?: string; cause?: unknown };
|
||||
if (obj.code === '23505' || obj.code === '23503') {
|
||||
return { code: obj.code };
|
||||
}
|
||||
current = obj.cause;
|
||||
}
|
||||
return error as { code?: string };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
import { CustomersService } from '../../configuration/customers/customers.service';
|
||||
import { ProductsService } from '../../configuration/products/products.service';
|
||||
import { SalesOrdersService } from '../sales-orders/sales-orders.service';
|
||||
import { PACKING_SLIP_STATUSES } from '../shared/sales-fields';
|
||||
import type { PackingSlip } from './packing-slip';
|
||||
import { PackingSlipsRepository } from './packing-slips.repository';
|
||||
import { PackingSlipsService } from './packing-slips.service';
|
||||
|
||||
describe('PackingSlipsService', () => {
|
||||
let service: PackingSlipsService;
|
||||
const repository: jest.Mocked<
|
||||
Pick<
|
||||
PackingSlipsRepository,
|
||||
| 'list'
|
||||
| 'findById'
|
||||
| 'create'
|
||||
| 'createMany'
|
||||
| 'update'
|
||||
| 'updateStatus'
|
||||
| 'bulkUpdateStatus'
|
||||
| 'delete'
|
||||
| 'bulkDelete'
|
||||
>
|
||||
> = {
|
||||
list: jest.fn(),
|
||||
findById: jest.fn(),
|
||||
create: jest.fn(),
|
||||
createMany: jest.fn(),
|
||||
update: jest.fn(),
|
||||
updateStatus: jest.fn(),
|
||||
bulkUpdateStatus: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
bulkDelete: jest.fn(),
|
||||
};
|
||||
const customersService = { findById: jest.fn() };
|
||||
const productsService = { findById: jest.fn() };
|
||||
const salesOrdersService = { findById: jest.fn() };
|
||||
|
||||
const now = DateTime.fromUnixMs(1_700_000_000_000);
|
||||
const sample: PackingSlip = {
|
||||
id: 'ps-1',
|
||||
code: 'PS-20260824-0001',
|
||||
salesOrderId: null,
|
||||
salesOrderNumber: null,
|
||||
date: now,
|
||||
customerId: 'cus-1',
|
||||
address: 'Jl Sudirman 1',
|
||||
latitude: -6.2,
|
||||
longitude: 106.8,
|
||||
notes: null,
|
||||
products: [
|
||||
{
|
||||
id: 'line-1',
|
||||
productId: 'prd-1',
|
||||
quantity: Decimal.create('2'),
|
||||
price: Decimal.create('12500'),
|
||||
},
|
||||
],
|
||||
status: Status.create('draft', PACKING_SLIP_STATUSES),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: 'user-1',
|
||||
updatedBy: 'user-1',
|
||||
};
|
||||
|
||||
const createBody = {
|
||||
date: '2026-08-24T10:00:00+07:00',
|
||||
customerId: 'cus-1',
|
||||
address: 'Jl Sudirman 1',
|
||||
products: [{ productId: 'prd-1', quantity: '2' }],
|
||||
userId: 'user-1',
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
customersService.findById.mockResolvedValue({ id: 'cus-1' });
|
||||
productsService.findById.mockResolvedValue({
|
||||
id: 'prd-1',
|
||||
price: '12500.0000',
|
||||
});
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
PackingSlipsService,
|
||||
{ provide: PackingSlipsRepository, useValue: repository },
|
||||
{ provide: CustomersService, useValue: customersService },
|
||||
{ provide: ProductsService, useValue: productsService },
|
||||
{ provide: SalesOrdersService, useValue: salesOrdersService },
|
||||
],
|
||||
}).compile();
|
||||
service = moduleRef.get(PackingSlipsService);
|
||||
});
|
||||
|
||||
it('create defaults price from the product catalog', async () => {
|
||||
repository.create.mockResolvedValue(sample);
|
||||
const result = await service.create(createBody);
|
||||
expect(result.code).toBe('PS-20260824-0001');
|
||||
expect(repository.create).toHaveBeenCalled();
|
||||
const [created] = repository.create.mock.calls[0];
|
||||
expect(created.products[0]?.price.value).toBe('12500.0000');
|
||||
expect(created.status?.value).toBe('draft');
|
||||
expect(created.salesOrderId).toBeNull();
|
||||
});
|
||||
|
||||
it('create copies header and lines from a sales order', async () => {
|
||||
salesOrdersService.findById.mockResolvedValue({
|
||||
id: 'so-1',
|
||||
code: 'SO-20260824-0003',
|
||||
date: now.value,
|
||||
customerId: 'cus-1',
|
||||
address: 'Jl Thamrin 9',
|
||||
latitude: -6.1,
|
||||
longitude: 106.9,
|
||||
notes: 'from order',
|
||||
products: [
|
||||
{
|
||||
id: 'ol-1',
|
||||
productId: 'prd-1',
|
||||
quantity: '3.0000',
|
||||
price: '10000.0000',
|
||||
},
|
||||
],
|
||||
});
|
||||
repository.create.mockResolvedValue({
|
||||
...sample,
|
||||
salesOrderId: 'so-1',
|
||||
salesOrderNumber: 'SO-20260824-0003',
|
||||
});
|
||||
await service.create({
|
||||
salesOrderId: 'so-1',
|
||||
userId: 'user-1',
|
||||
});
|
||||
expect(repository.create).toHaveBeenCalled();
|
||||
const [copied] = repository.create.mock.calls[0];
|
||||
expect(copied.salesOrderId).toBe('so-1');
|
||||
expect(copied.salesOrderNumber).toBe('SO-20260824-0003');
|
||||
expect(copied.address).toBe('Jl Thamrin 9');
|
||||
expect(copied.products).toHaveLength(1);
|
||||
expect(copied.products[0]?.quantity.value).toBe('3.0000');
|
||||
expect(salesOrdersService.findById).toHaveBeenCalledWith('so-1');
|
||||
});
|
||||
|
||||
it('create uses body products instead of copied order lines', async () => {
|
||||
salesOrdersService.findById.mockResolvedValue({
|
||||
id: 'so-1',
|
||||
code: 'SO-20260824-0003',
|
||||
date: now.value,
|
||||
customerId: 'cus-1',
|
||||
address: 'Jl Thamrin 9',
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
notes: null,
|
||||
products: [
|
||||
{
|
||||
id: 'ol-1',
|
||||
productId: 'prd-1',
|
||||
quantity: '3.0000',
|
||||
price: '10000.0000',
|
||||
},
|
||||
],
|
||||
});
|
||||
repository.create.mockResolvedValue(sample);
|
||||
await service.create({
|
||||
salesOrderId: 'so-1',
|
||||
products: [{ productId: 'prd-1', quantity: '1' }],
|
||||
userId: 'user-1',
|
||||
});
|
||||
expect(repository.create).toHaveBeenCalled();
|
||||
const [replaced] = repository.create.mock.calls[0];
|
||||
expect(replaced.salesOrderNumber).toBe('SO-20260824-0003');
|
||||
expect(replaced.products[0]?.quantity.value).toBe('1.0000');
|
||||
});
|
||||
|
||||
it('create ignores a client salesOrderNumber when salesOrderId is set', async () => {
|
||||
salesOrdersService.findById.mockResolvedValue({
|
||||
id: 'so-1',
|
||||
code: 'SO-20260824-0003',
|
||||
date: now.value,
|
||||
customerId: 'cus-1',
|
||||
address: 'Jl Thamrin 9',
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
notes: null,
|
||||
products: [
|
||||
{
|
||||
id: 'ol-1',
|
||||
productId: 'prd-1',
|
||||
quantity: '1.0000',
|
||||
price: '12500.0000',
|
||||
},
|
||||
],
|
||||
});
|
||||
repository.create.mockResolvedValue(sample);
|
||||
await service.create({
|
||||
salesOrderId: 'so-1',
|
||||
salesOrderNumber: 'CLIENT-NO',
|
||||
userId: 'user-1',
|
||||
});
|
||||
expect(repository.create).toHaveBeenCalled();
|
||||
const [ignored] = repository.create.mock.calls[0];
|
||||
expect(ignored.salesOrderNumber).toBe('SO-20260824-0003');
|
||||
});
|
||||
|
||||
it('create rejects a missing product line list without a sales order', async () => {
|
||||
await expect(
|
||||
service.create({ ...createBody, products: [] }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('findById throws when missing', async () => {
|
||||
repository.findById.mockResolvedValue(null);
|
||||
await expect(service.findById('missing')).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('update rejects status on PATCH', async () => {
|
||||
await expect(
|
||||
service.update('ps-1', { status: 'processed', userId: 'user-1' }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('updateStatus uses the packing-slip allow-list', async () => {
|
||||
repository.updateStatus.mockResolvedValue(sample);
|
||||
await service.updateStatus('ps-1', 'processed', 'user-1');
|
||||
expect(repository.updateStatus).toHaveBeenCalledWith(
|
||||
'ps-1',
|
||||
expect.objectContaining({ value: 'processed' }),
|
||||
'user-1',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,506 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type { PaginationResponse } from '../../../common/http/response';
|
||||
import { toListPage } from '../../../common/http/response';
|
||||
import { InvalidDateTimeError } from '../../../common/value-objects/date-time/invalid-date-time.error';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||
import { InvalidDecimalError } from '../../../common/value-objects/decimal/invalid-decimal.error';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
import { CustomersService } from '../../configuration/customers/customers.service';
|
||||
import { ProductsService } from '../../configuration/products/products.service';
|
||||
import { SalesOrdersService } from '../sales-orders/sales-orders.service';
|
||||
import {
|
||||
isValidDocumentAddress,
|
||||
isValidDocumentCode,
|
||||
isValidDocumentNotes,
|
||||
isValidLatitude,
|
||||
isValidLongitude,
|
||||
parseCsvRecord,
|
||||
PACKING_SLIP_STATUSES,
|
||||
} from '../shared/sales-fields';
|
||||
import type {
|
||||
CreatePackingSlipInput,
|
||||
PackingSlip,
|
||||
PackingSlipLineInput,
|
||||
UpdatePackingSlipInput,
|
||||
} from './packing-slip';
|
||||
import { PackingSlipsRepository } from './packing-slips.repository';
|
||||
|
||||
export type SalesLineBody = {
|
||||
readonly productId: string;
|
||||
readonly quantity: string;
|
||||
readonly price?: string;
|
||||
};
|
||||
|
||||
export type ListPackingSlipsQuery = {
|
||||
readonly code?: string;
|
||||
readonly status?: string;
|
||||
readonly customerId?: string;
|
||||
readonly salesOrderId?: string;
|
||||
readonly search?: string;
|
||||
readonly page?: number;
|
||||
readonly limit?: number;
|
||||
readonly offset?: number;
|
||||
};
|
||||
|
||||
const CSV_REQUIRED_HEADERS = ['date', 'customerId', 'address'] as const;
|
||||
|
||||
@Injectable()
|
||||
export class PackingSlipsService {
|
||||
constructor(
|
||||
private readonly packingSlipsRepository: PackingSlipsRepository,
|
||||
private readonly customersService: CustomersService,
|
||||
private readonly productsService: ProductsService,
|
||||
private readonly salesOrdersService: SalesOrdersService,
|
||||
) {}
|
||||
|
||||
async list(
|
||||
query: ListPackingSlipsQuery,
|
||||
): Promise<
|
||||
PaginationResponse<ReturnType<PackingSlipsService['toListItem']>>
|
||||
> {
|
||||
const page = toListPage(query);
|
||||
const { data, total } = await this.packingSlipsRepository.list({
|
||||
code: query.code,
|
||||
status: query.status,
|
||||
customerId: query.customerId,
|
||||
salesOrderId: query.salesOrderId,
|
||||
search: query.search,
|
||||
limit: page.limit,
|
||||
offset: page.offset,
|
||||
});
|
||||
return {
|
||||
data: data.map((item) => this.toListItem(item)),
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
async findById(
|
||||
id: string,
|
||||
): Promise<ReturnType<PackingSlipsService['toDetail']>> {
|
||||
const found = await this.packingSlipsRepository.findById(id);
|
||||
if (!found) {
|
||||
throw new NotFoundException('Packing slip not found');
|
||||
}
|
||||
return this.toDetail(found);
|
||||
}
|
||||
|
||||
async create(input: {
|
||||
code?: string;
|
||||
salesOrderId?: string;
|
||||
salesOrderNumber?: string;
|
||||
date?: string;
|
||||
customerId?: string;
|
||||
address?: string;
|
||||
latitude?: number | null;
|
||||
longitude?: number | null;
|
||||
notes?: string | null;
|
||||
products?: SalesLineBody[];
|
||||
status?: string;
|
||||
userId: string;
|
||||
}): Promise<ReturnType<PackingSlipsService['toDetail']>> {
|
||||
const merged = await this.mergeFromSalesOrder(input);
|
||||
await this.assertRelations(merged);
|
||||
const created = await this.packingSlipsRepository.create(
|
||||
await this.toCreateInput(merged),
|
||||
);
|
||||
return this.toDetail(created);
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
input: {
|
||||
code?: string;
|
||||
salesOrderId?: string | null;
|
||||
date?: string;
|
||||
customerId?: string;
|
||||
address?: string;
|
||||
latitude?: number | null;
|
||||
longitude?: number | null;
|
||||
notes?: string | null;
|
||||
products?: SalesLineBody[];
|
||||
status?: unknown;
|
||||
userId: string;
|
||||
},
|
||||
): Promise<ReturnType<PackingSlipsService['toDetail']>> {
|
||||
if (input.status !== undefined) {
|
||||
throw new BadRequestException('status cannot be updated via PATCH');
|
||||
}
|
||||
await this.assertRelations(input);
|
||||
let salesOrderNumber: string | null | undefined;
|
||||
if (input.salesOrderId) {
|
||||
const order = await this.salesOrdersService.findById(input.salesOrderId);
|
||||
salesOrderNumber = order.code;
|
||||
} else if (input.salesOrderId === null) {
|
||||
salesOrderNumber = null;
|
||||
}
|
||||
const payload: UpdatePackingSlipInput = {
|
||||
code: input.code !== undefined ? this.assertCode(input.code) : undefined,
|
||||
salesOrderId: input.salesOrderId,
|
||||
salesOrderNumber,
|
||||
date: input.date !== undefined ? this.assertDate(input.date) : undefined,
|
||||
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:
|
||||
input.products !== undefined
|
||||
? await this.assertLines(input.products)
|
||||
: undefined,
|
||||
userId: input.userId,
|
||||
};
|
||||
const updated = await this.packingSlipsRepository.update(id, payload);
|
||||
return this.toDetail(updated);
|
||||
}
|
||||
|
||||
async updateStatus(
|
||||
id: string,
|
||||
statusRaw: string,
|
||||
userId: string,
|
||||
): Promise<ReturnType<PackingSlipsService['toDetail']>> {
|
||||
const status = this.assertStatus(statusRaw);
|
||||
const updated = await this.packingSlipsRepository.updateStatus(
|
||||
id,
|
||||
status,
|
||||
userId,
|
||||
);
|
||||
return this.toDetail(updated);
|
||||
}
|
||||
|
||||
async bulkUpdateStatus(
|
||||
ids: string[],
|
||||
statusRaw: string,
|
||||
userId: string,
|
||||
): Promise<{ updated: number }> {
|
||||
const status = this.assertStatus(statusRaw);
|
||||
const updated = await this.packingSlipsRepository.bulkUpdateStatus(
|
||||
ids,
|
||||
status,
|
||||
userId,
|
||||
);
|
||||
return { updated };
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.packingSlipsRepository.delete(id);
|
||||
}
|
||||
|
||||
async bulkDelete(ids: string[]): Promise<{ deleted: number }> {
|
||||
const deleted = await this.packingSlipsRepository.bulkDelete(ids);
|
||||
return { deleted };
|
||||
}
|
||||
|
||||
async importCsv(csv: string, userId: string): Promise<{ imported: number }> {
|
||||
const rawLines = csv.split(/\r?\n/);
|
||||
const filled = rawLines
|
||||
.map((line, index) => ({ line: line.trim(), lineNo: index + 1 }))
|
||||
.filter((entry) => entry.line.length > 0);
|
||||
if (filled.length === 0) {
|
||||
throw new BadRequestException('CSV is empty');
|
||||
}
|
||||
if (filled.length > 501) {
|
||||
throw new BadRequestException('CSV exceeds maximum of 500 data rows');
|
||||
}
|
||||
const header = parseCsvRecord(filled[0].line).map((h) =>
|
||||
h.trim().toLowerCase(),
|
||||
);
|
||||
const missing = CSV_REQUIRED_HEADERS.filter(
|
||||
(h) => header.indexOf(h.toLowerCase()) < 0,
|
||||
);
|
||||
if (missing.length > 0) {
|
||||
throw new BadRequestException('CSV must include required headers');
|
||||
}
|
||||
const idx = (key: string) => header.indexOf(key.toLowerCase());
|
||||
const errors: string[] = [];
|
||||
const rows: CreatePackingSlipInput[] = [];
|
||||
for (let i = 1; i < filled.length; i++) {
|
||||
const cols = parseCsvRecord(filled[i].line);
|
||||
const rowNum = filled[i].lineNo;
|
||||
try {
|
||||
rows.push(
|
||||
await this.toCreateInput({
|
||||
code: idx('code') >= 0 ? cols[idx('code')] : undefined,
|
||||
date: cols[idx('date')] ?? '',
|
||||
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,
|
||||
userId,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
const reason =
|
||||
error instanceof BadRequestException ? error.message : 'invalid data';
|
||||
errors.push(`row ${rowNum}: ${reason}`);
|
||||
}
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
throw new BadRequestException({
|
||||
message: 'CSV validation failed',
|
||||
errors,
|
||||
});
|
||||
}
|
||||
await this.packingSlipsRepository.createMany(rows);
|
||||
return { imported: rows.length };
|
||||
}
|
||||
|
||||
toListItem(item: PackingSlip) {
|
||||
return {
|
||||
id: item.id,
|
||||
code: item.code,
|
||||
salesOrderId: item.salesOrderId,
|
||||
salesOrderNumber: item.salesOrderNumber,
|
||||
date: item.date.value,
|
||||
customerId: item.customerId,
|
||||
address: item.address,
|
||||
latitude: item.latitude,
|
||||
longitude: item.longitude,
|
||||
notes: item.notes,
|
||||
status: item.status.value,
|
||||
createdAt: item.createdAt.value,
|
||||
updatedAt: item.updatedAt.value,
|
||||
createdBy: item.createdBy,
|
||||
updatedBy: item.updatedBy,
|
||||
};
|
||||
}
|
||||
|
||||
toDetail(item: PackingSlip) {
|
||||
return {
|
||||
...this.toListItem(item),
|
||||
products: item.products.map((line) => ({
|
||||
id: line.id,
|
||||
productId: line.productId,
|
||||
quantity: line.quantity.value,
|
||||
price: line.price.value,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
private async mergeFromSalesOrder(input: {
|
||||
code?: string;
|
||||
salesOrderId?: string;
|
||||
salesOrderNumber?: string;
|
||||
date?: string;
|
||||
customerId?: string;
|
||||
address?: string;
|
||||
latitude?: number | null;
|
||||
longitude?: number | null;
|
||||
notes?: string | null;
|
||||
products?: SalesLineBody[];
|
||||
status?: string;
|
||||
userId: string;
|
||||
}) {
|
||||
if (!input.salesOrderId) {
|
||||
return {
|
||||
...input,
|
||||
salesOrderId: null as string | null,
|
||||
salesOrderNumber: null as string | null,
|
||||
date: input.date ?? '',
|
||||
customerId: input.customerId ?? '',
|
||||
address: input.address ?? '',
|
||||
products: input.products ?? [],
|
||||
};
|
||||
}
|
||||
const source = await this.salesOrdersService.findById(input.salesOrderId);
|
||||
return {
|
||||
...input,
|
||||
salesOrderId: input.salesOrderId,
|
||||
salesOrderNumber: source.code,
|
||||
date: input.date ?? DateTime.fromUnixMs(source.date).format(),
|
||||
customerId: input.customerId ?? source.customerId,
|
||||
address: input.address ?? source.address,
|
||||
latitude: input.latitude !== undefined ? input.latitude : source.latitude,
|
||||
longitude:
|
||||
input.longitude !== undefined ? input.longitude : source.longitude,
|
||||
notes: input.notes !== undefined ? input.notes : source.notes,
|
||||
products:
|
||||
input.products ??
|
||||
source.products.map((line) => ({
|
||||
productId: line.productId,
|
||||
quantity: line.quantity,
|
||||
price: line.price,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
private async toCreateInput(input: {
|
||||
code?: string;
|
||||
salesOrderId?: string | null;
|
||||
salesOrderNumber?: string | null;
|
||||
date: string;
|
||||
customerId: string;
|
||||
address: string;
|
||||
latitude?: number | null;
|
||||
longitude?: number | null;
|
||||
notes?: string | null;
|
||||
products: SalesLineBody[];
|
||||
status?: string;
|
||||
userId: string;
|
||||
}): Promise<CreatePackingSlipInput> {
|
||||
return {
|
||||
code:
|
||||
input.code !== undefined && input.code !== ''
|
||||
? this.assertCode(input.code)
|
||||
: undefined,
|
||||
salesOrderId: input.salesOrderId ?? null,
|
||||
salesOrderNumber: input.salesOrderNumber ?? null,
|
||||
date: this.assertDate(input.date),
|
||||
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
|
||||
? this.assertStatus(input.status)
|
||||
: Status.create('draft', PACKING_SLIP_STATUSES),
|
||||
userId: input.userId,
|
||||
};
|
||||
}
|
||||
|
||||
private async assertRelations(input: { customerId?: string }): Promise<void> {
|
||||
if (input.customerId) {
|
||||
await this.customersService.findById(input.customerId);
|
||||
}
|
||||
}
|
||||
|
||||
private async assertLines(
|
||||
lines: SalesLineBody[],
|
||||
): Promise<PackingSlipLineInput[]> {
|
||||
if (!Array.isArray(lines) || lines.length === 0) {
|
||||
throw new BadRequestException('At least one product line is required');
|
||||
}
|
||||
const result: PackingSlipLineInput[] = [];
|
||||
for (const line of lines) {
|
||||
const product = await this.productsService.findById(line.productId);
|
||||
const quantity = this.assertPositiveDecimal(line.quantity, 'quantity');
|
||||
let price: Decimal;
|
||||
if (line.price === undefined || line.price === '') {
|
||||
if (product.price === null) {
|
||||
throw new BadRequestException('Product price is required');
|
||||
}
|
||||
price = Decimal.create(product.price);
|
||||
} else {
|
||||
price = this.assertNonNegativeDecimal(line.price, 'price');
|
||||
}
|
||||
result.push({ productId: product.id, quantity, price });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private assertCode(raw: string): string {
|
||||
const code = raw.trim();
|
||||
if (!isValidDocumentCode(code)) {
|
||||
throw new BadRequestException('Invalid document code');
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
private assertDate(raw: string): DateTime {
|
||||
try {
|
||||
return DateTime.create(raw);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidDateTimeError) {
|
||||
throw new BadRequestException('Invalid date');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
if (!isValidDocumentNotes(raw)) {
|
||||
throw new BadRequestException('Invalid notes');
|
||||
}
|
||||
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, PACKING_SLIP_STATUSES);
|
||||
} catch {
|
||||
throw new BadRequestException('Invalid status');
|
||||
}
|
||||
}
|
||||
|
||||
private assertPositiveDecimal(raw: string, label: string): Decimal {
|
||||
const value = this.parseDecimal(raw);
|
||||
if (!value.isPositive()) {
|
||||
throw new BadRequestException(`Invalid ${label}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private assertNonNegativeDecimal(raw: string, label: string): Decimal {
|
||||
const value = this.parseDecimal(raw);
|
||||
if (value.isNegative()) {
|
||||
throw new BadRequestException(`Invalid ${label}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private parseDecimal(raw: string): Decimal {
|
||||
try {
|
||||
return Decimal.create(raw);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidDecimalError) {
|
||||
throw new BadRequestException('Invalid decimal');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayNotEmpty,
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Matches,
|
||||
MaxLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { PaginationQueryDto } from '../../../../common/http/response';
|
||||
import {
|
||||
DOCUMENT_CODE_MAX_LENGTH,
|
||||
DOCUMENT_CODE_PATTERN,
|
||||
DOCUMENT_NOTES_MAX_LENGTH,
|
||||
SALES_INVOICE_STATUSES,
|
||||
} from '../../shared/sales-fields';
|
||||
|
||||
export class SalesLineDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID('4')
|
||||
productId!: string;
|
||||
|
||||
@ApiProperty({ example: '2.0000' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
quantity!: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '12500.0000' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
price?: string;
|
||||
}
|
||||
|
||||
export class CreateSalesInvoiceDto {
|
||||
@ApiPropertyOptional({ example: 'SI-20260824-0001' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(DOCUMENT_CODE_MAX_LENGTH)
|
||||
@Matches(DOCUMENT_CODE_PATTERN)
|
||||
code?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
salesOrderId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
packingSlipId?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '2026-08-24T10:00:00+07:00' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
date?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
salesPersonId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
branchId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
divisionId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
customerId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(DOCUMENT_NOTES_MAX_LENGTH)
|
||||
notes?: string;
|
||||
|
||||
@ApiPropertyOptional({ type: [SalesLineDto] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => SalesLineDto)
|
||||
products?: SalesLineDto[];
|
||||
|
||||
@ApiPropertyOptional({ enum: SALES_INVOICE_STATUSES })
|
||||
@IsOptional()
|
||||
@IsIn([...SALES_INVOICE_STATUSES])
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export class UpdateSalesInvoiceDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(DOCUMENT_CODE_MAX_LENGTH)
|
||||
@Matches(DOCUMENT_CODE_PATTERN)
|
||||
code?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid', nullable: true })
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
salesOrderId?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid', nullable: true })
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
packingSlipId?: string | null;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
date?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
salesPersonId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
branchId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
divisionId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
customerId?: string;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ type: [SalesLineDto] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => SalesLineDto)
|
||||
products?: SalesLineDto[];
|
||||
}
|
||||
|
||||
export class UpdateSalesInvoiceStatusDto {
|
||||
@ApiProperty({ enum: SALES_INVOICE_STATUSES })
|
||||
@IsIn([...SALES_INVOICE_STATUSES])
|
||||
status!: string;
|
||||
}
|
||||
|
||||
export class BulkIdsDto {
|
||||
@ApiProperty({ type: [String], format: 'uuid' })
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsUUID('4', { each: true })
|
||||
ids!: string[];
|
||||
}
|
||||
|
||||
export class BulkStatusDto {
|
||||
@ApiProperty({ type: [String], format: 'uuid' })
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsUUID('4', { each: true })
|
||||
ids!: string[];
|
||||
|
||||
@ApiProperty({ enum: SALES_INVOICE_STATUSES })
|
||||
@IsIn([...SALES_INVOICE_STATUSES])
|
||||
status!: string;
|
||||
}
|
||||
|
||||
export class ListSalesInvoicesQueryDto extends PaginationQueryDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
code?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: SALES_INVOICE_STATUSES })
|
||||
@IsOptional()
|
||||
@IsIn([...SALES_INVOICE_STATUSES])
|
||||
status?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
customerId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
salesPersonId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
branchId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
divisionId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
salesOrderId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
packingSlipId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export class SalesInvoiceDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
id!: string;
|
||||
@ApiProperty()
|
||||
code!: string;
|
||||
@ApiPropertyOptional({ nullable: true, format: 'uuid' })
|
||||
salesOrderId!: string | null;
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
salesOrderCode!: string | null;
|
||||
@ApiPropertyOptional({ nullable: true, format: 'uuid' })
|
||||
packingSlipId!: string | null;
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
packingSlipCode!: string | null;
|
||||
@ApiProperty()
|
||||
date!: number;
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
salesPersonId!: string;
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
branchId!: string;
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
divisionId!: string;
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
customerId!: string;
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
notes!: string | null;
|
||||
@ApiProperty()
|
||||
balance!: string;
|
||||
@ApiProperty({ enum: SALES_INVOICE_STATUSES })
|
||||
status!: string;
|
||||
@ApiProperty()
|
||||
createdAt!: number;
|
||||
@ApiProperty()
|
||||
updatedAt!: number;
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
createdBy!: string;
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
updatedBy!: string;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
|
||||
export type SalesInvoiceLine = {
|
||||
readonly id: string;
|
||||
readonly productId: string;
|
||||
readonly quantity: Decimal;
|
||||
readonly price: Decimal;
|
||||
};
|
||||
|
||||
export type SalesInvoice = {
|
||||
readonly id: string;
|
||||
readonly code: string;
|
||||
readonly salesOrderId: string | null;
|
||||
readonly salesOrderCode: string | null;
|
||||
readonly packingSlipId: string | null;
|
||||
readonly packingSlipCode: string | null;
|
||||
readonly date: DateTime;
|
||||
readonly salesPersonId: string;
|
||||
readonly branchId: string;
|
||||
readonly divisionId: string;
|
||||
readonly customerId: string;
|
||||
readonly notes: string | null;
|
||||
readonly balance: Decimal;
|
||||
readonly products: readonly SalesInvoiceLine[];
|
||||
readonly status: Status;
|
||||
readonly createdAt: DateTime;
|
||||
readonly updatedAt: DateTime;
|
||||
readonly createdBy: string;
|
||||
readonly updatedBy: string;
|
||||
};
|
||||
|
||||
export type SalesInvoiceLineInput = {
|
||||
readonly productId: string;
|
||||
readonly quantity: Decimal;
|
||||
readonly price: Decimal;
|
||||
};
|
||||
|
||||
export type CreateSalesInvoiceInput = {
|
||||
readonly code?: string;
|
||||
readonly salesOrderId?: string | null;
|
||||
readonly salesOrderCode?: string | null;
|
||||
readonly packingSlipId?: string | null;
|
||||
readonly packingSlipCode?: string | null;
|
||||
readonly date: DateTime;
|
||||
readonly salesPersonId: string;
|
||||
readonly branchId: string;
|
||||
readonly divisionId: string;
|
||||
readonly customerId: string;
|
||||
readonly notes?: string | null;
|
||||
readonly products: readonly SalesInvoiceLineInput[];
|
||||
readonly status?: Status;
|
||||
readonly userId: string;
|
||||
};
|
||||
|
||||
export type UpdateSalesInvoiceInput = {
|
||||
readonly code?: string;
|
||||
readonly salesOrderId?: string | null;
|
||||
readonly salesOrderCode?: string | null;
|
||||
readonly packingSlipId?: string | null;
|
||||
readonly packingSlipCode?: string | null;
|
||||
readonly date?: DateTime;
|
||||
readonly salesPersonId?: string;
|
||||
readonly branchId?: string;
|
||||
readonly divisionId?: string;
|
||||
readonly customerId?: string;
|
||||
readonly notes?: string | null;
|
||||
readonly products?: readonly SalesInvoiceLineInput[];
|
||||
readonly userId: string;
|
||||
};
|
||||
|
||||
export type ListSalesInvoicesFilters = {
|
||||
readonly code?: string;
|
||||
readonly status?: string;
|
||||
readonly customerId?: string;
|
||||
readonly salesPersonId?: string;
|
||||
readonly branchId?: string;
|
||||
readonly divisionId?: string;
|
||||
readonly salesOrderId?: string;
|
||||
readonly packingSlipId?: string;
|
||||
readonly search?: string;
|
||||
readonly limit: number;
|
||||
readonly offset: number;
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiForbiddenResponse,
|
||||
ApiNotFoundResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
ApiUnauthorizedResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { RequirePrivilege } from '../../../common/decorators/require-privilege.decorator';
|
||||
import {
|
||||
Pagination,
|
||||
type PaginationResponse,
|
||||
PaginationMetaDto,
|
||||
} from '../../../common/http/response';
|
||||
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
|
||||
import {
|
||||
SalesInvoiceDto,
|
||||
ListSalesInvoicesQueryDto,
|
||||
} from './dto/sales-invoice.dto';
|
||||
import { SalesInvoicesService } from './sales-invoices.service';
|
||||
|
||||
export const SALES_INVOICE_PRIVILEGE_KEY = 'SALES.INVOICE';
|
||||
|
||||
@ApiTags('sales-invoices')
|
||||
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||
@Controller('sales-invoices')
|
||||
export class SalesInvoicesReadController {
|
||||
constructor(private readonly salesInvoicesService: SalesInvoicesService) {}
|
||||
|
||||
@Get()
|
||||
@Pagination()
|
||||
@RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEY, 'view')
|
||||
@ApiOperation({ summary: 'List sales invoices' })
|
||||
@ApiOkResponse({
|
||||
schema: {
|
||||
properties: {
|
||||
data: {
|
||||
type: 'array',
|
||||
items: { $ref: '#/components/schemas/SalesInvoiceDto' },
|
||||
},
|
||||
meta: { $ref: '#/components/schemas/PaginationMetaDto' },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
list(
|
||||
@Query() query: ListSalesInvoicesQueryDto,
|
||||
): Promise<PaginationResponse<SalesInvoiceDto>> {
|
||||
return this.salesInvoicesService.list(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEY, 'view')
|
||||
@ApiOperation({ summary: 'Get sales invoice detail' })
|
||||
@ApiOkResponse({ type: SalesInvoiceDto })
|
||||
@ApiNotFoundResponse()
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string): Promise<SalesInvoiceDto> {
|
||||
return this.salesInvoicesService.findById(id);
|
||||
}
|
||||
}
|
||||
|
||||
void PaginationMetaDto;
|
||||
@@ -0,0 +1,199 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
HttpCode,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
ApiConsumes,
|
||||
ApiCreatedResponse,
|
||||
ApiForbiddenResponse,
|
||||
ApiNoContentResponse,
|
||||
ApiNotFoundResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
ApiUnauthorizedResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { CurrentUser } from '../../../common/decorators/current-user.decorator';
|
||||
import { RequirePrivilege } from '../../../common/decorators/require-privilege.decorator';
|
||||
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
|
||||
import { isAllowedCsvUpload } from '../shared/sales-fields';
|
||||
import { SALES_INVOICE_PRIVILEGE_KEY } from './sales-invoices-read.controller';
|
||||
import { SalesInvoicesService } from './sales-invoices.service';
|
||||
import {
|
||||
BulkIdsDto,
|
||||
BulkStatusDto,
|
||||
CreateSalesInvoiceDto,
|
||||
SalesInvoiceDto,
|
||||
UpdateSalesInvoiceDto,
|
||||
UpdateSalesInvoiceStatusDto,
|
||||
} from './dto/sales-invoice.dto';
|
||||
|
||||
@ApiTags('sales-invoices')
|
||||
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||
@Controller('sales-invoices')
|
||||
export class SalesInvoicesWriteController {
|
||||
constructor(private readonly salesInvoicesService: SalesInvoicesService) {}
|
||||
|
||||
@Post('import')
|
||||
@RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEY, 'import')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
limits: { fileSize: 1_048_576 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (!isAllowedCsvUpload(file)) {
|
||||
cb(new BadRequestException('Only CSV files are allowed'), false);
|
||||
return;
|
||||
}
|
||||
cb(null, true);
|
||||
},
|
||||
}),
|
||||
)
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
file: { type: 'string', format: 'binary' },
|
||||
},
|
||||
required: ['file'],
|
||||
},
|
||||
})
|
||||
@ApiOperation({ summary: 'Import sales invoices from CSV' })
|
||||
@ApiOkResponse({
|
||||
schema: { properties: { imported: { type: 'number' } } },
|
||||
})
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
importCsv(
|
||||
@UploadedFile() file: { buffer?: Buffer } | undefined,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<{ imported: number }> {
|
||||
const csv = file?.buffer?.toString('utf8') ?? '';
|
||||
return this.salesInvoicesService.importCsv(csv, userId);
|
||||
}
|
||||
|
||||
@Post('bulk-delete')
|
||||
@HttpCode(200)
|
||||
@RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEY, 'delete')
|
||||
@ApiOperation({ summary: 'Bulk delete sales invoices' })
|
||||
@ApiOkResponse({
|
||||
schema: { properties: { deleted: { type: 'number' } } },
|
||||
})
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
bulkDelete(@Body() dto: BulkIdsDto): Promise<{ deleted: number }> {
|
||||
return this.salesInvoicesService.bulkDelete(dto.ids);
|
||||
}
|
||||
|
||||
@Post('bulk-status')
|
||||
@HttpCode(200)
|
||||
@RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEY, 'update')
|
||||
@ApiOperation({ summary: 'Bulk update sales invoice status' })
|
||||
@ApiOkResponse({
|
||||
schema: { properties: { updated: { type: 'number' } } },
|
||||
})
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
bulkStatus(
|
||||
@Body() dto: BulkStatusDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<{ updated: number }> {
|
||||
return this.salesInvoicesService.bulkUpdateStatus(
|
||||
dto.ids,
|
||||
dto.status,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEY, 'create')
|
||||
@ApiOperation({ summary: 'Create sales invoice' })
|
||||
@ApiCreatedResponse({ type: SalesInvoiceDto })
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
create(
|
||||
@Body() dto: CreateSalesInvoiceDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<SalesInvoiceDto> {
|
||||
return this.salesInvoicesService.create({
|
||||
code: dto.code,
|
||||
salesOrderId: dto.salesOrderId,
|
||||
packingSlipId: dto.packingSlipId,
|
||||
date: dto.date,
|
||||
salesPersonId: dto.salesPersonId,
|
||||
branchId: dto.branchId,
|
||||
divisionId: dto.divisionId,
|
||||
customerId: dto.customerId,
|
||||
notes: dto.notes,
|
||||
products: dto.products,
|
||||
status: dto.status,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
@Patch(':id/status')
|
||||
@RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEY, 'update')
|
||||
@ApiOperation({ summary: 'Update sales invoice status' })
|
||||
@ApiOkResponse({ type: SalesInvoiceDto })
|
||||
@ApiNotFoundResponse()
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
updateStatus(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateSalesInvoiceStatusDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<SalesInvoiceDto> {
|
||||
return this.salesInvoicesService.updateStatus(id, dto.status, userId);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEY, 'update')
|
||||
@ApiOperation({ summary: 'Update sales invoice (not status)' })
|
||||
@ApiOkResponse({ type: SalesInvoiceDto })
|
||||
@ApiNotFoundResponse()
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
update(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateSalesInvoiceDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<SalesInvoiceDto> {
|
||||
return this.salesInvoicesService.update(id, {
|
||||
code: dto.code,
|
||||
salesOrderId: dto.salesOrderId,
|
||||
packingSlipId: dto.packingSlipId,
|
||||
date: dto.date,
|
||||
salesPersonId: dto.salesPersonId,
|
||||
branchId: dto.branchId,
|
||||
divisionId: dto.divisionId,
|
||||
customerId: dto.customerId,
|
||||
notes: dto.notes,
|
||||
products: dto.products,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(204)
|
||||
@RequirePrivilege(SALES_INVOICE_PRIVILEGE_KEY, 'delete')
|
||||
@ApiOperation({ summary: 'Delete sales invoice' })
|
||||
@ApiNoContentResponse()
|
||||
@ApiNotFoundResponse()
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
async delete(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
|
||||
await this.salesInvoicesService.delete(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BranchesModule } from '../../configuration/branches/branches.module';
|
||||
import { CustomersModule } from '../../configuration/customers/customers.module';
|
||||
import { DivisionsModule } from '../../configuration/divisions/divisions.module';
|
||||
import { EmployeesModule } from '../../configuration/employees/employees.module';
|
||||
import { ProductsModule } from '../../configuration/products/products.module';
|
||||
import { PackingSlipsModule } from '../packing-slips/packing-slips.module';
|
||||
import { SalesOrdersModule } from '../sales-orders/sales-orders.module';
|
||||
import { DocumentCodeService } from '../shared/document-code.service';
|
||||
import { SalesInvoicesReadController } from './sales-invoices-read.controller';
|
||||
import { SalesInvoicesWriteController } from './sales-invoices-write.controller';
|
||||
import { SalesInvoicesRepository } from './sales-invoices.repository';
|
||||
import { SalesInvoicesService } from './sales-invoices.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
EmployeesModule,
|
||||
BranchesModule,
|
||||
DivisionsModule,
|
||||
CustomersModule,
|
||||
ProductsModule,
|
||||
SalesOrdersModule,
|
||||
PackingSlipsModule,
|
||||
],
|
||||
controllers: [SalesInvoicesReadController, SalesInvoicesWriteController],
|
||||
providers: [
|
||||
DocumentCodeService,
|
||||
SalesInvoicesRepository,
|
||||
SalesInvoicesService,
|
||||
],
|
||||
exports: [SalesInvoicesService, DocumentCodeService],
|
||||
})
|
||||
export class SalesInvoicesModule {}
|
||||
@@ -0,0 +1,461 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { and, asc, count, eq, ilike, inArray, or, SQL, sum } from 'drizzle-orm';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
import { DRIZZLE, type DrizzleDB } from '../../../database/database.module';
|
||||
import {
|
||||
salesInvoiceProducts,
|
||||
salesInvoices,
|
||||
type SalesInvoiceProductRow,
|
||||
type SalesInvoiceRow,
|
||||
} from '../../../database/sales-invoices-table';
|
||||
import {
|
||||
salesPaymentInvoices,
|
||||
salesPayments,
|
||||
} from '../../../database/sales-payments-table';
|
||||
import { DocumentCodeService } from '../shared/document-code.service';
|
||||
import { DOCUMENT_PREFIXES } from '../shared/document-prefixes';
|
||||
import { SALES_INVOICE_STATUSES } from '../shared/sales-fields';
|
||||
import type {
|
||||
CreateSalesInvoiceInput,
|
||||
ListSalesInvoicesFilters,
|
||||
SalesInvoice,
|
||||
SalesInvoiceLineInput,
|
||||
UpdateSalesInvoiceInput,
|
||||
} from './sales-invoice';
|
||||
|
||||
type QueryExecutor = Pick<DrizzleDB, 'select' | 'insert' | 'delete' | 'update'>;
|
||||
|
||||
export type InvoiceTotals = {
|
||||
readonly total: Decimal;
|
||||
readonly paid: Decimal;
|
||||
readonly balance: Decimal;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class SalesInvoicesRepository {
|
||||
constructor(
|
||||
@Inject(DRIZZLE) private readonly db: DrizzleDB,
|
||||
private readonly documentCodeService: DocumentCodeService,
|
||||
) {}
|
||||
|
||||
async list(
|
||||
filters: ListSalesInvoicesFilters,
|
||||
): Promise<{ data: SalesInvoice[]; total: number }> {
|
||||
const where = this.buildListWhere(filters);
|
||||
const totalRows = await this.db
|
||||
.select({ total: count() })
|
||||
.from(salesInvoices)
|
||||
.where(where);
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(salesInvoices)
|
||||
.where(where)
|
||||
.orderBy(asc(salesInvoices.code))
|
||||
.limit(filters.limit)
|
||||
.offset(filters.offset);
|
||||
return {
|
||||
data: rows.map((row) => this.toDomain(row, [])),
|
||||
total: Number(totalRows[0]?.total ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<SalesInvoice | null> {
|
||||
const rows: SalesInvoiceRow[] = await this.db
|
||||
.select()
|
||||
.from(salesInvoices)
|
||||
.where(eq(salesInvoices.id, id))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
const products = await this.selectProducts(this.db, id);
|
||||
return this.toDomain(row, products);
|
||||
}
|
||||
|
||||
async create(input: CreateSalesInvoiceInput): Promise<SalesInvoice> {
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const status =
|
||||
input.status ?? Status.create('draft', SALES_INVOICE_STATUSES);
|
||||
try {
|
||||
return await this.db.transaction(async (tx) => {
|
||||
const code =
|
||||
input.code ??
|
||||
(await this.documentCodeService.nextCode(
|
||||
DOCUMENT_PREFIXES.salesInvoice,
|
||||
input.date,
|
||||
tx,
|
||||
));
|
||||
const inserted = await tx
|
||||
.insert(salesInvoices)
|
||||
.values(this.toInsertValues(input, code, status, now, input.userId))
|
||||
.returning();
|
||||
const row = inserted[0];
|
||||
await this.replaceProducts(tx, row.id, input.products);
|
||||
const products = await this.selectProducts(tx, row.id);
|
||||
const withBalance = await this.refreshBalance(tx, row.id, products);
|
||||
return this.toDomain(withBalance, products);
|
||||
});
|
||||
} catch (error) {
|
||||
this.rethrowConstraintViolation(error);
|
||||
}
|
||||
}
|
||||
|
||||
async createMany(inputs: CreateSalesInvoiceInput[]): Promise<number> {
|
||||
if (inputs.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
for (const input of inputs) {
|
||||
await this.create(input);
|
||||
}
|
||||
return inputs.length;
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
input: UpdateSalesInvoiceInput,
|
||||
): Promise<SalesInvoice> {
|
||||
const existing = await this.findById(id);
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Sales invoice not found');
|
||||
}
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
try {
|
||||
return await this.db.transaction(async (tx) => {
|
||||
const updated = await tx
|
||||
.update(salesInvoices)
|
||||
.set({
|
||||
code: input.code ?? existing.code,
|
||||
salesOrderId:
|
||||
input.salesOrderId !== undefined
|
||||
? input.salesOrderId
|
||||
: existing.salesOrderId,
|
||||
salesOrderCode:
|
||||
input.salesOrderCode !== undefined
|
||||
? input.salesOrderCode
|
||||
: existing.salesOrderCode,
|
||||
packingSlipId:
|
||||
input.packingSlipId !== undefined
|
||||
? input.packingSlipId
|
||||
: existing.packingSlipId,
|
||||
packingSlipCode:
|
||||
input.packingSlipCode !== undefined
|
||||
? input.packingSlipCode
|
||||
: existing.packingSlipCode,
|
||||
date: input.date?.value ?? existing.date.value,
|
||||
salesPersonId: input.salesPersonId ?? existing.salesPersonId,
|
||||
branchId: input.branchId ?? existing.branchId,
|
||||
divisionId: input.divisionId ?? existing.divisionId,
|
||||
customerId: input.customerId ?? existing.customerId,
|
||||
notes: input.notes !== undefined ? input.notes : existing.notes,
|
||||
updatedAt: now.value,
|
||||
updatedBy: input.userId,
|
||||
})
|
||||
.where(eq(salesInvoices.id, id))
|
||||
.returning();
|
||||
const row = updated[0];
|
||||
if (!row) {
|
||||
throw new NotFoundException('Sales invoice not found');
|
||||
}
|
||||
if (input.products !== undefined) {
|
||||
await this.replaceProducts(tx, id, input.products);
|
||||
}
|
||||
const products = await this.selectProducts(tx, id);
|
||||
const withBalance = await this.refreshBalance(tx, id, products);
|
||||
return this.toDomain(withBalance, products);
|
||||
});
|
||||
} catch (error) {
|
||||
this.rethrowConstraintViolation(error);
|
||||
}
|
||||
}
|
||||
|
||||
async updateStatus(
|
||||
id: string,
|
||||
status: Status,
|
||||
userId: string,
|
||||
): Promise<SalesInvoice> {
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const updated = await this.db
|
||||
.update(salesInvoices)
|
||||
.set({
|
||||
status: status.value,
|
||||
updatedAt: now.value,
|
||||
updatedBy: userId,
|
||||
})
|
||||
.where(eq(salesInvoices.id, id))
|
||||
.returning();
|
||||
const row = updated[0];
|
||||
if (!row) {
|
||||
throw new NotFoundException('Sales invoice not found');
|
||||
}
|
||||
const products = await this.selectProducts(this.db, id);
|
||||
return this.toDomain(row, products);
|
||||
}
|
||||
|
||||
async bulkUpdateStatus(
|
||||
ids: string[],
|
||||
status: Status,
|
||||
userId: string,
|
||||
): Promise<number> {
|
||||
if (ids.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const rows = await this.db
|
||||
.update(salesInvoices)
|
||||
.set({
|
||||
status: status.value,
|
||||
updatedAt: now.value,
|
||||
updatedBy: userId,
|
||||
})
|
||||
.where(inArray(salesInvoices.id, ids))
|
||||
.returning({ id: salesInvoices.id });
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
const deleted = await this.db
|
||||
.delete(salesInvoices)
|
||||
.where(eq(salesInvoices.id, id))
|
||||
.returning({ id: salesInvoices.id });
|
||||
if (deleted.length === 0) {
|
||||
throw new NotFoundException('Sales invoice not found');
|
||||
}
|
||||
}
|
||||
|
||||
async bulkDelete(ids: string[]): Promise<number> {
|
||||
if (ids.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const deleted = await this.db
|
||||
.delete(salesInvoices)
|
||||
.where(inArray(salesInvoices.id, ids))
|
||||
.returning({ id: salesInvoices.id });
|
||||
return deleted.length;
|
||||
}
|
||||
|
||||
async computeTotals(invoiceId: string): Promise<InvoiceTotals> {
|
||||
const products = await this.selectProducts(this.db, invoiceId);
|
||||
return this.totalsFrom(invoiceId, products, this.db);
|
||||
}
|
||||
|
||||
async refreshStoredBalance(invoiceId: string): Promise<SalesInvoice> {
|
||||
const products = await this.selectProducts(this.db, invoiceId);
|
||||
const row = await this.refreshBalance(this.db, invoiceId, products);
|
||||
return this.toDomain(row, products);
|
||||
}
|
||||
|
||||
private async totalsFrom(
|
||||
invoiceId: string,
|
||||
productRows: SalesInvoiceProductRow[],
|
||||
executor: QueryExecutor,
|
||||
): Promise<InvoiceTotals> {
|
||||
let total = Decimal.create('0');
|
||||
for (const line of productRows) {
|
||||
total = total.add(
|
||||
Decimal.create(line.quantity).multiply(Decimal.create(line.price)),
|
||||
);
|
||||
}
|
||||
const paidRows = await executor
|
||||
.select({ amount: sum(salesPaymentInvoices.amount) })
|
||||
.from(salesPaymentInvoices)
|
||||
.innerJoin(
|
||||
salesPayments,
|
||||
eq(salesPaymentInvoices.salesPaymentId, salesPayments.id),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(salesPaymentInvoices.salesInvoiceId, invoiceId),
|
||||
eq(salesPayments.status, 'approved'),
|
||||
),
|
||||
);
|
||||
const paid = Decimal.create(paidRows[0]?.amount ?? '0');
|
||||
return {
|
||||
total,
|
||||
paid,
|
||||
balance: total.subtract(paid),
|
||||
};
|
||||
}
|
||||
|
||||
private async selectProducts(
|
||||
executor: QueryExecutor,
|
||||
salesInvoiceId: string,
|
||||
): Promise<SalesInvoiceProductRow[]> {
|
||||
return executor
|
||||
.select()
|
||||
.from(salesInvoiceProducts)
|
||||
.where(eq(salesInvoiceProducts.salesInvoiceId, salesInvoiceId));
|
||||
}
|
||||
|
||||
private async replaceProducts(
|
||||
executor: QueryExecutor,
|
||||
salesInvoiceId: string,
|
||||
products: readonly SalesInvoiceLineInput[],
|
||||
): Promise<void> {
|
||||
await executor
|
||||
.delete(salesInvoiceProducts)
|
||||
.where(eq(salesInvoiceProducts.salesInvoiceId, salesInvoiceId));
|
||||
if (products.length === 0) {
|
||||
return;
|
||||
}
|
||||
await executor.insert(salesInvoiceProducts).values(
|
||||
products.map((line) => ({
|
||||
salesInvoiceId,
|
||||
productId: line.productId,
|
||||
quantity: line.quantity.value,
|
||||
price: line.price.value,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
private buildListWhere(filters: ListSalesInvoicesFilters): SQL | undefined {
|
||||
const parts: SQL[] = [];
|
||||
if (filters.code) {
|
||||
parts.push(ilike(salesInvoices.code, `%${filters.code}%`));
|
||||
}
|
||||
if (filters.status) {
|
||||
parts.push(eq(salesInvoices.status, filters.status));
|
||||
}
|
||||
if (filters.customerId) {
|
||||
parts.push(eq(salesInvoices.customerId, filters.customerId));
|
||||
}
|
||||
if (filters.salesPersonId) {
|
||||
parts.push(eq(salesInvoices.salesPersonId, filters.salesPersonId));
|
||||
}
|
||||
if (filters.branchId) {
|
||||
parts.push(eq(salesInvoices.branchId, filters.branchId));
|
||||
}
|
||||
if (filters.divisionId) {
|
||||
parts.push(eq(salesInvoices.divisionId, filters.divisionId));
|
||||
}
|
||||
if (filters.salesOrderId) {
|
||||
parts.push(eq(salesInvoices.salesOrderId, filters.salesOrderId));
|
||||
}
|
||||
if (filters.packingSlipId) {
|
||||
parts.push(eq(salesInvoices.packingSlipId, filters.packingSlipId));
|
||||
}
|
||||
if (filters.search) {
|
||||
const search = or(
|
||||
ilike(salesInvoices.code, `%${filters.search}%`),
|
||||
ilike(salesInvoices.notes, `%${filters.search}%`),
|
||||
);
|
||||
if (search) {
|
||||
parts.push(search);
|
||||
}
|
||||
}
|
||||
if (parts.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return parts.length === 1 ? parts[0] : and(...parts);
|
||||
}
|
||||
|
||||
private async refreshBalance(
|
||||
executor: QueryExecutor,
|
||||
invoiceId: string,
|
||||
productRows: SalesInvoiceProductRow[],
|
||||
): Promise<SalesInvoiceRow> {
|
||||
const { balance } = await this.totalsFrom(invoiceId, productRows, executor);
|
||||
const updated = await executor
|
||||
.update(salesInvoices)
|
||||
.set({ balance: balance.value })
|
||||
.where(eq(salesInvoices.id, invoiceId))
|
||||
.returning();
|
||||
return updated[0];
|
||||
}
|
||||
|
||||
private toInsertValues(
|
||||
input: CreateSalesInvoiceInput,
|
||||
code: string,
|
||||
status: Status,
|
||||
now: DateTime,
|
||||
userId: string,
|
||||
) {
|
||||
return {
|
||||
code,
|
||||
salesOrderId: input.salesOrderId ?? null,
|
||||
salesOrderCode: input.salesOrderCode ?? null,
|
||||
packingSlipId: input.packingSlipId ?? null,
|
||||
packingSlipCode: input.packingSlipCode ?? null,
|
||||
date: input.date.value,
|
||||
salesPersonId: input.salesPersonId,
|
||||
branchId: input.branchId,
|
||||
divisionId: input.divisionId,
|
||||
customerId: input.customerId,
|
||||
balance: '0.0000',
|
||||
notes: input.notes ?? null,
|
||||
status: status.value,
|
||||
createdAt: now.value,
|
||||
updatedAt: now.value,
|
||||
createdBy: userId,
|
||||
updatedBy: userId,
|
||||
};
|
||||
}
|
||||
|
||||
private toDomain(
|
||||
row: SalesInvoiceRow,
|
||||
productRows: SalesInvoiceProductRow[],
|
||||
): SalesInvoice {
|
||||
return {
|
||||
id: row.id,
|
||||
code: row.code,
|
||||
salesOrderId: row.salesOrderId,
|
||||
salesOrderCode: row.salesOrderCode,
|
||||
packingSlipId: row.packingSlipId,
|
||||
packingSlipCode: row.packingSlipCode,
|
||||
date: DateTime.fromUnixMs(row.date),
|
||||
salesPersonId: row.salesPersonId,
|
||||
branchId: row.branchId,
|
||||
divisionId: row.divisionId,
|
||||
customerId: row.customerId,
|
||||
notes: row.notes,
|
||||
balance: Decimal.create(row.balance),
|
||||
products: productRows.map((line) => ({
|
||||
id: line.id,
|
||||
productId: line.productId,
|
||||
quantity: Decimal.create(line.quantity),
|
||||
price: Decimal.create(line.price),
|
||||
})),
|
||||
status: Status.create(row.status, SALES_INVOICE_STATUSES),
|
||||
createdAt: DateTime.fromUnixMs(row.createdAt),
|
||||
updatedAt: DateTime.fromUnixMs(row.updatedAt),
|
||||
createdBy: row.createdBy,
|
||||
updatedBy: row.updatedBy,
|
||||
};
|
||||
}
|
||||
|
||||
private rethrowConstraintViolation(error: unknown): never {
|
||||
if (error instanceof NotFoundException) {
|
||||
throw error;
|
||||
}
|
||||
const err = this.unwrapDbError(error);
|
||||
if (err.code === '23505') {
|
||||
throw new ConflictException('Sales invoice code already exists');
|
||||
}
|
||||
if (err.code === '23503') {
|
||||
throw new ConflictException('Related record was not found');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
private unwrapDbError(error: unknown): { code?: string } {
|
||||
let current: unknown = error;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (!current || typeof current !== 'object') {
|
||||
break;
|
||||
}
|
||||
const obj = current as { code?: string; cause?: unknown };
|
||||
if (obj.code === '23505' || obj.code === '23503') {
|
||||
return { code: obj.code };
|
||||
}
|
||||
current = obj.cause;
|
||||
}
|
||||
return error as { code?: string };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
import { BranchesService } from '../../configuration/branches/branches.service';
|
||||
import { CustomersService } from '../../configuration/customers/customers.service';
|
||||
import { DivisionsService } from '../../configuration/divisions/divisions.service';
|
||||
import { EmployeesService } from '../../configuration/employees/employees.service';
|
||||
import { ProductsService } from '../../configuration/products/products.service';
|
||||
import { PackingSlipsService } from '../packing-slips/packing-slips.service';
|
||||
import { SalesOrdersService } from '../sales-orders/sales-orders.service';
|
||||
import { SALES_INVOICE_STATUSES } from '../shared/sales-fields';
|
||||
import type { SalesInvoice } from './sales-invoice';
|
||||
import { SalesInvoicesRepository } from './sales-invoices.repository';
|
||||
import { SalesInvoicesService } from './sales-invoices.service';
|
||||
|
||||
describe('SalesInvoicesService', () => {
|
||||
let service: SalesInvoicesService;
|
||||
const repository: jest.Mocked<
|
||||
Pick<
|
||||
SalesInvoicesRepository,
|
||||
| 'list'
|
||||
| 'findById'
|
||||
| 'create'
|
||||
| 'createMany'
|
||||
| 'update'
|
||||
| 'updateStatus'
|
||||
| 'bulkUpdateStatus'
|
||||
| 'delete'
|
||||
| 'bulkDelete'
|
||||
| 'computeTotals'
|
||||
| 'refreshStoredBalance'
|
||||
>
|
||||
> = {
|
||||
list: jest.fn(),
|
||||
findById: jest.fn(),
|
||||
create: jest.fn(),
|
||||
createMany: jest.fn(),
|
||||
update: jest.fn(),
|
||||
updateStatus: jest.fn(),
|
||||
bulkUpdateStatus: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
bulkDelete: jest.fn(),
|
||||
computeTotals: jest.fn(),
|
||||
refreshStoredBalance: jest.fn(),
|
||||
};
|
||||
const employeesService = { findById: jest.fn() };
|
||||
const branchesService = { findById: jest.fn() };
|
||||
const divisionsService = { findById: jest.fn() };
|
||||
const customersService = { findById: jest.fn() };
|
||||
const productsService = { findById: jest.fn() };
|
||||
const salesOrdersService = { findById: jest.fn() };
|
||||
const packingSlipsService = { findById: jest.fn() };
|
||||
|
||||
const now = DateTime.fromUnixMs(1_700_000_000_000);
|
||||
const sample: SalesInvoice = {
|
||||
id: 'si-1',
|
||||
code: 'SI-20260824-0001',
|
||||
salesOrderId: null,
|
||||
salesOrderCode: null,
|
||||
packingSlipId: null,
|
||||
packingSlipCode: null,
|
||||
date: now,
|
||||
salesPersonId: 'emp-1',
|
||||
branchId: 'br-1',
|
||||
divisionId: 'div-1',
|
||||
customerId: 'cus-1',
|
||||
notes: null,
|
||||
balance: Decimal.create('25000'),
|
||||
products: [
|
||||
{
|
||||
id: 'line-1',
|
||||
productId: 'prd-1',
|
||||
quantity: Decimal.create('2'),
|
||||
price: Decimal.create('12500'),
|
||||
},
|
||||
],
|
||||
status: Status.create('draft', SALES_INVOICE_STATUSES),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: 'user-1',
|
||||
updatedBy: 'user-1',
|
||||
};
|
||||
|
||||
const createBody = {
|
||||
date: '2026-08-24T10:00:00+07:00',
|
||||
salesPersonId: 'emp-1',
|
||||
branchId: 'br-1',
|
||||
divisionId: 'div-1',
|
||||
customerId: 'cus-1',
|
||||
products: [{ productId: 'prd-1', quantity: '2' }],
|
||||
userId: 'user-1',
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
employeesService.findById.mockResolvedValue({ id: 'emp-1' });
|
||||
branchesService.findById.mockResolvedValue({ id: 'br-1' });
|
||||
divisionsService.findById.mockResolvedValue({ id: 'div-1' });
|
||||
customersService.findById.mockResolvedValue({ id: 'cus-1' });
|
||||
productsService.findById.mockResolvedValue({
|
||||
id: 'prd-1',
|
||||
price: '12500.0000',
|
||||
});
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
SalesInvoicesService,
|
||||
{ provide: SalesInvoicesRepository, useValue: repository },
|
||||
{ provide: EmployeesService, useValue: employeesService },
|
||||
{ provide: BranchesService, useValue: branchesService },
|
||||
{ provide: DivisionsService, useValue: divisionsService },
|
||||
{ provide: CustomersService, useValue: customersService },
|
||||
{ provide: ProductsService, useValue: productsService },
|
||||
{ provide: SalesOrdersService, useValue: salesOrdersService },
|
||||
{ provide: PackingSlipsService, useValue: packingSlipsService },
|
||||
],
|
||||
}).compile();
|
||||
service = moduleRef.get(SalesInvoicesService);
|
||||
});
|
||||
|
||||
it('create defaults price from the product catalog', async () => {
|
||||
repository.create.mockResolvedValue(sample);
|
||||
const result = await service.create(createBody);
|
||||
expect(result.code).toBe('SI-20260824-0001');
|
||||
expect(repository.create).toHaveBeenCalled();
|
||||
const [arg] = repository.create.mock.calls[0];
|
||||
expect(arg.products[0]?.price.value).toBe('12500.0000');
|
||||
expect(arg.status?.value).toBe('draft');
|
||||
});
|
||||
|
||||
it('create copies header and lines from a sales order', async () => {
|
||||
salesOrdersService.findById.mockResolvedValue({
|
||||
id: 'so-1',
|
||||
code: 'SO-1',
|
||||
date: now.value,
|
||||
salesPersonId: 'emp-1',
|
||||
branchId: 'br-1',
|
||||
divisionId: 'div-1',
|
||||
customerId: 'cus-1',
|
||||
notes: 'from order',
|
||||
products: [
|
||||
{
|
||||
id: 'ol-1',
|
||||
productId: 'prd-1',
|
||||
quantity: '2.0000',
|
||||
price: '12500.0000',
|
||||
},
|
||||
],
|
||||
});
|
||||
repository.create.mockResolvedValue({
|
||||
...sample,
|
||||
salesOrderId: 'so-1',
|
||||
salesOrderCode: 'SO-1',
|
||||
});
|
||||
await service.create({ salesOrderId: 'so-1', userId: 'user-1' });
|
||||
expect(repository.create).toHaveBeenCalled();
|
||||
const [copied] = repository.create.mock.calls[0];
|
||||
expect(copied.salesOrderCode).toBe('SO-1');
|
||||
expect(copied.salesPersonId).toBe('emp-1');
|
||||
expect(copied.products).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('create rejects a missing product line list without a parent', async () => {
|
||||
await expect(
|
||||
service.create({ ...createBody, products: [] }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('findById throws when missing', async () => {
|
||||
repository.findById.mockResolvedValue(null);
|
||||
await expect(service.findById('missing')).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('update rejects status on PATCH', async () => {
|
||||
await expect(
|
||||
service.update('si-1', { status: 'processed', userId: 'user-1' }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('applyPaymentEffects sets partial when underpaid', async () => {
|
||||
repository.findById.mockResolvedValue(sample);
|
||||
repository.computeTotals.mockResolvedValue({
|
||||
total: Decimal.create('25000'),
|
||||
paid: Decimal.create('10000'),
|
||||
balance: Decimal.create('15000'),
|
||||
});
|
||||
repository.refreshStoredBalance.mockResolvedValue(sample);
|
||||
repository.updateStatus.mockResolvedValue({
|
||||
...sample,
|
||||
status: Status.create('partial', SALES_INVOICE_STATUSES),
|
||||
});
|
||||
const result = await service.applyPaymentEffects('si-1', 'user-1');
|
||||
expect(repository.updateStatus).toHaveBeenCalledWith(
|
||||
'si-1',
|
||||
expect.objectContaining({ value: 'partial' }),
|
||||
'user-1',
|
||||
);
|
||||
expect(result.status).toBe('partial');
|
||||
});
|
||||
|
||||
it('applyPaymentEffects rejects overpayment', async () => {
|
||||
repository.findById.mockResolvedValue(sample);
|
||||
repository.computeTotals.mockResolvedValue({
|
||||
total: Decimal.create('25000'),
|
||||
paid: Decimal.create('30000'),
|
||||
balance: Decimal.create('-5000'),
|
||||
});
|
||||
await expect(
|
||||
service.applyPaymentEffects('si-1', 'user-1'),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(repository.updateStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,567 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type { PaginationResponse } from '../../../common/http/response';
|
||||
import { toListPage } from '../../../common/http/response';
|
||||
import { InvalidDateTimeError } from '../../../common/value-objects/date-time/invalid-date-time.error';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||
import { InvalidDecimalError } from '../../../common/value-objects/decimal/invalid-decimal.error';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
import { BranchesService } from '../../configuration/branches/branches.service';
|
||||
import { CustomersService } from '../../configuration/customers/customers.service';
|
||||
import { DivisionsService } from '../../configuration/divisions/divisions.service';
|
||||
import { EmployeesService } from '../../configuration/employees/employees.service';
|
||||
import { ProductsService } from '../../configuration/products/products.service';
|
||||
import { PackingSlipsService } from '../packing-slips/packing-slips.service';
|
||||
import { SalesOrdersService } from '../sales-orders/sales-orders.service';
|
||||
import {
|
||||
isValidDocumentCode,
|
||||
isValidDocumentNotes,
|
||||
parseCsvRecord,
|
||||
SALES_INVOICE_STATUSES,
|
||||
} from '../shared/sales-fields';
|
||||
import type {
|
||||
CreateSalesInvoiceInput,
|
||||
SalesInvoice,
|
||||
SalesInvoiceLineInput,
|
||||
UpdateSalesInvoiceInput,
|
||||
} from './sales-invoice';
|
||||
import { SalesInvoicesRepository } from './sales-invoices.repository';
|
||||
|
||||
export type SalesLineBody = {
|
||||
readonly productId: string;
|
||||
readonly quantity: string;
|
||||
readonly price?: string;
|
||||
};
|
||||
|
||||
export type ListSalesInvoicesQuery = {
|
||||
readonly code?: string;
|
||||
readonly status?: string;
|
||||
readonly customerId?: string;
|
||||
readonly salesPersonId?: string;
|
||||
readonly branchId?: string;
|
||||
readonly divisionId?: string;
|
||||
readonly salesOrderId?: string;
|
||||
readonly packingSlipId?: string;
|
||||
readonly search?: string;
|
||||
readonly page?: number;
|
||||
readonly limit?: number;
|
||||
readonly offset?: number;
|
||||
};
|
||||
|
||||
const CSV_REQUIRED_HEADERS = [
|
||||
'date',
|
||||
'salesPersonId',
|
||||
'branchId',
|
||||
'divisionId',
|
||||
'customerId',
|
||||
] as const;
|
||||
|
||||
@Injectable()
|
||||
export class SalesInvoicesService {
|
||||
constructor(
|
||||
private readonly salesInvoicesRepository: SalesInvoicesRepository,
|
||||
private readonly employeesService: EmployeesService,
|
||||
private readonly branchesService: BranchesService,
|
||||
private readonly divisionsService: DivisionsService,
|
||||
private readonly customersService: CustomersService,
|
||||
private readonly productsService: ProductsService,
|
||||
private readonly salesOrdersService: SalesOrdersService,
|
||||
private readonly packingSlipsService: PackingSlipsService,
|
||||
) {}
|
||||
|
||||
async list(
|
||||
query: ListSalesInvoicesQuery,
|
||||
): Promise<
|
||||
PaginationResponse<ReturnType<SalesInvoicesService['toListItem']>>
|
||||
> {
|
||||
const page = toListPage(query);
|
||||
const { data, total } = await this.salesInvoicesRepository.list({
|
||||
code: query.code,
|
||||
status: query.status,
|
||||
customerId: query.customerId,
|
||||
salesPersonId: query.salesPersonId,
|
||||
branchId: query.branchId,
|
||||
divisionId: query.divisionId,
|
||||
salesOrderId: query.salesOrderId,
|
||||
packingSlipId: query.packingSlipId,
|
||||
search: query.search,
|
||||
limit: page.limit,
|
||||
offset: page.offset,
|
||||
});
|
||||
return {
|
||||
data: data.map((item) => this.toListItem(item)),
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
async findById(
|
||||
id: string,
|
||||
): Promise<ReturnType<SalesInvoicesService['toDetail']>> {
|
||||
const found = await this.salesInvoicesRepository.findById(id);
|
||||
if (!found) {
|
||||
throw new NotFoundException('Sales invoice not found');
|
||||
}
|
||||
return this.toDetail(found);
|
||||
}
|
||||
|
||||
async create(input: {
|
||||
code?: string;
|
||||
salesOrderId?: string;
|
||||
packingSlipId?: string;
|
||||
date?: string;
|
||||
salesPersonId?: string;
|
||||
branchId?: string;
|
||||
divisionId?: string;
|
||||
customerId?: string;
|
||||
notes?: string | null;
|
||||
products?: SalesLineBody[];
|
||||
status?: string;
|
||||
userId: string;
|
||||
}): Promise<ReturnType<SalesInvoicesService['toDetail']>> {
|
||||
const merged = await this.mergeFromParents(input);
|
||||
await this.assertRelations(merged);
|
||||
const created = await this.salesInvoicesRepository.create(
|
||||
await this.toCreateInput(merged),
|
||||
);
|
||||
return this.toDetail(created);
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
input: {
|
||||
code?: string;
|
||||
salesOrderId?: string | null;
|
||||
packingSlipId?: string | null;
|
||||
date?: string;
|
||||
salesPersonId?: string;
|
||||
branchId?: string;
|
||||
divisionId?: string;
|
||||
customerId?: string;
|
||||
notes?: string | null;
|
||||
products?: SalesLineBody[];
|
||||
status?: unknown;
|
||||
userId: string;
|
||||
},
|
||||
): Promise<ReturnType<SalesInvoicesService['toDetail']>> {
|
||||
if (input.status !== undefined) {
|
||||
throw new BadRequestException('status cannot be updated via PATCH');
|
||||
}
|
||||
await this.assertRelations(input);
|
||||
let salesOrderCode: string | null | undefined;
|
||||
let packingSlipCode: string | null | undefined;
|
||||
if (input.salesOrderId) {
|
||||
const order = await this.salesOrdersService.findById(input.salesOrderId);
|
||||
salesOrderCode = order.code;
|
||||
} else if (input.salesOrderId === null) {
|
||||
salesOrderCode = null;
|
||||
}
|
||||
if (input.packingSlipId) {
|
||||
const slip = await this.packingSlipsService.findById(input.packingSlipId);
|
||||
packingSlipCode = slip.code;
|
||||
} else if (input.packingSlipId === null) {
|
||||
packingSlipCode = null;
|
||||
}
|
||||
const payload: UpdateSalesInvoiceInput = {
|
||||
code: input.code !== undefined ? this.assertCode(input.code) : undefined,
|
||||
salesOrderId: input.salesOrderId,
|
||||
salesOrderCode,
|
||||
packingSlipId: input.packingSlipId,
|
||||
packingSlipCode,
|
||||
date: input.date !== undefined ? this.assertDate(input.date) : undefined,
|
||||
salesPersonId: input.salesPersonId,
|
||||
branchId: input.branchId,
|
||||
divisionId: input.divisionId,
|
||||
customerId: input.customerId,
|
||||
notes:
|
||||
input.notes !== undefined ? this.assertNotes(input.notes) : undefined,
|
||||
products:
|
||||
input.products !== undefined
|
||||
? await this.assertLines(input.products)
|
||||
: undefined,
|
||||
userId: input.userId,
|
||||
};
|
||||
const updated = await this.salesInvoicesRepository.update(id, payload);
|
||||
return this.toDetail(updated);
|
||||
}
|
||||
|
||||
async updateStatus(
|
||||
id: string,
|
||||
statusRaw: string,
|
||||
userId: string,
|
||||
): Promise<ReturnType<SalesInvoicesService['toDetail']>> {
|
||||
const status = this.assertStatus(statusRaw);
|
||||
const updated = await this.salesInvoicesRepository.updateStatus(
|
||||
id,
|
||||
status,
|
||||
userId,
|
||||
);
|
||||
return this.toDetail(updated);
|
||||
}
|
||||
|
||||
async applyPaymentEffects(
|
||||
invoiceId: string,
|
||||
userId: string,
|
||||
): Promise<ReturnType<SalesInvoicesService['toDetail']>> {
|
||||
const invoice = await this.salesInvoicesRepository.findById(invoiceId);
|
||||
if (!invoice) {
|
||||
throw new NotFoundException('Sales invoice not found');
|
||||
}
|
||||
const totals = await this.salesInvoicesRepository.computeTotals(invoiceId);
|
||||
if (totals.paid.compare(totals.total) > 0) {
|
||||
throw new BadRequestException('Payment exceeds invoice total');
|
||||
}
|
||||
await this.salesInvoicesRepository.refreshStoredBalance(invoiceId);
|
||||
if (totals.paid.isZero()) {
|
||||
return this.findById(invoiceId);
|
||||
}
|
||||
const nextStatus =
|
||||
totals.paid.compare(totals.total) >= 0 ? 'completed' : 'partial';
|
||||
const updated = await this.salesInvoicesRepository.updateStatus(
|
||||
invoiceId,
|
||||
Status.create(nextStatus, SALES_INVOICE_STATUSES),
|
||||
userId,
|
||||
);
|
||||
return this.toDetail(updated);
|
||||
}
|
||||
|
||||
async getTotals(invoiceId: string) {
|
||||
return this.salesInvoicesRepository.computeTotals(invoiceId);
|
||||
}
|
||||
|
||||
async bulkUpdateStatus(
|
||||
ids: string[],
|
||||
statusRaw: string,
|
||||
userId: string,
|
||||
): Promise<{ updated: number }> {
|
||||
const status = this.assertStatus(statusRaw);
|
||||
const updated = await this.salesInvoicesRepository.bulkUpdateStatus(
|
||||
ids,
|
||||
status,
|
||||
userId,
|
||||
);
|
||||
return { updated };
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.salesInvoicesRepository.delete(id);
|
||||
}
|
||||
|
||||
async bulkDelete(ids: string[]): Promise<{ deleted: number }> {
|
||||
const deleted = await this.salesInvoicesRepository.bulkDelete(ids);
|
||||
return { deleted };
|
||||
}
|
||||
|
||||
async importCsv(csv: string, userId: string): Promise<{ imported: number }> {
|
||||
const rawLines = csv.split(/\r?\n/);
|
||||
const filled = rawLines
|
||||
.map((line, index) => ({ line: line.trim(), lineNo: index + 1 }))
|
||||
.filter((entry) => entry.line.length > 0);
|
||||
if (filled.length === 0) {
|
||||
throw new BadRequestException('CSV is empty');
|
||||
}
|
||||
if (filled.length > 501) {
|
||||
throw new BadRequestException('CSV exceeds maximum of 500 data rows');
|
||||
}
|
||||
const header = parseCsvRecord(filled[0].line).map((h) =>
|
||||
h.trim().toLowerCase(),
|
||||
);
|
||||
const missing = CSV_REQUIRED_HEADERS.filter(
|
||||
(h) => header.indexOf(h.toLowerCase()) < 0,
|
||||
);
|
||||
if (missing.length > 0) {
|
||||
throw new BadRequestException('CSV must include required headers');
|
||||
}
|
||||
const idx = (key: string) => header.indexOf(key.toLowerCase());
|
||||
const errors: string[] = [];
|
||||
const rows: CreateSalesInvoiceInput[] = [];
|
||||
for (let i = 1; i < filled.length; i++) {
|
||||
const cols = parseCsvRecord(filled[i].line);
|
||||
const rowNum = filled[i].lineNo;
|
||||
try {
|
||||
rows.push(
|
||||
await this.toCreateInput({
|
||||
code: idx('code') >= 0 ? cols[idx('code')] : undefined,
|
||||
date: cols[idx('date')] ?? '',
|
||||
salesPersonId: cols[idx('salespersonid')] ?? '',
|
||||
branchId: cols[idx('branchid')] ?? '',
|
||||
divisionId: cols[idx('divisionid')] ?? '',
|
||||
customerId: cols[idx('customerid')] ?? '',
|
||||
notes: idx('notes') >= 0 ? cols[idx('notes')] : null,
|
||||
products: [],
|
||||
status: idx('status') >= 0 ? cols[idx('status')] : undefined,
|
||||
userId,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
const reason =
|
||||
error instanceof BadRequestException ? error.message : 'invalid data';
|
||||
errors.push(`row ${rowNum}: ${reason}`);
|
||||
}
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
throw new BadRequestException({
|
||||
message: 'CSV validation failed',
|
||||
errors,
|
||||
});
|
||||
}
|
||||
await this.salesInvoicesRepository.createMany(rows);
|
||||
return { imported: rows.length };
|
||||
}
|
||||
|
||||
toListItem(item: SalesInvoice) {
|
||||
return {
|
||||
id: item.id,
|
||||
code: item.code,
|
||||
salesOrderId: item.salesOrderId,
|
||||
salesOrderCode: item.salesOrderCode,
|
||||
packingSlipId: item.packingSlipId,
|
||||
packingSlipCode: item.packingSlipCode,
|
||||
date: item.date.value,
|
||||
salesPersonId: item.salesPersonId,
|
||||
branchId: item.branchId,
|
||||
divisionId: item.divisionId,
|
||||
customerId: item.customerId,
|
||||
notes: item.notes,
|
||||
balance: item.balance.value,
|
||||
status: item.status.value,
|
||||
createdAt: item.createdAt.value,
|
||||
updatedAt: item.updatedAt.value,
|
||||
createdBy: item.createdBy,
|
||||
updatedBy: item.updatedBy,
|
||||
};
|
||||
}
|
||||
|
||||
toDetail(item: SalesInvoice) {
|
||||
return {
|
||||
...this.toListItem(item),
|
||||
products: item.products.map((line) => ({
|
||||
id: line.id,
|
||||
productId: line.productId,
|
||||
quantity: line.quantity.value,
|
||||
price: line.price.value,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
private async mergeFromParents(input: {
|
||||
code?: string;
|
||||
salesOrderId?: string;
|
||||
packingSlipId?: string;
|
||||
date?: string;
|
||||
salesPersonId?: string;
|
||||
branchId?: string;
|
||||
divisionId?: string;
|
||||
customerId?: string;
|
||||
notes?: string | null;
|
||||
products?: SalesLineBody[];
|
||||
status?: string;
|
||||
userId: string;
|
||||
}) {
|
||||
let date = input.date ?? '';
|
||||
let salesPersonId = input.salesPersonId ?? '';
|
||||
let branchId = input.branchId ?? '';
|
||||
let divisionId = input.divisionId ?? '';
|
||||
let customerId = input.customerId ?? '';
|
||||
let notes = input.notes;
|
||||
let products = input.products;
|
||||
let salesOrderCode: string | null = null;
|
||||
let packingSlipCode: string | null = null;
|
||||
if (input.salesOrderId) {
|
||||
const order = await this.salesOrdersService.findById(input.salesOrderId);
|
||||
salesOrderCode = order.code;
|
||||
date = date || DateTime.fromUnixMs(order.date).format();
|
||||
salesPersonId = salesPersonId || order.salesPersonId;
|
||||
branchId = branchId || order.branchId;
|
||||
divisionId = divisionId || order.divisionId;
|
||||
customerId = customerId || order.customerId;
|
||||
notes = notes !== undefined ? notes : order.notes;
|
||||
products =
|
||||
products ??
|
||||
order.products.map((line) => ({
|
||||
productId: line.productId,
|
||||
quantity: line.quantity,
|
||||
price: line.price,
|
||||
}));
|
||||
}
|
||||
if (input.packingSlipId) {
|
||||
const slip = await this.packingSlipsService.findById(input.packingSlipId);
|
||||
packingSlipCode = slip.code;
|
||||
date = date || DateTime.fromUnixMs(slip.date).format();
|
||||
customerId = customerId || slip.customerId;
|
||||
notes = notes !== undefined ? notes : slip.notes;
|
||||
products =
|
||||
input.products ??
|
||||
products ??
|
||||
slip.products.map((line) => ({
|
||||
productId: line.productId,
|
||||
quantity: line.quantity,
|
||||
price: line.price,
|
||||
}));
|
||||
}
|
||||
return {
|
||||
...input,
|
||||
salesOrderId: input.salesOrderId ?? null,
|
||||
salesOrderCode,
|
||||
packingSlipId: input.packingSlipId ?? null,
|
||||
packingSlipCode,
|
||||
date,
|
||||
salesPersonId,
|
||||
branchId,
|
||||
divisionId,
|
||||
customerId,
|
||||
notes: notes ?? null,
|
||||
products: products ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
private async toCreateInput(input: {
|
||||
code?: string;
|
||||
salesOrderId?: string | null;
|
||||
salesOrderCode?: string | null;
|
||||
packingSlipId?: string | null;
|
||||
packingSlipCode?: string | null;
|
||||
date: string;
|
||||
salesPersonId: string;
|
||||
branchId: string;
|
||||
divisionId: string;
|
||||
customerId: string;
|
||||
notes?: string | null;
|
||||
products: SalesLineBody[];
|
||||
status?: string;
|
||||
userId: string;
|
||||
}): Promise<CreateSalesInvoiceInput> {
|
||||
return {
|
||||
code:
|
||||
input.code !== undefined && input.code !== ''
|
||||
? this.assertCode(input.code)
|
||||
: undefined,
|
||||
salesOrderId: input.salesOrderId ?? null,
|
||||
salesOrderCode: input.salesOrderCode ?? null,
|
||||
packingSlipId: input.packingSlipId ?? null,
|
||||
packingSlipCode: input.packingSlipCode ?? null,
|
||||
date: this.assertDate(input.date),
|
||||
salesPersonId: input.salesPersonId,
|
||||
branchId: input.branchId,
|
||||
divisionId: input.divisionId,
|
||||
customerId: input.customerId,
|
||||
notes: this.assertNotes(input.notes ?? null),
|
||||
products: await this.assertLines(input.products),
|
||||
status: input.status
|
||||
? this.assertStatus(input.status)
|
||||
: Status.create('draft', SALES_INVOICE_STATUSES),
|
||||
userId: input.userId,
|
||||
};
|
||||
}
|
||||
|
||||
private async assertRelations(input: {
|
||||
salesPersonId?: string;
|
||||
branchId?: string;
|
||||
divisionId?: string;
|
||||
customerId?: string;
|
||||
}): Promise<void> {
|
||||
if (input.salesPersonId) {
|
||||
await this.employeesService.findById(input.salesPersonId);
|
||||
}
|
||||
if (input.branchId) {
|
||||
await this.branchesService.findById(input.branchId);
|
||||
}
|
||||
if (input.divisionId) {
|
||||
await this.divisionsService.findById(input.divisionId);
|
||||
}
|
||||
if (input.customerId) {
|
||||
await this.customersService.findById(input.customerId);
|
||||
}
|
||||
}
|
||||
|
||||
private async assertLines(
|
||||
lines: SalesLineBody[],
|
||||
): Promise<SalesInvoiceLineInput[]> {
|
||||
if (!Array.isArray(lines) || lines.length === 0) {
|
||||
throw new BadRequestException('At least one product line is required');
|
||||
}
|
||||
const result: SalesInvoiceLineInput[] = [];
|
||||
for (const line of lines) {
|
||||
const product = await this.productsService.findById(line.productId);
|
||||
const quantity = this.assertPositiveDecimal(line.quantity, 'quantity');
|
||||
let price: Decimal;
|
||||
if (line.price === undefined || line.price === '') {
|
||||
if (product.price === null) {
|
||||
throw new BadRequestException('Product price is required');
|
||||
}
|
||||
price = Decimal.create(product.price);
|
||||
} else {
|
||||
price = this.assertNonNegativeDecimal(line.price, 'price');
|
||||
}
|
||||
result.push({ productId: product.id, quantity, price });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private assertCode(raw: string): string {
|
||||
const code = raw.trim();
|
||||
if (!isValidDocumentCode(code)) {
|
||||
throw new BadRequestException('Invalid document code');
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
private assertDate(raw: string): DateTime {
|
||||
try {
|
||||
return DateTime.create(raw);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidDateTimeError) {
|
||||
throw new BadRequestException('Invalid date');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private assertNotes(raw: string | null): string | null {
|
||||
if (raw === null || raw === '') {
|
||||
return null;
|
||||
}
|
||||
if (!isValidDocumentNotes(raw)) {
|
||||
throw new BadRequestException('Invalid notes');
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
private assertStatus(raw: string): Status {
|
||||
try {
|
||||
return Status.create(raw, SALES_INVOICE_STATUSES);
|
||||
} catch {
|
||||
throw new BadRequestException('Invalid status');
|
||||
}
|
||||
}
|
||||
|
||||
private assertPositiveDecimal(raw: string, label: string): Decimal {
|
||||
const value = this.parseDecimal(raw);
|
||||
if (!value.isPositive()) {
|
||||
throw new BadRequestException(`Invalid ${label}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private assertNonNegativeDecimal(raw: string, label: string): Decimal {
|
||||
const value = this.parseDecimal(raw);
|
||||
if (value.isNegative()) {
|
||||
throw new BadRequestException(`Invalid ${label}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private parseDecimal(raw: string): Decimal {
|
||||
try {
|
||||
return Decimal.create(raw);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidDecimalError) {
|
||||
throw new BadRequestException('Invalid decimal');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayNotEmpty,
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Matches,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { PaginationQueryDto } from '../../../../common/http/response';
|
||||
import {
|
||||
DOCUMENT_ADDRESS_MAX_LENGTH,
|
||||
DOCUMENT_CODE_MAX_LENGTH,
|
||||
DOCUMENT_CODE_PATTERN,
|
||||
DOCUMENT_NOTES_MAX_LENGTH,
|
||||
IMAGE_DESCRIPTION_MAX_LENGTH,
|
||||
IMAGE_URL_MAX_LENGTH,
|
||||
SALES_ORDER_STATUSES,
|
||||
} from '../../shared/sales-fields';
|
||||
|
||||
export class SalesLineDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID('4')
|
||||
productId!: string;
|
||||
|
||||
@ApiProperty({ example: '2.0000' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
quantity!: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '12500.0000' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
price?: string;
|
||||
}
|
||||
|
||||
export class SalesImageDto {
|
||||
@ApiProperty({ example: 'https://cdn.example.com/a.png' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(IMAGE_URL_MAX_LENGTH)
|
||||
url!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(IMAGE_DESCRIPTION_MAX_LENGTH)
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export class CreateSalesOrderDto {
|
||||
@ApiPropertyOptional({ example: 'SR-20260824-0001' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(DOCUMENT_CODE_MAX_LENGTH)
|
||||
@Matches(DOCUMENT_CODE_PATTERN)
|
||||
code?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
salesRequestId?: string;
|
||||
|
||||
@ApiProperty({ example: '2026-08-24T10:00:00+07:00' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
date!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID('4')
|
||||
salesPersonId!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID('4')
|
||||
branchId!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID('4')
|
||||
divisionId!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID('4')
|
||||
customerId!: string;
|
||||
|
||||
@ApiProperty({ example: 'Jl Sudirman 1' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@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()
|
||||
@MaxLength(DOCUMENT_NOTES_MAX_LENGTH)
|
||||
notes?: string;
|
||||
|
||||
@ApiProperty({ type: [SalesLineDto] })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => SalesLineDto)
|
||||
products!: SalesLineDto[];
|
||||
|
||||
@ApiPropertyOptional({ type: [SalesImageDto] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => SalesImageDto)
|
||||
images?: SalesImageDto[];
|
||||
|
||||
@ApiPropertyOptional({ enum: SALES_ORDER_STATUSES })
|
||||
@IsOptional()
|
||||
@IsIn([...SALES_ORDER_STATUSES])
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export class UpdateSalesOrderDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(DOCUMENT_CODE_MAX_LENGTH)
|
||||
@Matches(DOCUMENT_CODE_PATTERN)
|
||||
code?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
date?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
salesPersonId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
branchId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
divisionId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@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()
|
||||
notes?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ type: [SalesLineDto] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => SalesLineDto)
|
||||
products?: SalesLineDto[];
|
||||
|
||||
@ApiPropertyOptional({ type: [SalesImageDto] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => SalesImageDto)
|
||||
images?: SalesImageDto[];
|
||||
}
|
||||
|
||||
export class UpdateSalesOrderStatusDto {
|
||||
@ApiProperty({ enum: SALES_ORDER_STATUSES })
|
||||
@IsIn([...SALES_ORDER_STATUSES])
|
||||
status!: string;
|
||||
}
|
||||
|
||||
export class BulkIdsDto {
|
||||
@ApiProperty({ type: [String], format: 'uuid' })
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsUUID('4', { each: true })
|
||||
ids!: string[];
|
||||
}
|
||||
|
||||
export class BulkStatusDto {
|
||||
@ApiProperty({ type: [String], format: 'uuid' })
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsUUID('4', { each: true })
|
||||
ids!: string[];
|
||||
|
||||
@ApiProperty({ enum: SALES_ORDER_STATUSES })
|
||||
@IsIn([...SALES_ORDER_STATUSES])
|
||||
status!: string;
|
||||
}
|
||||
|
||||
export class ListSalesOrdersQueryDto extends PaginationQueryDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
code?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: SALES_ORDER_STATUSES })
|
||||
@IsOptional()
|
||||
@IsIn([...SALES_ORDER_STATUSES])
|
||||
status?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
customerId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
salesPersonId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
branchId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
divisionId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export class SalesOrderDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
id!: string;
|
||||
@ApiProperty()
|
||||
code!: string;
|
||||
@ApiPropertyOptional({ nullable: true, format: 'uuid' })
|
||||
salesRequestId!: string | null;
|
||||
@ApiProperty()
|
||||
date!: number;
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
salesPersonId!: string;
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
branchId!: string;
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
divisionId!: string;
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
customerId!: string;
|
||||
@ApiProperty()
|
||||
address!: string;
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
latitude!: number | null;
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
longitude!: number | null;
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
notes!: string | null;
|
||||
@ApiProperty({ enum: SALES_ORDER_STATUSES })
|
||||
status!: string;
|
||||
@ApiProperty()
|
||||
createdAt!: number;
|
||||
@ApiProperty()
|
||||
updatedAt!: number;
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
createdBy!: string;
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
updatedBy!: string;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
|
||||
export type SalesOrderLine = {
|
||||
readonly id: string;
|
||||
readonly productId: string;
|
||||
readonly quantity: Decimal;
|
||||
readonly price: Decimal;
|
||||
};
|
||||
|
||||
export type SalesOrderImage = {
|
||||
readonly id: string;
|
||||
readonly url: string;
|
||||
readonly description: string | null;
|
||||
};
|
||||
|
||||
export type SalesOrder = {
|
||||
readonly id: string;
|
||||
readonly code: string;
|
||||
readonly salesRequestId: string | null;
|
||||
readonly date: DateTime;
|
||||
readonly salesPersonId: string;
|
||||
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 SalesOrderLine[];
|
||||
readonly images: readonly SalesOrderImage[];
|
||||
readonly status: Status;
|
||||
readonly createdAt: DateTime;
|
||||
readonly updatedAt: DateTime;
|
||||
readonly createdBy: string;
|
||||
readonly updatedBy: string;
|
||||
};
|
||||
|
||||
export type SalesOrderLineInput = {
|
||||
readonly productId: string;
|
||||
readonly quantity: Decimal;
|
||||
readonly price: Decimal;
|
||||
};
|
||||
|
||||
export type SalesOrderImageInput = {
|
||||
readonly url: string;
|
||||
readonly description?: string | null;
|
||||
};
|
||||
|
||||
export type CreateSalesOrderInput = {
|
||||
readonly code?: string;
|
||||
readonly salesRequestId?: string | null;
|
||||
readonly date: DateTime;
|
||||
readonly salesPersonId: string;
|
||||
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 SalesOrderLineInput[];
|
||||
readonly images?: readonly SalesOrderImageInput[];
|
||||
readonly status?: Status;
|
||||
readonly userId: string;
|
||||
};
|
||||
|
||||
export type UpdateSalesOrderInput = {
|
||||
readonly code?: string;
|
||||
readonly date?: DateTime;
|
||||
readonly salesPersonId?: string;
|
||||
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 SalesOrderLineInput[];
|
||||
readonly images?: readonly SalesOrderImageInput[];
|
||||
readonly userId: string;
|
||||
};
|
||||
|
||||
export type ListSalesOrdersFilters = {
|
||||
readonly code?: string;
|
||||
readonly status?: string;
|
||||
readonly customerId?: string;
|
||||
readonly salesPersonId?: string;
|
||||
readonly branchId?: string;
|
||||
readonly divisionId?: string;
|
||||
readonly search?: string;
|
||||
readonly limit: number;
|
||||
readonly offset: number;
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiForbiddenResponse,
|
||||
ApiNotFoundResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
ApiUnauthorizedResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { RequirePrivilege } from '../../../common/decorators/require-privilege.decorator';
|
||||
import {
|
||||
Pagination,
|
||||
type PaginationResponse,
|
||||
PaginationMetaDto,
|
||||
} from '../../../common/http/response';
|
||||
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
|
||||
import { SalesOrderDto, ListSalesOrdersQueryDto } from './dto/sales-order.dto';
|
||||
import { SalesOrdersService } from './sales-orders.service';
|
||||
|
||||
export const SALES_ORDER_PRIVILEGE_KEY = 'SALES.ORDER';
|
||||
|
||||
@ApiTags('sales-orders')
|
||||
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||
@Controller('sales-orders')
|
||||
export class SalesOrdersReadController {
|
||||
constructor(private readonly salesOrdersService: SalesOrdersService) {}
|
||||
|
||||
@Get()
|
||||
@Pagination()
|
||||
@RequirePrivilege(SALES_ORDER_PRIVILEGE_KEY, 'view')
|
||||
@ApiOperation({ summary: 'List sales orders' })
|
||||
@ApiOkResponse({
|
||||
schema: {
|
||||
properties: {
|
||||
data: {
|
||||
type: 'array',
|
||||
items: { $ref: '#/components/schemas/SalesOrderDto' },
|
||||
},
|
||||
meta: { $ref: '#/components/schemas/PaginationMetaDto' },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
list(
|
||||
@Query() query: ListSalesOrdersQueryDto,
|
||||
): Promise<PaginationResponse<SalesOrderDto>> {
|
||||
return this.salesOrdersService.list(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePrivilege(SALES_ORDER_PRIVILEGE_KEY, 'view')
|
||||
@ApiOperation({ summary: 'Get sales order detail' })
|
||||
@ApiOkResponse({ type: SalesOrderDto })
|
||||
@ApiNotFoundResponse()
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string): Promise<SalesOrderDto> {
|
||||
return this.salesOrdersService.findById(id);
|
||||
}
|
||||
}
|
||||
|
||||
void PaginationMetaDto;
|
||||
@@ -0,0 +1,204 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
HttpCode,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
ApiConsumes,
|
||||
ApiCreatedResponse,
|
||||
ApiForbiddenResponse,
|
||||
ApiNoContentResponse,
|
||||
ApiNotFoundResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
ApiUnauthorizedResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { CurrentUser } from '../../../common/decorators/current-user.decorator';
|
||||
import { RequirePrivilege } from '../../../common/decorators/require-privilege.decorator';
|
||||
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
|
||||
import { isAllowedCsvUpload } from '../shared/sales-fields';
|
||||
import { SALES_ORDER_PRIVILEGE_KEY } from './sales-orders-read.controller';
|
||||
import { SalesOrdersService } from './sales-orders.service';
|
||||
import {
|
||||
BulkIdsDto,
|
||||
BulkStatusDto,
|
||||
CreateSalesOrderDto,
|
||||
SalesOrderDto,
|
||||
UpdateSalesOrderDto,
|
||||
UpdateSalesOrderStatusDto,
|
||||
} from './dto/sales-order.dto';
|
||||
|
||||
@ApiTags('sales-orders')
|
||||
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||
@Controller('sales-orders')
|
||||
export class SalesOrdersWriteController {
|
||||
constructor(private readonly salesOrdersService: SalesOrdersService) {}
|
||||
|
||||
@Post('import')
|
||||
@RequirePrivilege(SALES_ORDER_PRIVILEGE_KEY, 'import')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
limits: { fileSize: 1_048_576 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (!isAllowedCsvUpload(file)) {
|
||||
cb(new BadRequestException('Only CSV files are allowed'), false);
|
||||
return;
|
||||
}
|
||||
cb(null, true);
|
||||
},
|
||||
}),
|
||||
)
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
file: { type: 'string', format: 'binary' },
|
||||
},
|
||||
required: ['file'],
|
||||
},
|
||||
})
|
||||
@ApiOperation({ summary: 'Import sales orders from CSV' })
|
||||
@ApiOkResponse({
|
||||
schema: { properties: { imported: { type: 'number' } } },
|
||||
})
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
importCsv(
|
||||
@UploadedFile() file: { buffer?: Buffer } | undefined,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<{ imported: number }> {
|
||||
const csv = file?.buffer?.toString('utf8') ?? '';
|
||||
return this.salesOrdersService.importCsv(csv, userId);
|
||||
}
|
||||
|
||||
@Post('bulk-delete')
|
||||
@HttpCode(200)
|
||||
@RequirePrivilege(SALES_ORDER_PRIVILEGE_KEY, 'delete')
|
||||
@ApiOperation({ summary: 'Bulk delete sales orders' })
|
||||
@ApiOkResponse({
|
||||
schema: { properties: { deleted: { type: 'number' } } },
|
||||
})
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
bulkDelete(@Body() dto: BulkIdsDto): Promise<{ deleted: number }> {
|
||||
return this.salesOrdersService.bulkDelete(dto.ids);
|
||||
}
|
||||
|
||||
@Post('bulk-status')
|
||||
@HttpCode(200)
|
||||
@RequirePrivilege(SALES_ORDER_PRIVILEGE_KEY, 'update')
|
||||
@ApiOperation({ summary: 'Bulk update sales order status' })
|
||||
@ApiOkResponse({
|
||||
schema: { properties: { updated: { type: 'number' } } },
|
||||
})
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
bulkStatus(
|
||||
@Body() dto: BulkStatusDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<{ updated: number }> {
|
||||
return this.salesOrdersService.bulkUpdateStatus(
|
||||
dto.ids,
|
||||
dto.status,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePrivilege(SALES_ORDER_PRIVILEGE_KEY, 'create')
|
||||
@ApiOperation({ summary: 'Create sales order' })
|
||||
@ApiCreatedResponse({ type: SalesOrderDto })
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
create(
|
||||
@Body() dto: CreateSalesOrderDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<SalesOrderDto> {
|
||||
return this.salesOrdersService.create({
|
||||
code: dto.code,
|
||||
salesRequestId: dto.salesRequestId,
|
||||
date: dto.date,
|
||||
salesPersonId: dto.salesPersonId,
|
||||
branchId: dto.branchId,
|
||||
divisionId: dto.divisionId,
|
||||
customerId: dto.customerId,
|
||||
address: dto.address,
|
||||
latitude: dto.latitude,
|
||||
longitude: dto.longitude,
|
||||
notes: dto.notes,
|
||||
products: dto.products,
|
||||
images: dto.images,
|
||||
status: dto.status,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
@Patch(':id/status')
|
||||
@RequirePrivilege(SALES_ORDER_PRIVILEGE_KEY, 'update')
|
||||
@ApiOperation({ summary: 'Update sales order status' })
|
||||
@ApiOkResponse({ type: SalesOrderDto })
|
||||
@ApiNotFoundResponse()
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
updateStatus(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateSalesOrderStatusDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<SalesOrderDto> {
|
||||
return this.salesOrdersService.updateStatus(id, dto.status, userId);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePrivilege(SALES_ORDER_PRIVILEGE_KEY, 'update')
|
||||
@ApiOperation({ summary: 'Update sales order (not status)' })
|
||||
@ApiOkResponse({ type: SalesOrderDto })
|
||||
@ApiNotFoundResponse()
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
update(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateSalesOrderDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<SalesOrderDto> {
|
||||
return this.salesOrdersService.update(id, {
|
||||
code: dto.code,
|
||||
date: dto.date,
|
||||
salesPersonId: dto.salesPersonId,
|
||||
branchId: dto.branchId,
|
||||
divisionId: dto.divisionId,
|
||||
customerId: dto.customerId,
|
||||
address: dto.address,
|
||||
latitude: dto.latitude,
|
||||
longitude: dto.longitude,
|
||||
notes: dto.notes,
|
||||
products: dto.products,
|
||||
images: dto.images,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(204)
|
||||
@RequirePrivilege(SALES_ORDER_PRIVILEGE_KEY, 'delete')
|
||||
@ApiOperation({ summary: 'Delete sales order' })
|
||||
@ApiNoContentResponse()
|
||||
@ApiNotFoundResponse()
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
async delete(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
|
||||
await this.salesOrdersService.delete(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BranchesModule } from '../../configuration/branches/branches.module';
|
||||
import { CustomersModule } from '../../configuration/customers/customers.module';
|
||||
import { DivisionsModule } from '../../configuration/divisions/divisions.module';
|
||||
import { EmployeesModule } from '../../configuration/employees/employees.module';
|
||||
import { ProductsModule } from '../../configuration/products/products.module';
|
||||
import { SalesRequestsModule } from '../sales-requests/sales-requests.module';
|
||||
import { DocumentCodeService } from '../shared/document-code.service';
|
||||
import { SalesOrdersReadController } from './sales-orders-read.controller';
|
||||
import { SalesOrdersWriteController } from './sales-orders-write.controller';
|
||||
import { SalesOrdersRepository } from './sales-orders.repository';
|
||||
import { SalesOrdersService } from './sales-orders.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
EmployeesModule,
|
||||
BranchesModule,
|
||||
DivisionsModule,
|
||||
CustomersModule,
|
||||
ProductsModule,
|
||||
SalesRequestsModule,
|
||||
],
|
||||
controllers: [SalesOrdersReadController, SalesOrdersWriteController],
|
||||
providers: [DocumentCodeService, SalesOrdersRepository, SalesOrdersService],
|
||||
exports: [SalesOrdersService, DocumentCodeService],
|
||||
})
|
||||
export class SalesOrdersModule {}
|
||||
@@ -0,0 +1,419 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { and, asc, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
import { DRIZZLE, type DrizzleDB } from '../../../database/database.module';
|
||||
import {
|
||||
salesOrderImages,
|
||||
salesOrderProducts,
|
||||
salesOrders,
|
||||
type SalesOrderImageRow,
|
||||
type SalesOrderProductRow,
|
||||
type SalesOrderRow,
|
||||
} from '../../../database/sales-orders-table';
|
||||
import { DocumentCodeService } from '../shared/document-code.service';
|
||||
import { DOCUMENT_PREFIXES } from '../shared/document-prefixes';
|
||||
import { SALES_ORDER_STATUSES } from '../shared/sales-fields';
|
||||
import type {
|
||||
CreateSalesOrderInput,
|
||||
ListSalesOrdersFilters,
|
||||
SalesOrder,
|
||||
SalesOrderImageInput,
|
||||
SalesOrderLineInput,
|
||||
UpdateSalesOrderInput,
|
||||
} from './sales-order';
|
||||
|
||||
type QueryExecutor = Pick<DrizzleDB, 'select' | 'insert' | 'delete'>;
|
||||
|
||||
@Injectable()
|
||||
export class SalesOrdersRepository {
|
||||
constructor(
|
||||
@Inject(DRIZZLE) private readonly db: DrizzleDB,
|
||||
private readonly documentCodeService: DocumentCodeService,
|
||||
) {}
|
||||
|
||||
async list(
|
||||
filters: ListSalesOrdersFilters,
|
||||
): Promise<{ data: SalesOrder[]; total: number }> {
|
||||
const where = this.buildListWhere(filters);
|
||||
const totalRows = await this.db
|
||||
.select({ total: count() })
|
||||
.from(salesOrders)
|
||||
.where(where);
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(salesOrders)
|
||||
.where(where)
|
||||
.orderBy(asc(salesOrders.code))
|
||||
.limit(filters.limit)
|
||||
.offset(filters.offset);
|
||||
return {
|
||||
data: rows.map((row) => this.toDomain(row, [], [])),
|
||||
total: Number(totalRows[0]?.total ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<SalesOrder | null> {
|
||||
const rows: SalesOrderRow[] = await this.db
|
||||
.select()
|
||||
.from(salesOrders)
|
||||
.where(eq(salesOrders.id, id))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
const products = await this.selectProducts(this.db, id);
|
||||
const images = await this.selectImages(this.db, id);
|
||||
return this.toDomain(row, products, images);
|
||||
}
|
||||
|
||||
async create(input: CreateSalesOrderInput): Promise<SalesOrder> {
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const status = input.status ?? Status.create('draft', SALES_ORDER_STATUSES);
|
||||
try {
|
||||
return await this.db.transaction(async (tx) => {
|
||||
const code =
|
||||
input.code ??
|
||||
(await this.documentCodeService.nextCode(
|
||||
DOCUMENT_PREFIXES.salesOrder,
|
||||
input.date,
|
||||
tx,
|
||||
));
|
||||
const inserted = await tx
|
||||
.insert(salesOrders)
|
||||
.values(this.toInsertValues(input, code, status, now, input.userId))
|
||||
.returning();
|
||||
const row = inserted[0];
|
||||
await this.replaceProducts(tx, row.id, input.products);
|
||||
await this.replaceImages(tx, row.id, input.images ?? []);
|
||||
const products = await this.selectProducts(tx, row.id);
|
||||
const images = await this.selectImages(tx, row.id);
|
||||
return this.toDomain(row, products, images);
|
||||
});
|
||||
} catch (error) {
|
||||
this.rethrowConstraintViolation(error);
|
||||
}
|
||||
}
|
||||
|
||||
async createMany(inputs: CreateSalesOrderInput[]): Promise<number> {
|
||||
if (inputs.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
for (const input of inputs) {
|
||||
await this.create(input);
|
||||
}
|
||||
return inputs.length;
|
||||
}
|
||||
|
||||
async update(id: string, input: UpdateSalesOrderInput): Promise<SalesOrder> {
|
||||
const existing = await this.findById(id);
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Sales order not found');
|
||||
}
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
try {
|
||||
return await this.db.transaction(async (tx) => {
|
||||
const updated = await tx
|
||||
.update(salesOrders)
|
||||
.set({
|
||||
code: input.code ?? existing.code,
|
||||
date: input.date?.value ?? existing.date.value,
|
||||
salesPersonId: input.salesPersonId ?? existing.salesPersonId,
|
||||
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,
|
||||
})
|
||||
.where(eq(salesOrders.id, id))
|
||||
.returning();
|
||||
const row = updated[0];
|
||||
if (!row) {
|
||||
throw new NotFoundException('Sales order not found');
|
||||
}
|
||||
if (input.products !== undefined) {
|
||||
await this.replaceProducts(tx, id, input.products);
|
||||
}
|
||||
if (input.images !== undefined) {
|
||||
await this.replaceImages(tx, id, input.images);
|
||||
}
|
||||
const products = await this.selectProducts(tx, id);
|
||||
const images = await this.selectImages(tx, id);
|
||||
return this.toDomain(row, products, images);
|
||||
});
|
||||
} catch (error) {
|
||||
this.rethrowConstraintViolation(error);
|
||||
}
|
||||
}
|
||||
|
||||
async updateStatus(
|
||||
id: string,
|
||||
status: Status,
|
||||
userId: string,
|
||||
): Promise<SalesOrder> {
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const updated = await this.db
|
||||
.update(salesOrders)
|
||||
.set({
|
||||
status: status.value,
|
||||
updatedAt: now.value,
|
||||
updatedBy: userId,
|
||||
})
|
||||
.where(eq(salesOrders.id, id))
|
||||
.returning();
|
||||
const row = updated[0];
|
||||
if (!row) {
|
||||
throw new NotFoundException('Sales order not found');
|
||||
}
|
||||
const products = await this.selectProducts(this.db, id);
|
||||
const images = await this.selectImages(this.db, id);
|
||||
return this.toDomain(row, products, images);
|
||||
}
|
||||
|
||||
async bulkUpdateStatus(
|
||||
ids: string[],
|
||||
status: Status,
|
||||
userId: string,
|
||||
): Promise<number> {
|
||||
if (ids.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const rows = await this.db
|
||||
.update(salesOrders)
|
||||
.set({
|
||||
status: status.value,
|
||||
updatedAt: now.value,
|
||||
updatedBy: userId,
|
||||
})
|
||||
.where(inArray(salesOrders.id, ids))
|
||||
.returning({ id: salesOrders.id });
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
const deleted = await this.db
|
||||
.delete(salesOrders)
|
||||
.where(eq(salesOrders.id, id))
|
||||
.returning({ id: salesOrders.id });
|
||||
if (deleted.length === 0) {
|
||||
throw new NotFoundException('Sales order not found');
|
||||
}
|
||||
}
|
||||
|
||||
async bulkDelete(ids: string[]): Promise<number> {
|
||||
if (ids.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const deleted = await this.db
|
||||
.delete(salesOrders)
|
||||
.where(inArray(salesOrders.id, ids))
|
||||
.returning({ id: salesOrders.id });
|
||||
return deleted.length;
|
||||
}
|
||||
|
||||
private async selectProducts(
|
||||
executor: QueryExecutor,
|
||||
salesOrderId: string,
|
||||
): Promise<SalesOrderProductRow[]> {
|
||||
return executor
|
||||
.select()
|
||||
.from(salesOrderProducts)
|
||||
.where(eq(salesOrderProducts.salesOrderId, salesOrderId));
|
||||
}
|
||||
|
||||
private async selectImages(
|
||||
executor: QueryExecutor,
|
||||
salesOrderId: string,
|
||||
): Promise<SalesOrderImageRow[]> {
|
||||
return executor
|
||||
.select()
|
||||
.from(salesOrderImages)
|
||||
.where(eq(salesOrderImages.salesOrderId, salesOrderId));
|
||||
}
|
||||
|
||||
private async replaceProducts(
|
||||
executor: QueryExecutor,
|
||||
salesOrderId: string,
|
||||
products: readonly SalesOrderLineInput[],
|
||||
): Promise<void> {
|
||||
await executor
|
||||
.delete(salesOrderProducts)
|
||||
.where(eq(salesOrderProducts.salesOrderId, salesOrderId));
|
||||
if (products.length === 0) {
|
||||
return;
|
||||
}
|
||||
await executor.insert(salesOrderProducts).values(
|
||||
products.map((line) => ({
|
||||
salesOrderId,
|
||||
productId: line.productId,
|
||||
quantity: line.quantity.value,
|
||||
price: line.price.value,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
private async replaceImages(
|
||||
executor: QueryExecutor,
|
||||
salesOrderId: string,
|
||||
images: readonly SalesOrderImageInput[],
|
||||
): Promise<void> {
|
||||
await executor
|
||||
.delete(salesOrderImages)
|
||||
.where(eq(salesOrderImages.salesOrderId, salesOrderId));
|
||||
if (images.length === 0) {
|
||||
return;
|
||||
}
|
||||
await executor.insert(salesOrderImages).values(
|
||||
images.map((image) => ({
|
||||
salesOrderId,
|
||||
url: image.url,
|
||||
description: image.description ?? null,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
private buildListWhere(filters: ListSalesOrdersFilters): SQL | undefined {
|
||||
const parts: SQL[] = [];
|
||||
if (filters.code) {
|
||||
parts.push(ilike(salesOrders.code, `%${filters.code}%`));
|
||||
}
|
||||
if (filters.status) {
|
||||
parts.push(eq(salesOrders.status, filters.status));
|
||||
}
|
||||
if (filters.customerId) {
|
||||
parts.push(eq(salesOrders.customerId, filters.customerId));
|
||||
}
|
||||
if (filters.salesPersonId) {
|
||||
parts.push(eq(salesOrders.salesPersonId, filters.salesPersonId));
|
||||
}
|
||||
if (filters.branchId) {
|
||||
parts.push(eq(salesOrders.branchId, filters.branchId));
|
||||
}
|
||||
if (filters.divisionId) {
|
||||
parts.push(eq(salesOrders.divisionId, filters.divisionId));
|
||||
}
|
||||
if (filters.search) {
|
||||
const search = or(
|
||||
ilike(salesOrders.code, `%${filters.search}%`),
|
||||
ilike(salesOrders.address, `%${filters.search}%`),
|
||||
ilike(salesOrders.notes, `%${filters.search}%`),
|
||||
);
|
||||
if (search) {
|
||||
parts.push(search);
|
||||
}
|
||||
}
|
||||
if (parts.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return parts.length === 1 ? parts[0] : and(...parts);
|
||||
}
|
||||
|
||||
private toInsertValues(
|
||||
input: CreateSalesOrderInput,
|
||||
code: string,
|
||||
status: Status,
|
||||
now: DateTime,
|
||||
userId: string,
|
||||
) {
|
||||
return {
|
||||
code,
|
||||
salesRequestId: input.salesRequestId ?? null,
|
||||
date: input.date.value,
|
||||
salesPersonId: input.salesPersonId,
|
||||
branchId: input.branchId,
|
||||
divisionId: input.divisionId,
|
||||
customerId: input.customerId,
|
||||
address: input.address,
|
||||
latitude: input.latitude ?? null,
|
||||
longitude: input.longitude ?? null,
|
||||
notes: input.notes ?? null,
|
||||
status: status.value,
|
||||
createdAt: now.value,
|
||||
updatedAt: now.value,
|
||||
createdBy: userId,
|
||||
updatedBy: userId,
|
||||
};
|
||||
}
|
||||
|
||||
private toDomain(
|
||||
row: SalesOrderRow,
|
||||
productRows: SalesOrderProductRow[],
|
||||
imageRows: SalesOrderImageRow[],
|
||||
): SalesOrder {
|
||||
return {
|
||||
id: row.id,
|
||||
code: row.code,
|
||||
salesRequestId: row.salesRequestId,
|
||||
date: DateTime.fromUnixMs(row.date),
|
||||
salesPersonId: row.salesPersonId,
|
||||
branchId: row.branchId,
|
||||
divisionId: row.divisionId,
|
||||
customerId: row.customerId,
|
||||
address: row.address,
|
||||
latitude: row.latitude,
|
||||
longitude: row.longitude,
|
||||
notes: row.notes,
|
||||
products: productRows.map((line) => ({
|
||||
id: line.id,
|
||||
productId: line.productId,
|
||||
quantity: Decimal.create(line.quantity),
|
||||
price: Decimal.create(line.price),
|
||||
})),
|
||||
images: imageRows.map((image) => ({
|
||||
id: image.id,
|
||||
url: image.url,
|
||||
description: image.description,
|
||||
})),
|
||||
status: Status.create(row.status, SALES_ORDER_STATUSES),
|
||||
createdAt: DateTime.fromUnixMs(row.createdAt),
|
||||
updatedAt: DateTime.fromUnixMs(row.updatedAt),
|
||||
createdBy: row.createdBy,
|
||||
updatedBy: row.updatedBy,
|
||||
};
|
||||
}
|
||||
|
||||
private rethrowConstraintViolation(error: unknown): never {
|
||||
if (error instanceof NotFoundException) {
|
||||
throw error;
|
||||
}
|
||||
const err = this.unwrapDbError(error);
|
||||
if (err.code === '23505') {
|
||||
throw new ConflictException('Sales order code already exists');
|
||||
}
|
||||
if (err.code === '23503') {
|
||||
throw new ConflictException('Related record was not found');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
private unwrapDbError(error: unknown): { code?: string } {
|
||||
let current: unknown = error;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (!current || typeof current !== 'object') {
|
||||
break;
|
||||
}
|
||||
const obj = current as { code?: string; cause?: unknown };
|
||||
if (obj.code === '23505' || obj.code === '23503') {
|
||||
return { code: obj.code };
|
||||
}
|
||||
current = obj.cause;
|
||||
}
|
||||
return error as { code?: string };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
import { BranchesService } from '../../configuration/branches/branches.service';
|
||||
import { CustomersService } from '../../configuration/customers/customers.service';
|
||||
import { DivisionsService } from '../../configuration/divisions/divisions.service';
|
||||
import { EmployeesService } from '../../configuration/employees/employees.service';
|
||||
import { ProductsService } from '../../configuration/products/products.service';
|
||||
import { SalesRequestsService } from '../sales-requests/sales-requests.service';
|
||||
import { SALES_ORDER_STATUSES } from '../shared/sales-fields';
|
||||
import type { SalesOrder } from './sales-order';
|
||||
import { SalesOrdersRepository } from './sales-orders.repository';
|
||||
import { SalesOrdersService } from './sales-orders.service';
|
||||
|
||||
describe('SalesOrdersService', () => {
|
||||
let service: SalesOrdersService;
|
||||
const repository: jest.Mocked<
|
||||
Pick<
|
||||
SalesOrdersRepository,
|
||||
| 'list'
|
||||
| 'findById'
|
||||
| 'create'
|
||||
| 'createMany'
|
||||
| 'update'
|
||||
| 'updateStatus'
|
||||
| 'bulkUpdateStatus'
|
||||
| 'delete'
|
||||
| 'bulkDelete'
|
||||
>
|
||||
> = {
|
||||
list: jest.fn(),
|
||||
findById: jest.fn(),
|
||||
create: jest.fn(),
|
||||
createMany: jest.fn(),
|
||||
update: jest.fn(),
|
||||
updateStatus: jest.fn(),
|
||||
bulkUpdateStatus: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
bulkDelete: jest.fn(),
|
||||
};
|
||||
const employeesService = { findById: jest.fn() };
|
||||
const branchesService = { findById: jest.fn() };
|
||||
const divisionsService = { findById: jest.fn() };
|
||||
const customersService = { findById: jest.fn() };
|
||||
const productsService = { findById: jest.fn() };
|
||||
const salesRequestsService = { findById: jest.fn() };
|
||||
|
||||
const now = DateTime.fromUnixMs(1_700_000_000_000);
|
||||
const sample: SalesOrder = {
|
||||
id: 'sr-1',
|
||||
code: 'SO-20260824-0001',
|
||||
salesRequestId: null,
|
||||
date: now,
|
||||
salesPersonId: 'emp-1',
|
||||
branchId: 'br-1',
|
||||
divisionId: 'div-1',
|
||||
customerId: 'cus-1',
|
||||
address: 'Jl Sudirman 1',
|
||||
latitude: -6.2,
|
||||
longitude: 106.8,
|
||||
notes: null,
|
||||
products: [
|
||||
{
|
||||
id: 'line-1',
|
||||
productId: 'prd-1',
|
||||
quantity: Decimal.create('2'),
|
||||
price: Decimal.create('12500'),
|
||||
},
|
||||
],
|
||||
images: [],
|
||||
status: Status.create('draft', SALES_ORDER_STATUSES),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: 'user-1',
|
||||
updatedBy: 'user-1',
|
||||
};
|
||||
|
||||
const createBody = {
|
||||
date: '2026-08-24T10:00:00+07:00',
|
||||
salesPersonId: 'emp-1',
|
||||
branchId: 'br-1',
|
||||
divisionId: 'div-1',
|
||||
customerId: 'cus-1',
|
||||
address: 'Jl Sudirman 1',
|
||||
products: [{ productId: 'prd-1', quantity: '2' }],
|
||||
userId: 'user-1',
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
employeesService.findById.mockResolvedValue({ id: 'emp-1' });
|
||||
branchesService.findById.mockResolvedValue({ id: 'br-1' });
|
||||
divisionsService.findById.mockResolvedValue({ id: 'div-1' });
|
||||
customersService.findById.mockResolvedValue({ id: 'cus-1' });
|
||||
productsService.findById.mockResolvedValue({
|
||||
id: 'prd-1',
|
||||
price: '12500.0000',
|
||||
});
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
SalesOrdersService,
|
||||
{ provide: SalesOrdersRepository, useValue: repository },
|
||||
{ provide: EmployeesService, useValue: employeesService },
|
||||
{ provide: BranchesService, useValue: branchesService },
|
||||
{ provide: DivisionsService, useValue: divisionsService },
|
||||
{ provide: CustomersService, useValue: customersService },
|
||||
{ provide: ProductsService, useValue: productsService },
|
||||
{ provide: SalesRequestsService, useValue: salesRequestsService },
|
||||
],
|
||||
}).compile();
|
||||
service = moduleRef.get(SalesOrdersService);
|
||||
});
|
||||
|
||||
it('create defaults price from the product catalog', async () => {
|
||||
repository.create.mockResolvedValue(sample);
|
||||
const result = await service.create(createBody);
|
||||
expect(result.code).toBe('SO-20260824-0001');
|
||||
expect(repository.create).toHaveBeenCalled();
|
||||
const [arg] = repository.create.mock.calls[0];
|
||||
expect(arg.products[0]?.price.value).toBe('12500.0000');
|
||||
expect(arg.status?.value).toBe('draft');
|
||||
});
|
||||
|
||||
it('create rejects a missing product line list', async () => {
|
||||
await expect(
|
||||
service.create({ ...createBody, products: [] }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('findById throws when missing', async () => {
|
||||
repository.findById.mockResolvedValue(null);
|
||||
await expect(service.findById('missing')).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('update rejects status on PATCH', async () => {
|
||||
await expect(
|
||||
service.update('sr-1', { status: 'approved', userId: 'user-1' }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('updateStatus uses the sales-request allow-list', async () => {
|
||||
repository.updateStatus.mockResolvedValue(sample);
|
||||
await service.updateStatus('sr-1', 'processed', 'user-1');
|
||||
expect(repository.updateStatus).toHaveBeenCalledWith(
|
||||
'sr-1',
|
||||
expect.objectContaining({ value: 'processed' }),
|
||||
'user-1',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,588 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type { PaginationResponse } from '../../../common/http/response';
|
||||
import { toListPage } from '../../../common/http/response';
|
||||
import { InvalidDateTimeError } from '../../../common/value-objects/date-time/invalid-date-time.error';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||
import { InvalidDecimalError } from '../../../common/value-objects/decimal/invalid-decimal.error';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
import { BranchesService } from '../../configuration/branches/branches.service';
|
||||
import { CustomersService } from '../../configuration/customers/customers.service';
|
||||
import { DivisionsService } from '../../configuration/divisions/divisions.service';
|
||||
import { EmployeesService } from '../../configuration/employees/employees.service';
|
||||
import { ProductsService } from '../../configuration/products/products.service';
|
||||
import { SalesRequestsService } from '../sales-requests/sales-requests.service';
|
||||
import {
|
||||
isValidDocumentAddress,
|
||||
isValidDocumentCode,
|
||||
isValidDocumentNotes,
|
||||
isValidImageDescription,
|
||||
isValidImageUrl,
|
||||
isValidLatitude,
|
||||
isValidLongitude,
|
||||
parseCsvRecord,
|
||||
SALES_ORDER_STATUSES,
|
||||
} from '../shared/sales-fields';
|
||||
import type {
|
||||
CreateSalesOrderInput,
|
||||
SalesOrder,
|
||||
SalesOrderImageInput,
|
||||
SalesOrderLineInput,
|
||||
UpdateSalesOrderInput,
|
||||
} from './sales-order';
|
||||
import { SalesOrdersRepository } from './sales-orders.repository';
|
||||
|
||||
export type SalesLineBody = {
|
||||
readonly productId: string;
|
||||
readonly quantity: string;
|
||||
readonly price?: string;
|
||||
};
|
||||
|
||||
export type SalesImageBody = {
|
||||
readonly url: string;
|
||||
readonly description?: string | null;
|
||||
};
|
||||
|
||||
export type ListSalesOrdersQuery = {
|
||||
readonly code?: string;
|
||||
readonly status?: string;
|
||||
readonly customerId?: string;
|
||||
readonly salesPersonId?: string;
|
||||
readonly branchId?: string;
|
||||
readonly divisionId?: string;
|
||||
readonly search?: string;
|
||||
readonly page?: number;
|
||||
readonly limit?: number;
|
||||
readonly offset?: number;
|
||||
};
|
||||
|
||||
const CSV_REQUIRED_HEADERS = [
|
||||
'date',
|
||||
'salesPersonId',
|
||||
'branchId',
|
||||
'divisionId',
|
||||
'customerId',
|
||||
'address',
|
||||
] as const;
|
||||
|
||||
@Injectable()
|
||||
export class SalesOrdersService {
|
||||
constructor(
|
||||
private readonly salesOrdersRepository: SalesOrdersRepository,
|
||||
private readonly employeesService: EmployeesService,
|
||||
private readonly branchesService: BranchesService,
|
||||
private readonly divisionsService: DivisionsService,
|
||||
private readonly customersService: CustomersService,
|
||||
private readonly productsService: ProductsService,
|
||||
private readonly salesRequestsService: SalesRequestsService,
|
||||
) {}
|
||||
|
||||
async list(
|
||||
query: ListSalesOrdersQuery,
|
||||
): Promise<PaginationResponse<ReturnType<SalesOrdersService['toListItem']>>> {
|
||||
const page = toListPage(query);
|
||||
const { data, total } = await this.salesOrdersRepository.list({
|
||||
code: query.code,
|
||||
status: query.status,
|
||||
customerId: query.customerId,
|
||||
salesPersonId: query.salesPersonId,
|
||||
branchId: query.branchId,
|
||||
divisionId: query.divisionId,
|
||||
search: query.search,
|
||||
limit: page.limit,
|
||||
offset: page.offset,
|
||||
});
|
||||
return {
|
||||
data: data.map((item) => this.toListItem(item)),
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
async findById(
|
||||
id: string,
|
||||
): Promise<ReturnType<SalesOrdersService['toDetail']>> {
|
||||
const found = await this.salesOrdersRepository.findById(id);
|
||||
if (!found) {
|
||||
throw new NotFoundException('Sales order not found');
|
||||
}
|
||||
return this.toDetail(found);
|
||||
}
|
||||
|
||||
async create(input: {
|
||||
code?: string;
|
||||
salesRequestId?: string;
|
||||
date?: string;
|
||||
salesPersonId?: string;
|
||||
branchId: string;
|
||||
divisionId: string;
|
||||
customerId: string;
|
||||
address: string;
|
||||
latitude?: number | null;
|
||||
longitude?: number | null;
|
||||
notes?: string | null;
|
||||
products?: SalesLineBody[];
|
||||
images?: SalesImageBody[];
|
||||
status?: string;
|
||||
userId: string;
|
||||
}): Promise<ReturnType<SalesOrdersService['toDetail']>> {
|
||||
const merged = await this.mergeFromSalesRequest(input);
|
||||
await this.assertRelations(merged);
|
||||
const created = await this.salesOrdersRepository.create(
|
||||
await this.toCreateInput(merged),
|
||||
);
|
||||
return this.toDetail(created);
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
input: {
|
||||
code?: string;
|
||||
date?: string;
|
||||
salesPersonId?: string;
|
||||
branchId?: string;
|
||||
divisionId?: string;
|
||||
customerId?: string;
|
||||
address?: string;
|
||||
latitude?: number | null;
|
||||
longitude?: number | null;
|
||||
notes?: string | null;
|
||||
products?: SalesLineBody[];
|
||||
images?: SalesImageBody[];
|
||||
status?: unknown;
|
||||
userId: string;
|
||||
},
|
||||
): Promise<ReturnType<SalesOrdersService['toDetail']>> {
|
||||
if (input.status !== undefined) {
|
||||
throw new BadRequestException('status cannot be updated via PATCH');
|
||||
}
|
||||
await this.assertRelations(input);
|
||||
const payload: UpdateSalesOrderInput = {
|
||||
code: input.code !== undefined ? this.assertCode(input.code) : undefined,
|
||||
date: input.date !== undefined ? this.assertDate(input.date) : undefined,
|
||||
salesPersonId: input.salesPersonId,
|
||||
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:
|
||||
input.products !== undefined
|
||||
? await this.assertLines(input.products)
|
||||
: undefined,
|
||||
images:
|
||||
input.images !== undefined
|
||||
? input.images.map((image) => this.assertImage(image))
|
||||
: undefined,
|
||||
userId: input.userId,
|
||||
};
|
||||
const updated = await this.salesOrdersRepository.update(id, payload);
|
||||
return this.toDetail(updated);
|
||||
}
|
||||
|
||||
async updateStatus(
|
||||
id: string,
|
||||
statusRaw: string,
|
||||
userId: string,
|
||||
): Promise<ReturnType<SalesOrdersService['toDetail']>> {
|
||||
const status = this.assertStatus(statusRaw);
|
||||
const updated = await this.salesOrdersRepository.updateStatus(
|
||||
id,
|
||||
status,
|
||||
userId,
|
||||
);
|
||||
return this.toDetail(updated);
|
||||
}
|
||||
|
||||
async bulkUpdateStatus(
|
||||
ids: string[],
|
||||
statusRaw: string,
|
||||
userId: string,
|
||||
): Promise<{ updated: number }> {
|
||||
const status = this.assertStatus(statusRaw);
|
||||
const updated = await this.salesOrdersRepository.bulkUpdateStatus(
|
||||
ids,
|
||||
status,
|
||||
userId,
|
||||
);
|
||||
return { updated };
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.salesOrdersRepository.delete(id);
|
||||
}
|
||||
|
||||
async bulkDelete(ids: string[]): Promise<{ deleted: number }> {
|
||||
const deleted = await this.salesOrdersRepository.bulkDelete(ids);
|
||||
return { deleted };
|
||||
}
|
||||
|
||||
async importCsv(csv: string, userId: string): Promise<{ imported: number }> {
|
||||
const rawLines = csv.split(/\r?\n/);
|
||||
const filled = rawLines
|
||||
.map((line, index) => ({ line: line.trim(), lineNo: index + 1 }))
|
||||
.filter((entry) => entry.line.length > 0);
|
||||
if (filled.length === 0) {
|
||||
throw new BadRequestException('CSV is empty');
|
||||
}
|
||||
if (filled.length > 501) {
|
||||
throw new BadRequestException('CSV exceeds maximum of 500 data rows');
|
||||
}
|
||||
const header = parseCsvRecord(filled[0].line).map((h) =>
|
||||
h.trim().toLowerCase(),
|
||||
);
|
||||
const missing = CSV_REQUIRED_HEADERS.filter(
|
||||
(h) => header.indexOf(h.toLowerCase()) < 0,
|
||||
);
|
||||
if (missing.length > 0) {
|
||||
throw new BadRequestException('CSV must include required headers');
|
||||
}
|
||||
const idx = (key: string) => header.indexOf(key.toLowerCase());
|
||||
const errors: string[] = [];
|
||||
const rows: CreateSalesOrderInput[] = [];
|
||||
for (let i = 1; i < filled.length; i++) {
|
||||
const cols = parseCsvRecord(filled[i].line);
|
||||
const rowNum = filled[i].lineNo;
|
||||
try {
|
||||
rows.push(
|
||||
await this.toCreateInput({
|
||||
code: idx('code') >= 0 ? cols[idx('code')] : undefined,
|
||||
date: cols[idx('date')] ?? '',
|
||||
salesPersonId: cols[idx('salespersonid')] ?? '',
|
||||
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: [],
|
||||
images: [],
|
||||
status: idx('status') >= 0 ? cols[idx('status')] : undefined,
|
||||
userId,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
const reason =
|
||||
error instanceof BadRequestException ? error.message : 'invalid data';
|
||||
errors.push(`row ${rowNum}: ${reason}`);
|
||||
}
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
throw new BadRequestException({
|
||||
message: 'CSV validation failed',
|
||||
errors,
|
||||
});
|
||||
}
|
||||
await this.salesOrdersRepository.createMany(rows);
|
||||
return { imported: rows.length };
|
||||
}
|
||||
|
||||
toListItem(item: SalesOrder) {
|
||||
return {
|
||||
id: item.id,
|
||||
code: item.code,
|
||||
salesRequestId: item.salesRequestId,
|
||||
date: item.date.value,
|
||||
salesPersonId: item.salesPersonId,
|
||||
branchId: item.branchId,
|
||||
divisionId: item.divisionId,
|
||||
customerId: item.customerId,
|
||||
address: item.address,
|
||||
latitude: item.latitude,
|
||||
longitude: item.longitude,
|
||||
notes: item.notes,
|
||||
status: item.status.value,
|
||||
createdAt: item.createdAt.value,
|
||||
updatedAt: item.updatedAt.value,
|
||||
createdBy: item.createdBy,
|
||||
updatedBy: item.updatedBy,
|
||||
};
|
||||
}
|
||||
|
||||
toDetail(item: SalesOrder) {
|
||||
return {
|
||||
...this.toListItem(item),
|
||||
products: item.products.map((line) => ({
|
||||
id: line.id,
|
||||
productId: line.productId,
|
||||
quantity: line.quantity.value,
|
||||
price: line.price.value,
|
||||
})),
|
||||
images: item.images.map((image) => ({
|
||||
id: image.id,
|
||||
url: image.url,
|
||||
description: image.description,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
private async mergeFromSalesRequest(input: {
|
||||
code?: string;
|
||||
salesRequestId?: string;
|
||||
date?: string;
|
||||
salesPersonId?: string;
|
||||
branchId?: string;
|
||||
divisionId?: string;
|
||||
customerId?: string;
|
||||
address?: string;
|
||||
latitude?: number | null;
|
||||
longitude?: number | null;
|
||||
notes?: string | null;
|
||||
products?: SalesLineBody[];
|
||||
images?: SalesImageBody[];
|
||||
status?: string;
|
||||
userId: string;
|
||||
}) {
|
||||
if (!input.salesRequestId) {
|
||||
return {
|
||||
...input,
|
||||
date: input.date ?? '',
|
||||
salesPersonId: input.salesPersonId ?? '',
|
||||
branchId: input.branchId ?? '',
|
||||
divisionId: input.divisionId ?? '',
|
||||
customerId: input.customerId ?? '',
|
||||
address: input.address ?? '',
|
||||
products: input.products ?? [],
|
||||
};
|
||||
}
|
||||
const source = await this.salesRequestsService.findById(
|
||||
input.salesRequestId,
|
||||
);
|
||||
return {
|
||||
...input,
|
||||
date: input.date ?? DateTime.fromUnixMs(source.date).format(),
|
||||
salesPersonId: input.salesPersonId ?? source.salesPersonId,
|
||||
branchId: input.branchId ?? source.branchId,
|
||||
divisionId: input.divisionId ?? source.divisionId,
|
||||
customerId: input.customerId ?? source.customerId,
|
||||
address: input.address ?? source.address,
|
||||
latitude: input.latitude !== undefined ? input.latitude : source.latitude,
|
||||
longitude:
|
||||
input.longitude !== undefined ? input.longitude : source.longitude,
|
||||
notes: input.notes !== undefined ? input.notes : source.notes,
|
||||
products:
|
||||
input.products ??
|
||||
source.products.map((line) => ({
|
||||
productId: line.productId,
|
||||
quantity: line.quantity,
|
||||
price: line.price,
|
||||
})),
|
||||
images:
|
||||
input.images ??
|
||||
source.images.map((image) => ({
|
||||
url: image.url,
|
||||
description: image.description,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
private async toCreateInput(input: {
|
||||
code?: string;
|
||||
salesRequestId?: string | null;
|
||||
date: string;
|
||||
salesPersonId: string;
|
||||
branchId: string;
|
||||
divisionId: string;
|
||||
customerId: string;
|
||||
address: string;
|
||||
latitude?: number | null;
|
||||
longitude?: number | null;
|
||||
notes?: string | null;
|
||||
products: SalesLineBody[];
|
||||
images?: SalesImageBody[];
|
||||
status?: string;
|
||||
userId: string;
|
||||
}): Promise<CreateSalesOrderInput> {
|
||||
return {
|
||||
code:
|
||||
input.code !== undefined && input.code !== ''
|
||||
? this.assertCode(input.code)
|
||||
: undefined,
|
||||
salesRequestId: input.salesRequestId ?? null,
|
||||
date: this.assertDate(input.date),
|
||||
salesPersonId: input.salesPersonId,
|
||||
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),
|
||||
images: (input.images ?? []).map((image) => this.assertImage(image)),
|
||||
status: input.status
|
||||
? this.assertStatus(input.status)
|
||||
: Status.create('draft', SALES_ORDER_STATUSES),
|
||||
userId: input.userId,
|
||||
};
|
||||
}
|
||||
|
||||
private async assertRelations(input: {
|
||||
salesPersonId?: string;
|
||||
branchId?: string;
|
||||
divisionId?: string;
|
||||
customerId?: string;
|
||||
}): Promise<void> {
|
||||
if (input.salesPersonId) {
|
||||
await this.employeesService.findById(input.salesPersonId);
|
||||
}
|
||||
if (input.branchId) {
|
||||
await this.branchesService.findById(input.branchId);
|
||||
}
|
||||
if (input.divisionId) {
|
||||
await this.divisionsService.findById(input.divisionId);
|
||||
}
|
||||
if (input.customerId) {
|
||||
await this.customersService.findById(input.customerId);
|
||||
}
|
||||
}
|
||||
|
||||
private async assertLines(
|
||||
lines: SalesLineBody[],
|
||||
): Promise<SalesOrderLineInput[]> {
|
||||
if (!Array.isArray(lines) || lines.length === 0) {
|
||||
throw new BadRequestException('At least one product line is required');
|
||||
}
|
||||
const result: SalesOrderLineInput[] = [];
|
||||
for (const line of lines) {
|
||||
const product = await this.productsService.findById(line.productId);
|
||||
const quantity = this.assertPositiveDecimal(line.quantity, 'quantity');
|
||||
let price: Decimal;
|
||||
if (line.price === undefined || line.price === '') {
|
||||
if (product.price === null) {
|
||||
throw new BadRequestException('Product price is required');
|
||||
}
|
||||
price = Decimal.create(product.price);
|
||||
} else {
|
||||
price = this.assertNonNegativeDecimal(line.price, 'price');
|
||||
}
|
||||
result.push({ productId: product.id, quantity, price });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private assertImage(image: SalesImageBody): SalesOrderImageInput {
|
||||
if (!isValidImageUrl(image.url)) {
|
||||
throw new BadRequestException('Invalid image URL');
|
||||
}
|
||||
const description = image.description ?? null;
|
||||
if (description !== null && !isValidImageDescription(description)) {
|
||||
throw new BadRequestException('Invalid image description');
|
||||
}
|
||||
return { url: image.url, description };
|
||||
}
|
||||
|
||||
private assertCode(raw: string): string {
|
||||
const code = raw.trim();
|
||||
if (!isValidDocumentCode(code)) {
|
||||
throw new BadRequestException('Invalid document code');
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
private assertDate(raw: string): DateTime {
|
||||
try {
|
||||
return DateTime.create(raw);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidDateTimeError) {
|
||||
throw new BadRequestException('Invalid date');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
if (!isValidDocumentNotes(raw)) {
|
||||
throw new BadRequestException('Invalid notes');
|
||||
}
|
||||
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_ORDER_STATUSES);
|
||||
} catch {
|
||||
throw new BadRequestException('Invalid status');
|
||||
}
|
||||
}
|
||||
|
||||
private assertPositiveDecimal(raw: string, label: string): Decimal {
|
||||
const value = this.parseDecimal(raw);
|
||||
if (!value.isPositive()) {
|
||||
throw new BadRequestException(`Invalid ${label}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private assertNonNegativeDecimal(raw: string, label: string): Decimal {
|
||||
const value = this.parseDecimal(raw);
|
||||
if (value.isNegative()) {
|
||||
throw new BadRequestException(`Invalid ${label}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private parseDecimal(raw: string): Decimal {
|
||||
try {
|
||||
return Decimal.create(raw);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidDecimalError) {
|
||||
throw new BadRequestException('Invalid decimal');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayNotEmpty,
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Matches,
|
||||
MaxLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { PaginationQueryDto } from '../../../../common/http/response';
|
||||
import {
|
||||
DOCUMENT_CODE_MAX_LENGTH,
|
||||
DOCUMENT_CODE_PATTERN,
|
||||
DOCUMENT_NOTES_MAX_LENGTH,
|
||||
IMAGE_DESCRIPTION_MAX_LENGTH,
|
||||
IMAGE_URL_MAX_LENGTH,
|
||||
SALES_PAYMENT_STATUSES,
|
||||
} from '../../shared/sales-fields';
|
||||
|
||||
export class PaymentAllocationDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID('4')
|
||||
invoiceId!: string;
|
||||
|
||||
@ApiProperty({ example: '10000.0000' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
amount!: string;
|
||||
}
|
||||
|
||||
export class SalesImageDto {
|
||||
@ApiProperty({ example: 'https://cdn.example.com/a.png' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(IMAGE_URL_MAX_LENGTH)
|
||||
url!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(IMAGE_DESCRIPTION_MAX_LENGTH)
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export class CreateSalesPaymentDto {
|
||||
@ApiPropertyOptional({ example: 'SP-20260824-0001' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(DOCUMENT_CODE_MAX_LENGTH)
|
||||
@Matches(DOCUMENT_CODE_PATTERN)
|
||||
code?: string;
|
||||
|
||||
@ApiProperty({ example: '2026-08-24T10:00:00+07:00' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
date!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(DOCUMENT_NOTES_MAX_LENGTH)
|
||||
notes?: string;
|
||||
|
||||
@ApiProperty({ type: [PaymentAllocationDto] })
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => PaymentAllocationDto)
|
||||
invoices!: PaymentAllocationDto[];
|
||||
|
||||
@ApiPropertyOptional({ type: [SalesImageDto] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => SalesImageDto)
|
||||
images?: SalesImageDto[];
|
||||
|
||||
@ApiPropertyOptional({ enum: SALES_PAYMENT_STATUSES })
|
||||
@IsOptional()
|
||||
@IsIn([...SALES_PAYMENT_STATUSES])
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export class UpdateSalesPaymentDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(DOCUMENT_CODE_MAX_LENGTH)
|
||||
@Matches(DOCUMENT_CODE_PATTERN)
|
||||
code?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
date?: string;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ type: [PaymentAllocationDto] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => PaymentAllocationDto)
|
||||
invoices?: PaymentAllocationDto[];
|
||||
|
||||
@ApiPropertyOptional({ type: [SalesImageDto] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => SalesImageDto)
|
||||
images?: SalesImageDto[];
|
||||
}
|
||||
|
||||
export class UpdateSalesPaymentStatusDto {
|
||||
@ApiProperty({ enum: SALES_PAYMENT_STATUSES })
|
||||
@IsIn([...SALES_PAYMENT_STATUSES])
|
||||
status!: string;
|
||||
}
|
||||
|
||||
export class BulkIdsDto {
|
||||
@ApiProperty({ type: [String], format: 'uuid' })
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsUUID('4', { each: true })
|
||||
ids!: string[];
|
||||
}
|
||||
|
||||
export class BulkStatusDto {
|
||||
@ApiProperty({ type: [String], format: 'uuid' })
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsUUID('4', { each: true })
|
||||
ids!: string[];
|
||||
|
||||
@ApiProperty({ enum: SALES_PAYMENT_STATUSES })
|
||||
@IsIn([...SALES_PAYMENT_STATUSES])
|
||||
status!: string;
|
||||
}
|
||||
|
||||
export class ListSalesPaymentsQueryDto extends PaginationQueryDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
code?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: SALES_PAYMENT_STATUSES })
|
||||
@IsOptional()
|
||||
@IsIn([...SALES_PAYMENT_STATUSES])
|
||||
status?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export class SalesPaymentDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
id!: string;
|
||||
@ApiProperty()
|
||||
code!: string;
|
||||
@ApiProperty()
|
||||
date!: number;
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
notes!: string | null;
|
||||
@ApiProperty({ enum: SALES_PAYMENT_STATUSES })
|
||||
status!: string;
|
||||
@ApiProperty()
|
||||
createdAt!: number;
|
||||
@ApiProperty()
|
||||
updatedAt!: number;
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
createdBy!: string;
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
updatedBy!: string;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
|
||||
export type SalesPaymentAllocation = {
|
||||
readonly id: string;
|
||||
readonly invoiceId: string;
|
||||
readonly amount: Decimal;
|
||||
};
|
||||
|
||||
export type SalesPaymentImage = {
|
||||
readonly id: string;
|
||||
readonly url: string;
|
||||
readonly description: string | null;
|
||||
};
|
||||
|
||||
export type SalesPayment = {
|
||||
readonly id: string;
|
||||
readonly code: string;
|
||||
readonly date: DateTime;
|
||||
readonly notes: string | null;
|
||||
readonly invoices: readonly SalesPaymentAllocation[];
|
||||
readonly images: readonly SalesPaymentImage[];
|
||||
readonly status: Status;
|
||||
readonly createdAt: DateTime;
|
||||
readonly updatedAt: DateTime;
|
||||
readonly createdBy: string;
|
||||
readonly updatedBy: string;
|
||||
};
|
||||
|
||||
export type SalesPaymentAllocationInput = {
|
||||
readonly invoiceId: string;
|
||||
readonly amount: Decimal;
|
||||
};
|
||||
|
||||
export type SalesPaymentImageInput = {
|
||||
readonly url: string;
|
||||
readonly description?: string | null;
|
||||
};
|
||||
|
||||
export type CreateSalesPaymentInput = {
|
||||
readonly code?: string;
|
||||
readonly date: DateTime;
|
||||
readonly notes?: string | null;
|
||||
readonly invoices: readonly SalesPaymentAllocationInput[];
|
||||
readonly images?: readonly SalesPaymentImageInput[];
|
||||
readonly status?: Status;
|
||||
readonly userId: string;
|
||||
};
|
||||
|
||||
export type UpdateSalesPaymentInput = {
|
||||
readonly code?: string;
|
||||
readonly date?: DateTime;
|
||||
readonly notes?: string | null;
|
||||
readonly invoices?: readonly SalesPaymentAllocationInput[];
|
||||
readonly images?: readonly SalesPaymentImageInput[];
|
||||
readonly userId: string;
|
||||
};
|
||||
|
||||
export type ListSalesPaymentsFilters = {
|
||||
readonly code?: string;
|
||||
readonly status?: string;
|
||||
readonly search?: string;
|
||||
readonly limit: number;
|
||||
readonly offset: number;
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiForbiddenResponse,
|
||||
ApiNotFoundResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
ApiUnauthorizedResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { RequirePrivilege } from '../../../common/decorators/require-privilege.decorator';
|
||||
import {
|
||||
Pagination,
|
||||
type PaginationResponse,
|
||||
PaginationMetaDto,
|
||||
} from '../../../common/http/response';
|
||||
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
|
||||
import {
|
||||
SalesPaymentDto,
|
||||
ListSalesPaymentsQueryDto,
|
||||
} from './dto/sales-payment.dto';
|
||||
import { SalesPaymentsService } from './sales-payments.service';
|
||||
|
||||
export const SALES_PAYMENT_PRIVILEGE_KEY = 'SALES.PAYMENT';
|
||||
|
||||
@ApiTags('sales-payments')
|
||||
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||
@Controller('sales-payments')
|
||||
export class SalesPaymentsReadController {
|
||||
constructor(private readonly salesPaymentsService: SalesPaymentsService) {}
|
||||
|
||||
@Get()
|
||||
@Pagination()
|
||||
@RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEY, 'view')
|
||||
@ApiOperation({ summary: 'List sales payments' })
|
||||
@ApiOkResponse({
|
||||
schema: {
|
||||
properties: {
|
||||
data: {
|
||||
type: 'array',
|
||||
items: { $ref: '#/components/schemas/SalesPaymentDto' },
|
||||
},
|
||||
meta: { $ref: '#/components/schemas/PaginationMetaDto' },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
list(
|
||||
@Query() query: ListSalesPaymentsQueryDto,
|
||||
): Promise<PaginationResponse<SalesPaymentDto>> {
|
||||
return this.salesPaymentsService.list(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEY, 'view')
|
||||
@ApiOperation({ summary: 'Get sales payment detail' })
|
||||
@ApiOkResponse({ type: SalesPaymentDto })
|
||||
@ApiNotFoundResponse()
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string): Promise<SalesPaymentDto> {
|
||||
return this.salesPaymentsService.findById(id);
|
||||
}
|
||||
}
|
||||
|
||||
void PaginationMetaDto;
|
||||
@@ -0,0 +1,189 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
HttpCode,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
ApiConsumes,
|
||||
ApiCreatedResponse,
|
||||
ApiForbiddenResponse,
|
||||
ApiNoContentResponse,
|
||||
ApiNotFoundResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
ApiUnauthorizedResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { CurrentUser } from '../../../common/decorators/current-user.decorator';
|
||||
import { RequirePrivilege } from '../../../common/decorators/require-privilege.decorator';
|
||||
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
|
||||
import { isAllowedCsvUpload } from '../shared/sales-fields';
|
||||
import { SALES_PAYMENT_PRIVILEGE_KEY } from './sales-payments-read.controller';
|
||||
import { SalesPaymentsService } from './sales-payments.service';
|
||||
import {
|
||||
BulkIdsDto,
|
||||
BulkStatusDto,
|
||||
CreateSalesPaymentDto,
|
||||
SalesPaymentDto,
|
||||
UpdateSalesPaymentDto,
|
||||
UpdateSalesPaymentStatusDto,
|
||||
} from './dto/sales-payment.dto';
|
||||
|
||||
@ApiTags('sales-payments')
|
||||
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||
@Controller('sales-payments')
|
||||
export class SalesPaymentsWriteController {
|
||||
constructor(private readonly salesPaymentsService: SalesPaymentsService) {}
|
||||
|
||||
@Post('import')
|
||||
@RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEY, 'import')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
limits: { fileSize: 1_048_576 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (!isAllowedCsvUpload(file)) {
|
||||
cb(new BadRequestException('Only CSV files are allowed'), false);
|
||||
return;
|
||||
}
|
||||
cb(null, true);
|
||||
},
|
||||
}),
|
||||
)
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
file: { type: 'string', format: 'binary' },
|
||||
},
|
||||
required: ['file'],
|
||||
},
|
||||
})
|
||||
@ApiOperation({ summary: 'Import sales payments from CSV' })
|
||||
@ApiOkResponse({
|
||||
schema: { properties: { imported: { type: 'number' } } },
|
||||
})
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
importCsv(
|
||||
@UploadedFile() file: { buffer?: Buffer } | undefined,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<{ imported: number }> {
|
||||
const csv = file?.buffer?.toString('utf8') ?? '';
|
||||
return this.salesPaymentsService.importCsv(csv, userId);
|
||||
}
|
||||
|
||||
@Post('bulk-delete')
|
||||
@HttpCode(200)
|
||||
@RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEY, 'delete')
|
||||
@ApiOperation({ summary: 'Bulk delete sales payments' })
|
||||
@ApiOkResponse({
|
||||
schema: { properties: { deleted: { type: 'number' } } },
|
||||
})
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
bulkDelete(@Body() dto: BulkIdsDto): Promise<{ deleted: number }> {
|
||||
return this.salesPaymentsService.bulkDelete(dto.ids);
|
||||
}
|
||||
|
||||
@Post('bulk-status')
|
||||
@HttpCode(200)
|
||||
@RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEY, 'update')
|
||||
@ApiOperation({ summary: 'Bulk update sales payment status' })
|
||||
@ApiOkResponse({
|
||||
schema: { properties: { updated: { type: 'number' } } },
|
||||
})
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
bulkStatus(
|
||||
@Body() dto: BulkStatusDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<{ updated: number }> {
|
||||
return this.salesPaymentsService.bulkUpdateStatus(
|
||||
dto.ids,
|
||||
dto.status,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEY, 'create')
|
||||
@ApiOperation({ summary: 'Create sales payment' })
|
||||
@ApiCreatedResponse({ type: SalesPaymentDto })
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
create(
|
||||
@Body() dto: CreateSalesPaymentDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<SalesPaymentDto> {
|
||||
return this.salesPaymentsService.create({
|
||||
code: dto.code,
|
||||
date: dto.date,
|
||||
notes: dto.notes,
|
||||
invoices: dto.invoices,
|
||||
images: dto.images,
|
||||
status: dto.status,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
@Patch(':id/status')
|
||||
@RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEY, 'update')
|
||||
@ApiOperation({ summary: 'Update sales payment status' })
|
||||
@ApiOkResponse({ type: SalesPaymentDto })
|
||||
@ApiNotFoundResponse()
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
updateStatus(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateSalesPaymentStatusDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<SalesPaymentDto> {
|
||||
return this.salesPaymentsService.updateStatus(id, dto.status, userId);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEY, 'update')
|
||||
@ApiOperation({ summary: 'Update sales payment (not status)' })
|
||||
@ApiOkResponse({ type: SalesPaymentDto })
|
||||
@ApiNotFoundResponse()
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
update(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateSalesPaymentDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<SalesPaymentDto> {
|
||||
return this.salesPaymentsService.update(id, {
|
||||
code: dto.code,
|
||||
date: dto.date,
|
||||
notes: dto.notes,
|
||||
invoices: dto.invoices,
|
||||
images: dto.images,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(204)
|
||||
@RequirePrivilege(SALES_PAYMENT_PRIVILEGE_KEY, 'delete')
|
||||
@ApiOperation({ summary: 'Delete sales payment' })
|
||||
@ApiNoContentResponse()
|
||||
@ApiNotFoundResponse()
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
async delete(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
|
||||
await this.salesPaymentsService.delete(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SalesInvoicesModule } from '../sales-invoices/sales-invoices.module';
|
||||
import { DocumentCodeService } from '../shared/document-code.service';
|
||||
import { SalesPaymentsReadController } from './sales-payments-read.controller';
|
||||
import { SalesPaymentsWriteController } from './sales-payments-write.controller';
|
||||
import { SalesPaymentsRepository } from './sales-payments.repository';
|
||||
import { SalesPaymentsService } from './sales-payments.service';
|
||||
|
||||
@Module({
|
||||
imports: [SalesInvoicesModule],
|
||||
controllers: [SalesPaymentsReadController, SalesPaymentsWriteController],
|
||||
providers: [
|
||||
DocumentCodeService,
|
||||
SalesPaymentsRepository,
|
||||
SalesPaymentsService,
|
||||
],
|
||||
exports: [SalesPaymentsService, DocumentCodeService],
|
||||
})
|
||||
export class SalesPaymentsModule {}
|
||||
@@ -0,0 +1,381 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { and, asc, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
import { DRIZZLE, type DrizzleDB } from '../../../database/database.module';
|
||||
import {
|
||||
salesPaymentImages,
|
||||
salesPaymentInvoices,
|
||||
salesPayments,
|
||||
type SalesPaymentImageRow,
|
||||
type SalesPaymentInvoiceRow,
|
||||
type SalesPaymentRow,
|
||||
} from '../../../database/sales-payments-table';
|
||||
import { DocumentCodeService } from '../shared/document-code.service';
|
||||
import { DOCUMENT_PREFIXES } from '../shared/document-prefixes';
|
||||
import { SALES_PAYMENT_STATUSES } from '../shared/sales-fields';
|
||||
import type {
|
||||
CreateSalesPaymentInput,
|
||||
ListSalesPaymentsFilters,
|
||||
SalesPayment,
|
||||
SalesPaymentAllocationInput,
|
||||
SalesPaymentImageInput,
|
||||
UpdateSalesPaymentInput,
|
||||
} from './sales-payment';
|
||||
|
||||
type QueryExecutor = Pick<DrizzleDB, 'select' | 'insert' | 'delete'>;
|
||||
|
||||
@Injectable()
|
||||
export class SalesPaymentsRepository {
|
||||
constructor(
|
||||
@Inject(DRIZZLE) private readonly db: DrizzleDB,
|
||||
private readonly documentCodeService: DocumentCodeService,
|
||||
) {}
|
||||
|
||||
async list(
|
||||
filters: ListSalesPaymentsFilters,
|
||||
): Promise<{ data: SalesPayment[]; total: number }> {
|
||||
const where = this.buildListWhere(filters);
|
||||
const totalRows = await this.db
|
||||
.select({ total: count() })
|
||||
.from(salesPayments)
|
||||
.where(where);
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(salesPayments)
|
||||
.where(where)
|
||||
.orderBy(asc(salesPayments.code))
|
||||
.limit(filters.limit)
|
||||
.offset(filters.offset);
|
||||
return {
|
||||
data: rows.map((row) => this.toDomain(row, [], [])),
|
||||
total: Number(totalRows[0]?.total ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<SalesPayment | null> {
|
||||
const rows: SalesPaymentRow[] = await this.db
|
||||
.select()
|
||||
.from(salesPayments)
|
||||
.where(eq(salesPayments.id, id))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
const invoices = await this.selectAllocations(this.db, id);
|
||||
const images = await this.selectImages(this.db, id);
|
||||
return this.toDomain(row, invoices, images);
|
||||
}
|
||||
|
||||
async create(input: CreateSalesPaymentInput): Promise<SalesPayment> {
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const status =
|
||||
input.status ?? Status.create('draft', SALES_PAYMENT_STATUSES);
|
||||
try {
|
||||
return await this.db.transaction(async (tx) => {
|
||||
const code =
|
||||
input.code ??
|
||||
(await this.documentCodeService.nextCode(
|
||||
DOCUMENT_PREFIXES.salesPayment,
|
||||
input.date,
|
||||
tx,
|
||||
));
|
||||
const inserted = await tx
|
||||
.insert(salesPayments)
|
||||
.values(this.toInsertValues(input, code, status, now, input.userId))
|
||||
.returning();
|
||||
const row = inserted[0];
|
||||
await this.replaceAllocations(tx, row.id, input.invoices);
|
||||
await this.replaceImages(tx, row.id, input.images ?? []);
|
||||
const invoices = await this.selectAllocations(tx, row.id);
|
||||
const images = await this.selectImages(tx, row.id);
|
||||
return this.toDomain(row, invoices, images);
|
||||
});
|
||||
} catch (error) {
|
||||
this.rethrowConstraintViolation(error);
|
||||
}
|
||||
}
|
||||
|
||||
async createMany(inputs: CreateSalesPaymentInput[]): Promise<number> {
|
||||
if (inputs.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
for (const input of inputs) {
|
||||
await this.create(input);
|
||||
}
|
||||
return inputs.length;
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
input: UpdateSalesPaymentInput,
|
||||
): Promise<SalesPayment> {
|
||||
const existing = await this.findById(id);
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Sales payment not found');
|
||||
}
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
try {
|
||||
return await this.db.transaction(async (tx) => {
|
||||
const updated = await tx
|
||||
.update(salesPayments)
|
||||
.set({
|
||||
code: input.code ?? existing.code,
|
||||
date: input.date?.value ?? existing.date.value,
|
||||
notes: input.notes !== undefined ? input.notes : existing.notes,
|
||||
updatedAt: now.value,
|
||||
updatedBy: input.userId,
|
||||
})
|
||||
.where(eq(salesPayments.id, id))
|
||||
.returning();
|
||||
const row = updated[0];
|
||||
if (!row) {
|
||||
throw new NotFoundException('Sales payment not found');
|
||||
}
|
||||
if (input.invoices !== undefined) {
|
||||
await this.replaceAllocations(tx, id, input.invoices);
|
||||
}
|
||||
if (input.images !== undefined) {
|
||||
await this.replaceImages(tx, id, input.images);
|
||||
}
|
||||
const invoices = await this.selectAllocations(tx, id);
|
||||
const images = await this.selectImages(tx, id);
|
||||
return this.toDomain(row, invoices, images);
|
||||
});
|
||||
} catch (error) {
|
||||
this.rethrowConstraintViolation(error);
|
||||
}
|
||||
}
|
||||
|
||||
async updateStatus(
|
||||
id: string,
|
||||
status: Status,
|
||||
userId: string,
|
||||
): Promise<SalesPayment> {
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const updated = await this.db
|
||||
.update(salesPayments)
|
||||
.set({
|
||||
status: status.value,
|
||||
updatedAt: now.value,
|
||||
updatedBy: userId,
|
||||
})
|
||||
.where(eq(salesPayments.id, id))
|
||||
.returning();
|
||||
const row = updated[0];
|
||||
if (!row) {
|
||||
throw new NotFoundException('Sales payment not found');
|
||||
}
|
||||
const invoices = await this.selectAllocations(this.db, id);
|
||||
const images = await this.selectImages(this.db, id);
|
||||
return this.toDomain(row, invoices, images);
|
||||
}
|
||||
|
||||
async bulkUpdateStatus(
|
||||
ids: string[],
|
||||
status: Status,
|
||||
userId: string,
|
||||
): Promise<number> {
|
||||
if (ids.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const rows = await this.db
|
||||
.update(salesPayments)
|
||||
.set({
|
||||
status: status.value,
|
||||
updatedAt: now.value,
|
||||
updatedBy: userId,
|
||||
})
|
||||
.where(inArray(salesPayments.id, ids))
|
||||
.returning({ id: salesPayments.id });
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
const deleted = await this.db
|
||||
.delete(salesPayments)
|
||||
.where(eq(salesPayments.id, id))
|
||||
.returning({ id: salesPayments.id });
|
||||
if (deleted.length === 0) {
|
||||
throw new NotFoundException('Sales payment not found');
|
||||
}
|
||||
}
|
||||
|
||||
async bulkDelete(ids: string[]): Promise<number> {
|
||||
if (ids.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const deleted = await this.db
|
||||
.delete(salesPayments)
|
||||
.where(inArray(salesPayments.id, ids))
|
||||
.returning({ id: salesPayments.id });
|
||||
return deleted.length;
|
||||
}
|
||||
|
||||
private async selectAllocations(
|
||||
executor: QueryExecutor,
|
||||
salesPaymentId: string,
|
||||
): Promise<SalesPaymentInvoiceRow[]> {
|
||||
return executor
|
||||
.select()
|
||||
.from(salesPaymentInvoices)
|
||||
.where(eq(salesPaymentInvoices.salesPaymentId, salesPaymentId));
|
||||
}
|
||||
|
||||
private async selectImages(
|
||||
executor: QueryExecutor,
|
||||
salesPaymentId: string,
|
||||
): Promise<SalesPaymentImageRow[]> {
|
||||
return executor
|
||||
.select()
|
||||
.from(salesPaymentImages)
|
||||
.where(eq(salesPaymentImages.salesPaymentId, salesPaymentId));
|
||||
}
|
||||
|
||||
private async replaceAllocations(
|
||||
executor: QueryExecutor,
|
||||
salesPaymentId: string,
|
||||
invoices: readonly SalesPaymentAllocationInput[],
|
||||
): Promise<void> {
|
||||
await executor
|
||||
.delete(salesPaymentInvoices)
|
||||
.where(eq(salesPaymentInvoices.salesPaymentId, salesPaymentId));
|
||||
if (invoices.length === 0) {
|
||||
return;
|
||||
}
|
||||
await executor.insert(salesPaymentInvoices).values(
|
||||
invoices.map((line) => ({
|
||||
salesPaymentId,
|
||||
salesInvoiceId: line.invoiceId,
|
||||
amount: line.amount.value,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
private async replaceImages(
|
||||
executor: QueryExecutor,
|
||||
salesPaymentId: string,
|
||||
images: readonly SalesPaymentImageInput[],
|
||||
): Promise<void> {
|
||||
await executor
|
||||
.delete(salesPaymentImages)
|
||||
.where(eq(salesPaymentImages.salesPaymentId, salesPaymentId));
|
||||
if (images.length === 0) {
|
||||
return;
|
||||
}
|
||||
await executor.insert(salesPaymentImages).values(
|
||||
images.map((image) => ({
|
||||
salesPaymentId,
|
||||
url: image.url,
|
||||
description: image.description ?? null,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
private buildListWhere(filters: ListSalesPaymentsFilters): SQL | undefined {
|
||||
const parts: SQL[] = [];
|
||||
if (filters.code) {
|
||||
parts.push(ilike(salesPayments.code, `%${filters.code}%`));
|
||||
}
|
||||
if (filters.status) {
|
||||
parts.push(eq(salesPayments.status, filters.status));
|
||||
}
|
||||
if (filters.search) {
|
||||
const search = or(
|
||||
ilike(salesPayments.code, `%${filters.search}%`),
|
||||
ilike(salesPayments.notes, `%${filters.search}%`),
|
||||
);
|
||||
if (search) {
|
||||
parts.push(search);
|
||||
}
|
||||
}
|
||||
if (parts.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return parts.length === 1 ? parts[0] : and(...parts);
|
||||
}
|
||||
|
||||
private toInsertValues(
|
||||
input: CreateSalesPaymentInput,
|
||||
code: string,
|
||||
status: Status,
|
||||
now: DateTime,
|
||||
userId: string,
|
||||
) {
|
||||
return {
|
||||
code,
|
||||
date: input.date.value,
|
||||
notes: input.notes ?? null,
|
||||
status: status.value,
|
||||
createdAt: now.value,
|
||||
updatedAt: now.value,
|
||||
createdBy: userId,
|
||||
updatedBy: userId,
|
||||
};
|
||||
}
|
||||
|
||||
private toDomain(
|
||||
row: SalesPaymentRow,
|
||||
allocationRows: SalesPaymentInvoiceRow[],
|
||||
imageRows: SalesPaymentImageRow[],
|
||||
): SalesPayment {
|
||||
return {
|
||||
id: row.id,
|
||||
code: row.code,
|
||||
date: DateTime.fromUnixMs(row.date),
|
||||
notes: row.notes,
|
||||
invoices: allocationRows.map((line) => ({
|
||||
id: line.id,
|
||||
invoiceId: line.salesInvoiceId,
|
||||
amount: Decimal.create(line.amount),
|
||||
})),
|
||||
images: imageRows.map((image) => ({
|
||||
id: image.id,
|
||||
url: image.url,
|
||||
description: image.description,
|
||||
})),
|
||||
status: Status.create(row.status, SALES_PAYMENT_STATUSES),
|
||||
createdAt: DateTime.fromUnixMs(row.createdAt),
|
||||
updatedAt: DateTime.fromUnixMs(row.updatedAt),
|
||||
createdBy: row.createdBy,
|
||||
updatedBy: row.updatedBy,
|
||||
};
|
||||
}
|
||||
|
||||
private rethrowConstraintViolation(error: unknown): never {
|
||||
if (error instanceof NotFoundException) {
|
||||
throw error;
|
||||
}
|
||||
const err = this.unwrapDbError(error);
|
||||
if (err.code === '23505') {
|
||||
throw new ConflictException('Sales payment code already exists');
|
||||
}
|
||||
if (err.code === '23503') {
|
||||
throw new ConflictException('Related record was not found');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
private unwrapDbError(error: unknown): { code?: string } {
|
||||
let current: unknown = error;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (!current || typeof current !== 'object') {
|
||||
break;
|
||||
}
|
||||
const obj = current as { code?: string; cause?: unknown };
|
||||
if (obj.code === '23505' || obj.code === '23503') {
|
||||
return { code: obj.code };
|
||||
}
|
||||
current = obj.cause;
|
||||
}
|
||||
return error as { code?: string };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
import { SalesInvoicesService } from '../sales-invoices/sales-invoices.service';
|
||||
import { SALES_PAYMENT_STATUSES } from '../shared/sales-fields';
|
||||
import type { SalesPayment } from './sales-payment';
|
||||
import { SalesPaymentsRepository } from './sales-payments.repository';
|
||||
import { SalesPaymentsService } from './sales-payments.service';
|
||||
|
||||
describe('SalesPaymentsService', () => {
|
||||
let service: SalesPaymentsService;
|
||||
const repository: jest.Mocked<
|
||||
Pick<
|
||||
SalesPaymentsRepository,
|
||||
| 'list'
|
||||
| 'findById'
|
||||
| 'create'
|
||||
| 'createMany'
|
||||
| 'update'
|
||||
| 'updateStatus'
|
||||
| 'bulkUpdateStatus'
|
||||
| 'delete'
|
||||
| 'bulkDelete'
|
||||
>
|
||||
> = {
|
||||
list: jest.fn(),
|
||||
findById: jest.fn(),
|
||||
create: jest.fn(),
|
||||
createMany: jest.fn(),
|
||||
update: jest.fn(),
|
||||
updateStatus: jest.fn(),
|
||||
bulkUpdateStatus: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
bulkDelete: jest.fn(),
|
||||
};
|
||||
const salesInvoicesService = {
|
||||
findById: jest.fn(),
|
||||
getTotals: jest.fn(),
|
||||
applyPaymentEffects: jest.fn(),
|
||||
};
|
||||
|
||||
const now = DateTime.fromUnixMs(1_700_000_000_000);
|
||||
const sample: SalesPayment = {
|
||||
id: 'sp-1',
|
||||
code: 'SP-20260824-0001',
|
||||
date: now,
|
||||
notes: null,
|
||||
invoices: [
|
||||
{
|
||||
id: 'alloc-1',
|
||||
invoiceId: 'si-1',
|
||||
amount: Decimal.create('10000'),
|
||||
},
|
||||
],
|
||||
images: [],
|
||||
status: Status.create('draft', SALES_PAYMENT_STATUSES),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: 'user-1',
|
||||
updatedBy: 'user-1',
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
salesInvoicesService.findById.mockResolvedValue({ id: 'si-1' });
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
SalesPaymentsService,
|
||||
{ provide: SalesPaymentsRepository, useValue: repository },
|
||||
{ provide: SalesInvoicesService, useValue: salesInvoicesService },
|
||||
],
|
||||
}).compile();
|
||||
service = moduleRef.get(SalesPaymentsService);
|
||||
});
|
||||
|
||||
it('create persists invoice allocations', async () => {
|
||||
repository.create.mockResolvedValue(sample);
|
||||
const result = await service.create({
|
||||
date: '2026-08-24T10:00:00+07:00',
|
||||
invoices: [{ invoiceId: 'si-1', amount: '10000' }],
|
||||
userId: 'user-1',
|
||||
});
|
||||
expect(result.code).toBe('SP-20260824-0001');
|
||||
expect(repository.create).toHaveBeenCalled();
|
||||
const [arg] = repository.create.mock.calls[0];
|
||||
expect(arg.invoices[0]?.invoiceId).toBe('si-1');
|
||||
expect(arg.invoices[0]?.amount.value).toBe('10000.0000');
|
||||
});
|
||||
|
||||
it('update rejects status on PATCH', async () => {
|
||||
await expect(
|
||||
service.update('sp-1', { status: 'approved', userId: 'user-1' }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('findById throws when missing', async () => {
|
||||
repository.findById.mockResolvedValue(null);
|
||||
await expect(service.findById('missing')).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('approving a payment recomputes referenced invoices', async () => {
|
||||
repository.findById.mockResolvedValue(sample);
|
||||
salesInvoicesService.getTotals.mockResolvedValue({
|
||||
total: Decimal.create('25000'),
|
||||
paid: Decimal.create('0'),
|
||||
balance: Decimal.create('25000'),
|
||||
});
|
||||
repository.updateStatus.mockResolvedValue({
|
||||
...sample,
|
||||
status: Status.create('approved', SALES_PAYMENT_STATUSES),
|
||||
});
|
||||
await service.updateStatus('sp-1', 'approved', 'user-1');
|
||||
expect(salesInvoicesService.applyPaymentEffects).toHaveBeenCalledWith(
|
||||
'si-1',
|
||||
'user-1',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects approve when allocation would exceed the invoice total', async () => {
|
||||
repository.findById.mockResolvedValue(sample);
|
||||
salesInvoicesService.getTotals.mockResolvedValue({
|
||||
total: Decimal.create('5000'),
|
||||
paid: Decimal.create('0'),
|
||||
balance: Decimal.create('5000'),
|
||||
});
|
||||
await expect(
|
||||
service.updateStatus('sp-1', 'approved', 'user-1'),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(repository.updateStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,396 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type { PaginationResponse } from '../../../common/http/response';
|
||||
import { toListPage } from '../../../common/http/response';
|
||||
import { InvalidDateTimeError } from '../../../common/value-objects/date-time/invalid-date-time.error';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||
import { InvalidDecimalError } from '../../../common/value-objects/decimal/invalid-decimal.error';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
import { SalesInvoicesService } from '../sales-invoices/sales-invoices.service';
|
||||
import {
|
||||
isValidDocumentCode,
|
||||
isValidDocumentNotes,
|
||||
isValidImageDescription,
|
||||
isValidImageUrl,
|
||||
parseCsvRecord,
|
||||
SALES_PAYMENT_STATUSES,
|
||||
} from '../shared/sales-fields';
|
||||
import type {
|
||||
CreateSalesPaymentInput,
|
||||
SalesPayment,
|
||||
SalesPaymentAllocationInput,
|
||||
SalesPaymentImageInput,
|
||||
UpdateSalesPaymentInput,
|
||||
} from './sales-payment';
|
||||
import { SalesPaymentsRepository } from './sales-payments.repository';
|
||||
|
||||
export type PaymentAllocationBody = {
|
||||
readonly invoiceId: string;
|
||||
readonly amount: string;
|
||||
};
|
||||
|
||||
export type SalesImageBody = {
|
||||
readonly url: string;
|
||||
readonly description?: string | null;
|
||||
};
|
||||
|
||||
export type ListSalesPaymentsQuery = {
|
||||
readonly code?: string;
|
||||
readonly status?: string;
|
||||
readonly search?: string;
|
||||
readonly page?: number;
|
||||
readonly limit?: number;
|
||||
readonly offset?: number;
|
||||
};
|
||||
|
||||
const CSV_REQUIRED_HEADERS = ['date'] as const;
|
||||
|
||||
@Injectable()
|
||||
export class SalesPaymentsService {
|
||||
constructor(
|
||||
private readonly salesPaymentsRepository: SalesPaymentsRepository,
|
||||
private readonly salesInvoicesService: SalesInvoicesService,
|
||||
) {}
|
||||
|
||||
async list(
|
||||
query: ListSalesPaymentsQuery,
|
||||
): Promise<
|
||||
PaginationResponse<ReturnType<SalesPaymentsService['toListItem']>>
|
||||
> {
|
||||
const page = toListPage(query);
|
||||
const { data, total } = await this.salesPaymentsRepository.list({
|
||||
code: query.code,
|
||||
status: query.status,
|
||||
search: query.search,
|
||||
limit: page.limit,
|
||||
offset: page.offset,
|
||||
});
|
||||
return {
|
||||
data: data.map((item) => this.toListItem(item)),
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
async findById(
|
||||
id: string,
|
||||
): Promise<ReturnType<SalesPaymentsService['toDetail']>> {
|
||||
const found = await this.salesPaymentsRepository.findById(id);
|
||||
if (!found) {
|
||||
throw new NotFoundException('Sales payment not found');
|
||||
}
|
||||
return this.toDetail(found);
|
||||
}
|
||||
|
||||
async create(input: {
|
||||
code?: string;
|
||||
date: string;
|
||||
notes?: string | null;
|
||||
invoices: PaymentAllocationBody[];
|
||||
images?: SalesImageBody[];
|
||||
status?: string;
|
||||
userId: string;
|
||||
}): Promise<ReturnType<SalesPaymentsService['toDetail']>> {
|
||||
const created = await this.salesPaymentsRepository.create(
|
||||
await this.toCreateInput(input),
|
||||
);
|
||||
return this.toDetail(created);
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
input: {
|
||||
code?: string;
|
||||
date?: string;
|
||||
notes?: string | null;
|
||||
invoices?: PaymentAllocationBody[];
|
||||
images?: SalesImageBody[];
|
||||
status?: unknown;
|
||||
userId: string;
|
||||
},
|
||||
): Promise<ReturnType<SalesPaymentsService['toDetail']>> {
|
||||
if (input.status !== undefined) {
|
||||
throw new BadRequestException('status cannot be updated via PATCH');
|
||||
}
|
||||
const payload: UpdateSalesPaymentInput = {
|
||||
code: input.code !== undefined ? this.assertCode(input.code) : undefined,
|
||||
date: input.date !== undefined ? this.assertDate(input.date) : undefined,
|
||||
notes:
|
||||
input.notes !== undefined ? this.assertNotes(input.notes) : undefined,
|
||||
invoices:
|
||||
input.invoices !== undefined
|
||||
? await this.assertAllocations(input.invoices)
|
||||
: undefined,
|
||||
images:
|
||||
input.images !== undefined
|
||||
? input.images.map((image) => this.assertImage(image))
|
||||
: undefined,
|
||||
userId: input.userId,
|
||||
};
|
||||
const updated = await this.salesPaymentsRepository.update(id, payload);
|
||||
return this.toDetail(updated);
|
||||
}
|
||||
|
||||
async updateStatus(
|
||||
id: string,
|
||||
statusRaw: string,
|
||||
userId: string,
|
||||
): Promise<ReturnType<SalesPaymentsService['toDetail']>> {
|
||||
const next = this.assertStatus(statusRaw);
|
||||
const existing = await this.salesPaymentsRepository.findById(id);
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Sales payment not found');
|
||||
}
|
||||
const becomingApproved =
|
||||
next.value === 'approved' && existing.status.value !== 'approved';
|
||||
const leavingApproved =
|
||||
existing.status.value === 'approved' && next.value !== 'approved';
|
||||
if (becomingApproved) {
|
||||
await this.assertAllocationsFit(existing.invoices);
|
||||
}
|
||||
const updated = await this.salesPaymentsRepository.updateStatus(
|
||||
id,
|
||||
next,
|
||||
userId,
|
||||
);
|
||||
if (becomingApproved || leavingApproved) {
|
||||
const invoiceIds = [
|
||||
...new Set(updated.invoices.map((line) => line.invoiceId)),
|
||||
];
|
||||
for (const invoiceId of invoiceIds) {
|
||||
await this.salesInvoicesService.applyPaymentEffects(invoiceId, userId);
|
||||
}
|
||||
}
|
||||
return this.toDetail(updated);
|
||||
}
|
||||
|
||||
async bulkUpdateStatus(
|
||||
ids: string[],
|
||||
statusRaw: string,
|
||||
userId: string,
|
||||
): Promise<{ updated: number }> {
|
||||
const status = this.assertStatus(statusRaw);
|
||||
const updated = await this.salesPaymentsRepository.bulkUpdateStatus(
|
||||
ids,
|
||||
status,
|
||||
userId,
|
||||
);
|
||||
return { updated };
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.salesPaymentsRepository.delete(id);
|
||||
}
|
||||
|
||||
async bulkDelete(ids: string[]): Promise<{ deleted: number }> {
|
||||
const deleted = await this.salesPaymentsRepository.bulkDelete(ids);
|
||||
return { deleted };
|
||||
}
|
||||
|
||||
async importCsv(csv: string, userId: string): Promise<{ imported: number }> {
|
||||
const rawLines = csv.split(/\r?\n/);
|
||||
const filled = rawLines
|
||||
.map((line, index) => ({ line: line.trim(), lineNo: index + 1 }))
|
||||
.filter((entry) => entry.line.length > 0);
|
||||
if (filled.length === 0) {
|
||||
throw new BadRequestException('CSV is empty');
|
||||
}
|
||||
if (filled.length > 501) {
|
||||
throw new BadRequestException('CSV exceeds maximum of 500 data rows');
|
||||
}
|
||||
const header = parseCsvRecord(filled[0].line).map((h) =>
|
||||
h.trim().toLowerCase(),
|
||||
);
|
||||
const missing = CSV_REQUIRED_HEADERS.filter(
|
||||
(h) => header.indexOf(h.toLowerCase()) < 0,
|
||||
);
|
||||
if (missing.length > 0) {
|
||||
throw new BadRequestException('CSV must include required headers');
|
||||
}
|
||||
const idx = (key: string) => header.indexOf(key.toLowerCase());
|
||||
const errors: string[] = [];
|
||||
const rows: CreateSalesPaymentInput[] = [];
|
||||
for (let i = 1; i < filled.length; i++) {
|
||||
const cols = parseCsvRecord(filled[i].line);
|
||||
const rowNum = filled[i].lineNo;
|
||||
try {
|
||||
rows.push(
|
||||
await this.toCreateInput({
|
||||
code: idx('code') >= 0 ? cols[idx('code')] : undefined,
|
||||
date: cols[idx('date')] ?? '',
|
||||
notes: idx('notes') >= 0 ? cols[idx('notes')] : null,
|
||||
invoices: [],
|
||||
images: [],
|
||||
status: idx('status') >= 0 ? cols[idx('status')] : undefined,
|
||||
userId,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
const reason =
|
||||
error instanceof BadRequestException ? error.message : 'invalid data';
|
||||
errors.push(`row ${rowNum}: ${reason}`);
|
||||
}
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
throw new BadRequestException({
|
||||
message: 'CSV validation failed',
|
||||
errors,
|
||||
});
|
||||
}
|
||||
await this.salesPaymentsRepository.createMany(rows);
|
||||
return { imported: rows.length };
|
||||
}
|
||||
|
||||
toListItem(item: SalesPayment) {
|
||||
return {
|
||||
id: item.id,
|
||||
code: item.code,
|
||||
date: item.date.value,
|
||||
notes: item.notes,
|
||||
status: item.status.value,
|
||||
createdAt: item.createdAt.value,
|
||||
updatedAt: item.updatedAt.value,
|
||||
createdBy: item.createdBy,
|
||||
updatedBy: item.updatedBy,
|
||||
};
|
||||
}
|
||||
|
||||
toDetail(item: SalesPayment) {
|
||||
return {
|
||||
...this.toListItem(item),
|
||||
invoices: item.invoices.map((line) => ({
|
||||
id: line.id,
|
||||
invoiceId: line.invoiceId,
|
||||
amount: line.amount.value,
|
||||
})),
|
||||
images: item.images.map((image) => ({
|
||||
id: image.id,
|
||||
url: image.url,
|
||||
description: image.description,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
private async toCreateInput(input: {
|
||||
code?: string;
|
||||
date: string;
|
||||
notes?: string | null;
|
||||
invoices: PaymentAllocationBody[];
|
||||
images?: SalesImageBody[];
|
||||
status?: string;
|
||||
userId: string;
|
||||
}): Promise<CreateSalesPaymentInput> {
|
||||
return {
|
||||
code:
|
||||
input.code !== undefined && input.code !== ''
|
||||
? this.assertCode(input.code)
|
||||
: undefined,
|
||||
date: this.assertDate(input.date),
|
||||
notes: this.assertNotes(input.notes ?? null),
|
||||
invoices: await this.assertAllocations(input.invoices),
|
||||
images: (input.images ?? []).map((image) => this.assertImage(image)),
|
||||
status: input.status
|
||||
? this.assertStatus(input.status)
|
||||
: Status.create('draft', SALES_PAYMENT_STATUSES),
|
||||
userId: input.userId,
|
||||
};
|
||||
}
|
||||
|
||||
private async assertAllocationsFit(
|
||||
allocations: readonly SalesPayment['invoices'][number][],
|
||||
): Promise<void> {
|
||||
const extras = new Map<string, Decimal>();
|
||||
for (const line of allocations) {
|
||||
const current = extras.get(line.invoiceId) ?? Decimal.create('0');
|
||||
extras.set(line.invoiceId, current.add(line.amount));
|
||||
}
|
||||
for (const [invoiceId, extra] of extras) {
|
||||
const totals = await this.salesInvoicesService.getTotals(invoiceId);
|
||||
if (totals.paid.add(extra).compare(totals.total) > 0) {
|
||||
throw new BadRequestException('Payment exceeds invoice total');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async assertAllocations(
|
||||
lines: PaymentAllocationBody[],
|
||||
): Promise<SalesPaymentAllocationInput[]> {
|
||||
if (!Array.isArray(lines)) {
|
||||
throw new BadRequestException(
|
||||
'At least one invoice allocation is required',
|
||||
);
|
||||
}
|
||||
const result: SalesPaymentAllocationInput[] = [];
|
||||
for (const line of lines) {
|
||||
await this.salesInvoicesService.findById(line.invoiceId);
|
||||
const amount = this.parseDecimal(line.amount);
|
||||
if (!amount.isPositive()) {
|
||||
throw new BadRequestException('Invalid amount');
|
||||
}
|
||||
result.push({ invoiceId: line.invoiceId, amount });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private assertImage(image: SalesImageBody): SalesPaymentImageInput {
|
||||
if (!isValidImageUrl(image.url)) {
|
||||
throw new BadRequestException('Invalid image URL');
|
||||
}
|
||||
const description = image.description ?? null;
|
||||
if (description !== null && !isValidImageDescription(description)) {
|
||||
throw new BadRequestException('Invalid image description');
|
||||
}
|
||||
return { url: image.url, description };
|
||||
}
|
||||
|
||||
private assertCode(raw: string): string {
|
||||
const code = raw.trim();
|
||||
if (!isValidDocumentCode(code)) {
|
||||
throw new BadRequestException('Invalid document code');
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
private assertDate(raw: string): DateTime {
|
||||
try {
|
||||
return DateTime.create(raw);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidDateTimeError) {
|
||||
throw new BadRequestException('Invalid date');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private assertNotes(raw: string | null): string | null {
|
||||
if (raw === null || raw === '') {
|
||||
return null;
|
||||
}
|
||||
if (!isValidDocumentNotes(raw)) {
|
||||
throw new BadRequestException('Invalid notes');
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
private assertStatus(raw: string): Status {
|
||||
try {
|
||||
return Status.create(raw, SALES_PAYMENT_STATUSES);
|
||||
} catch {
|
||||
throw new BadRequestException('Invalid status');
|
||||
}
|
||||
}
|
||||
|
||||
private parseDecimal(raw: string): Decimal {
|
||||
try {
|
||||
return Decimal.create(raw);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidDecimalError) {
|
||||
throw new BadRequestException('Invalid decimal');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayNotEmpty,
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Matches,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { PaginationQueryDto } from '../../../../common/http/response';
|
||||
import {
|
||||
DOCUMENT_ADDRESS_MAX_LENGTH,
|
||||
DOCUMENT_CODE_MAX_LENGTH,
|
||||
DOCUMENT_CODE_PATTERN,
|
||||
DOCUMENT_NOTES_MAX_LENGTH,
|
||||
IMAGE_DESCRIPTION_MAX_LENGTH,
|
||||
IMAGE_URL_MAX_LENGTH,
|
||||
SALES_REQUEST_STATUSES,
|
||||
} from '../../shared/sales-fields';
|
||||
|
||||
export class SalesLineDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID('4')
|
||||
productId!: string;
|
||||
|
||||
@ApiProperty({ example: '2.0000' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
quantity!: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '12500.0000' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
price?: string;
|
||||
}
|
||||
|
||||
export class SalesImageDto {
|
||||
@ApiProperty({ example: 'https://cdn.example.com/a.png' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(IMAGE_URL_MAX_LENGTH)
|
||||
url!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(IMAGE_DESCRIPTION_MAX_LENGTH)
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export class CreateSalesRequestDto {
|
||||
@ApiPropertyOptional({ example: 'SR-20260824-0001' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(DOCUMENT_CODE_MAX_LENGTH)
|
||||
@Matches(DOCUMENT_CODE_PATTERN)
|
||||
code?: string;
|
||||
|
||||
@ApiProperty({ example: '2026-08-24T10:00:00+07:00' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
date!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID('4')
|
||||
salesPersonId!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID('4')
|
||||
branchId!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID('4')
|
||||
divisionId!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID('4')
|
||||
customerId!: string;
|
||||
|
||||
@ApiProperty({ example: 'Jl Sudirman 1' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@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()
|
||||
@MaxLength(DOCUMENT_NOTES_MAX_LENGTH)
|
||||
notes?: string;
|
||||
|
||||
@ApiProperty({ type: [SalesLineDto] })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => SalesLineDto)
|
||||
products!: SalesLineDto[];
|
||||
|
||||
@ApiPropertyOptional({ type: [SalesImageDto] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => SalesImageDto)
|
||||
images?: SalesImageDto[];
|
||||
|
||||
@ApiPropertyOptional({ enum: SALES_REQUEST_STATUSES })
|
||||
@IsOptional()
|
||||
@IsIn([...SALES_REQUEST_STATUSES])
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export class UpdateSalesRequestDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(DOCUMENT_CODE_MAX_LENGTH)
|
||||
@Matches(DOCUMENT_CODE_PATTERN)
|
||||
code?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
date?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
salesPersonId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
branchId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
divisionId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@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()
|
||||
notes?: string | null;
|
||||
|
||||
@ApiPropertyOptional({ type: [SalesLineDto] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => SalesLineDto)
|
||||
products?: SalesLineDto[];
|
||||
|
||||
@ApiPropertyOptional({ type: [SalesImageDto] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => SalesImageDto)
|
||||
images?: SalesImageDto[];
|
||||
}
|
||||
|
||||
export class UpdateSalesRequestStatusDto {
|
||||
@ApiProperty({ enum: SALES_REQUEST_STATUSES })
|
||||
@IsIn([...SALES_REQUEST_STATUSES])
|
||||
status!: string;
|
||||
}
|
||||
|
||||
export class BulkIdsDto {
|
||||
@ApiProperty({ type: [String], format: 'uuid' })
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsUUID('4', { each: true })
|
||||
ids!: string[];
|
||||
}
|
||||
|
||||
export class BulkStatusDto {
|
||||
@ApiProperty({ type: [String], format: 'uuid' })
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsUUID('4', { each: true })
|
||||
ids!: string[];
|
||||
|
||||
@ApiProperty({ enum: SALES_REQUEST_STATUSES })
|
||||
@IsIn([...SALES_REQUEST_STATUSES])
|
||||
status!: string;
|
||||
}
|
||||
|
||||
export class ListSalesRequestsQueryDto extends PaginationQueryDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
code?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: SALES_REQUEST_STATUSES })
|
||||
@IsOptional()
|
||||
@IsIn([...SALES_REQUEST_STATUSES])
|
||||
status?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
customerId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
salesPersonId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
branchId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID('4')
|
||||
divisionId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export class SalesRequestDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
id!: string;
|
||||
@ApiProperty()
|
||||
code!: string;
|
||||
@ApiProperty()
|
||||
date!: number;
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
salesPersonId!: string;
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
branchId!: string;
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
divisionId!: string;
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
customerId!: string;
|
||||
@ApiProperty()
|
||||
address!: string;
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
latitude!: number | null;
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
longitude!: number | null;
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
notes!: string | null;
|
||||
@ApiProperty({ enum: SALES_REQUEST_STATUSES })
|
||||
status!: string;
|
||||
@ApiProperty()
|
||||
createdAt!: number;
|
||||
@ApiProperty()
|
||||
updatedAt!: number;
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
createdBy!: string;
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
updatedBy!: string;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
|
||||
export type SalesRequestLine = {
|
||||
readonly id: string;
|
||||
readonly productId: string;
|
||||
readonly quantity: Decimal;
|
||||
readonly price: Decimal;
|
||||
};
|
||||
|
||||
export type SalesRequestImage = {
|
||||
readonly id: string;
|
||||
readonly url: string;
|
||||
readonly description: string | null;
|
||||
};
|
||||
|
||||
export type SalesRequest = {
|
||||
readonly id: string;
|
||||
readonly code: string;
|
||||
readonly date: DateTime;
|
||||
readonly salesPersonId: string;
|
||||
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 SalesRequestLine[];
|
||||
readonly images: readonly SalesRequestImage[];
|
||||
readonly status: Status;
|
||||
readonly createdAt: DateTime;
|
||||
readonly updatedAt: DateTime;
|
||||
readonly createdBy: string;
|
||||
readonly updatedBy: string;
|
||||
};
|
||||
|
||||
export type SalesRequestLineInput = {
|
||||
readonly productId: string;
|
||||
readonly quantity: Decimal;
|
||||
readonly price: Decimal;
|
||||
};
|
||||
|
||||
export type SalesRequestImageInput = {
|
||||
readonly url: string;
|
||||
readonly description?: string | null;
|
||||
};
|
||||
|
||||
export type CreateSalesRequestInput = {
|
||||
readonly code?: string;
|
||||
readonly date: DateTime;
|
||||
readonly salesPersonId: string;
|
||||
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 SalesRequestLineInput[];
|
||||
readonly images?: readonly SalesRequestImageInput[];
|
||||
readonly status?: Status;
|
||||
readonly userId: string;
|
||||
};
|
||||
|
||||
export type UpdateSalesRequestInput = {
|
||||
readonly code?: string;
|
||||
readonly date?: DateTime;
|
||||
readonly salesPersonId?: string;
|
||||
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 SalesRequestLineInput[];
|
||||
readonly images?: readonly SalesRequestImageInput[];
|
||||
readonly userId: string;
|
||||
};
|
||||
|
||||
export type ListSalesRequestsFilters = {
|
||||
readonly code?: string;
|
||||
readonly status?: string;
|
||||
readonly customerId?: string;
|
||||
readonly salesPersonId?: string;
|
||||
readonly branchId?: string;
|
||||
readonly divisionId?: string;
|
||||
readonly search?: string;
|
||||
readonly limit: number;
|
||||
readonly offset: number;
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiForbiddenResponse,
|
||||
ApiNotFoundResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
ApiUnauthorizedResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { RequirePrivilege } from '../../../common/decorators/require-privilege.decorator';
|
||||
import {
|
||||
Pagination,
|
||||
type PaginationResponse,
|
||||
PaginationMetaDto,
|
||||
} from '../../../common/http/response';
|
||||
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
|
||||
import {
|
||||
SalesRequestDto,
|
||||
ListSalesRequestsQueryDto,
|
||||
} from './dto/sales-request.dto';
|
||||
import { SalesRequestsService } from './sales-requests.service';
|
||||
|
||||
export const SALES_REQUEST_PRIVILEGE_KEY = 'SALES.REQUEST';
|
||||
|
||||
@ApiTags('sales-requests')
|
||||
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||
@Controller('sales-requests')
|
||||
export class SalesRequestsReadController {
|
||||
constructor(private readonly salesRequestsService: SalesRequestsService) {}
|
||||
|
||||
@Get()
|
||||
@Pagination()
|
||||
@RequirePrivilege(SALES_REQUEST_PRIVILEGE_KEY, 'view')
|
||||
@ApiOperation({ summary: 'List sales requests' })
|
||||
@ApiOkResponse({
|
||||
schema: {
|
||||
properties: {
|
||||
data: {
|
||||
type: 'array',
|
||||
items: { $ref: '#/components/schemas/SalesRequestDto' },
|
||||
},
|
||||
meta: { $ref: '#/components/schemas/PaginationMetaDto' },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
list(
|
||||
@Query() query: ListSalesRequestsQueryDto,
|
||||
): Promise<PaginationResponse<SalesRequestDto>> {
|
||||
return this.salesRequestsService.list(query);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePrivilege(SALES_REQUEST_PRIVILEGE_KEY, 'view')
|
||||
@ApiOperation({ summary: 'Get sales request detail' })
|
||||
@ApiOkResponse({ type: SalesRequestDto })
|
||||
@ApiNotFoundResponse()
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string): Promise<SalesRequestDto> {
|
||||
return this.salesRequestsService.findById(id);
|
||||
}
|
||||
}
|
||||
|
||||
void PaginationMetaDto;
|
||||
@@ -0,0 +1,203 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
HttpCode,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
ApiConsumes,
|
||||
ApiCreatedResponse,
|
||||
ApiForbiddenResponse,
|
||||
ApiNoContentResponse,
|
||||
ApiNotFoundResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
ApiUnauthorizedResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { CurrentUser } from '../../../common/decorators/current-user.decorator';
|
||||
import { RequirePrivilege } from '../../../common/decorators/require-privilege.decorator';
|
||||
import { BEARER_AUTH_NAME } from '../../../common/swagger/setup-swagger';
|
||||
import { isAllowedCsvUpload } from '../shared/sales-fields';
|
||||
import { SALES_REQUEST_PRIVILEGE_KEY } from './sales-requests-read.controller';
|
||||
import { SalesRequestsService } from './sales-requests.service';
|
||||
import {
|
||||
BulkIdsDto,
|
||||
BulkStatusDto,
|
||||
CreateSalesRequestDto,
|
||||
SalesRequestDto,
|
||||
UpdateSalesRequestDto,
|
||||
UpdateSalesRequestStatusDto,
|
||||
} from './dto/sales-request.dto';
|
||||
|
||||
@ApiTags('sales-requests')
|
||||
@ApiBearerAuth(BEARER_AUTH_NAME)
|
||||
@Controller('sales-requests')
|
||||
export class SalesRequestsWriteController {
|
||||
constructor(private readonly salesRequestsService: SalesRequestsService) {}
|
||||
|
||||
@Post('import')
|
||||
@RequirePrivilege(SALES_REQUEST_PRIVILEGE_KEY, 'import')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
limits: { fileSize: 1_048_576 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (!isAllowedCsvUpload(file)) {
|
||||
cb(new BadRequestException('Only CSV files are allowed'), false);
|
||||
return;
|
||||
}
|
||||
cb(null, true);
|
||||
},
|
||||
}),
|
||||
)
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
file: { type: 'string', format: 'binary' },
|
||||
},
|
||||
required: ['file'],
|
||||
},
|
||||
})
|
||||
@ApiOperation({ summary: 'Import sales requests from CSV' })
|
||||
@ApiOkResponse({
|
||||
schema: { properties: { imported: { type: 'number' } } },
|
||||
})
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
importCsv(
|
||||
@UploadedFile() file: { buffer?: Buffer } | undefined,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<{ imported: number }> {
|
||||
const csv = file?.buffer?.toString('utf8') ?? '';
|
||||
return this.salesRequestsService.importCsv(csv, userId);
|
||||
}
|
||||
|
||||
@Post('bulk-delete')
|
||||
@HttpCode(200)
|
||||
@RequirePrivilege(SALES_REQUEST_PRIVILEGE_KEY, 'delete')
|
||||
@ApiOperation({ summary: 'Bulk delete sales requests' })
|
||||
@ApiOkResponse({
|
||||
schema: { properties: { deleted: { type: 'number' } } },
|
||||
})
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
bulkDelete(@Body() dto: BulkIdsDto): Promise<{ deleted: number }> {
|
||||
return this.salesRequestsService.bulkDelete(dto.ids);
|
||||
}
|
||||
|
||||
@Post('bulk-status')
|
||||
@HttpCode(200)
|
||||
@RequirePrivilege(SALES_REQUEST_PRIVILEGE_KEY, 'update')
|
||||
@ApiOperation({ summary: 'Bulk update sales request status' })
|
||||
@ApiOkResponse({
|
||||
schema: { properties: { updated: { type: 'number' } } },
|
||||
})
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
bulkStatus(
|
||||
@Body() dto: BulkStatusDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<{ updated: number }> {
|
||||
return this.salesRequestsService.bulkUpdateStatus(
|
||||
dto.ids,
|
||||
dto.status,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePrivilege(SALES_REQUEST_PRIVILEGE_KEY, 'create')
|
||||
@ApiOperation({ summary: 'Create sales request' })
|
||||
@ApiCreatedResponse({ type: SalesRequestDto })
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
create(
|
||||
@Body() dto: CreateSalesRequestDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<SalesRequestDto> {
|
||||
return this.salesRequestsService.create({
|
||||
code: dto.code,
|
||||
date: dto.date,
|
||||
salesPersonId: dto.salesPersonId,
|
||||
branchId: dto.branchId,
|
||||
divisionId: dto.divisionId,
|
||||
customerId: dto.customerId,
|
||||
address: dto.address,
|
||||
latitude: dto.latitude,
|
||||
longitude: dto.longitude,
|
||||
notes: dto.notes,
|
||||
products: dto.products,
|
||||
images: dto.images,
|
||||
status: dto.status,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
@Patch(':id/status')
|
||||
@RequirePrivilege(SALES_REQUEST_PRIVILEGE_KEY, 'update')
|
||||
@ApiOperation({ summary: 'Update sales request status' })
|
||||
@ApiOkResponse({ type: SalesRequestDto })
|
||||
@ApiNotFoundResponse()
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
updateStatus(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateSalesRequestStatusDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<SalesRequestDto> {
|
||||
return this.salesRequestsService.updateStatus(id, dto.status, userId);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RequirePrivilege(SALES_REQUEST_PRIVILEGE_KEY, 'update')
|
||||
@ApiOperation({ summary: 'Update sales request (not status)' })
|
||||
@ApiOkResponse({ type: SalesRequestDto })
|
||||
@ApiNotFoundResponse()
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
update(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateSalesRequestDto,
|
||||
@CurrentUser('id') userId: string,
|
||||
): Promise<SalesRequestDto> {
|
||||
return this.salesRequestsService.update(id, {
|
||||
code: dto.code,
|
||||
date: dto.date,
|
||||
salesPersonId: dto.salesPersonId,
|
||||
branchId: dto.branchId,
|
||||
divisionId: dto.divisionId,
|
||||
customerId: dto.customerId,
|
||||
address: dto.address,
|
||||
latitude: dto.latitude,
|
||||
longitude: dto.longitude,
|
||||
notes: dto.notes,
|
||||
products: dto.products,
|
||||
images: dto.images,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(204)
|
||||
@RequirePrivilege(SALES_REQUEST_PRIVILEGE_KEY, 'delete')
|
||||
@ApiOperation({ summary: 'Delete sales request' })
|
||||
@ApiNoContentResponse()
|
||||
@ApiNotFoundResponse()
|
||||
@ApiUnauthorizedResponse()
|
||||
@ApiForbiddenResponse()
|
||||
async delete(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
|
||||
await this.salesRequestsService.delete(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BranchesModule } from '../../configuration/branches/branches.module';
|
||||
import { CustomersModule } from '../../configuration/customers/customers.module';
|
||||
import { DivisionsModule } from '../../configuration/divisions/divisions.module';
|
||||
import { EmployeesModule } from '../../configuration/employees/employees.module';
|
||||
import { ProductsModule } from '../../configuration/products/products.module';
|
||||
import { DocumentCodeService } from '../shared/document-code.service';
|
||||
import { SalesRequestsReadController } from './sales-requests-read.controller';
|
||||
import { SalesRequestsWriteController } from './sales-requests-write.controller';
|
||||
import { SalesRequestsRepository } from './sales-requests.repository';
|
||||
import { SalesRequestsService } from './sales-requests.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
EmployeesModule,
|
||||
BranchesModule,
|
||||
DivisionsModule,
|
||||
CustomersModule,
|
||||
ProductsModule,
|
||||
],
|
||||
controllers: [SalesRequestsReadController, SalesRequestsWriteController],
|
||||
providers: [
|
||||
DocumentCodeService,
|
||||
SalesRequestsRepository,
|
||||
SalesRequestsService,
|
||||
],
|
||||
exports: [SalesRequestsService, DocumentCodeService],
|
||||
})
|
||||
export class SalesRequestsModule {}
|
||||
@@ -0,0 +1,421 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { and, asc, count, eq, ilike, inArray, or, SQL } from 'drizzle-orm';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
import { DRIZZLE, type DrizzleDB } from '../../../database/database.module';
|
||||
import {
|
||||
salesRequestImages,
|
||||
salesRequestProducts,
|
||||
salesRequests,
|
||||
type SalesRequestImageRow,
|
||||
type SalesRequestProductRow,
|
||||
type SalesRequestRow,
|
||||
} from '../../../database/sales-requests-table';
|
||||
import { DocumentCodeService } from '../shared/document-code.service';
|
||||
import { DOCUMENT_PREFIXES } from '../shared/document-prefixes';
|
||||
import { SALES_REQUEST_STATUSES } from '../shared/sales-fields';
|
||||
import type {
|
||||
CreateSalesRequestInput,
|
||||
ListSalesRequestsFilters,
|
||||
SalesRequest,
|
||||
SalesRequestImageInput,
|
||||
SalesRequestLineInput,
|
||||
UpdateSalesRequestInput,
|
||||
} from './sales-request';
|
||||
|
||||
type QueryExecutor = Pick<DrizzleDB, 'select' | 'insert' | 'delete'>;
|
||||
|
||||
@Injectable()
|
||||
export class SalesRequestsRepository {
|
||||
constructor(
|
||||
@Inject(DRIZZLE) private readonly db: DrizzleDB,
|
||||
private readonly documentCodeService: DocumentCodeService,
|
||||
) {}
|
||||
|
||||
async list(
|
||||
filters: ListSalesRequestsFilters,
|
||||
): Promise<{ data: SalesRequest[]; total: number }> {
|
||||
const where = this.buildListWhere(filters);
|
||||
const totalRows = await this.db
|
||||
.select({ total: count() })
|
||||
.from(salesRequests)
|
||||
.where(where);
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(salesRequests)
|
||||
.where(where)
|
||||
.orderBy(asc(salesRequests.code))
|
||||
.limit(filters.limit)
|
||||
.offset(filters.offset);
|
||||
return {
|
||||
data: rows.map((row) => this.toDomain(row, [], [])),
|
||||
total: Number(totalRows[0]?.total ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<SalesRequest | null> {
|
||||
const rows: SalesRequestRow[] = await this.db
|
||||
.select()
|
||||
.from(salesRequests)
|
||||
.where(eq(salesRequests.id, id))
|
||||
.limit(1);
|
||||
const row = rows[0];
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
const products = await this.selectProducts(this.db, id);
|
||||
const images = await this.selectImages(this.db, id);
|
||||
return this.toDomain(row, products, images);
|
||||
}
|
||||
|
||||
async create(input: CreateSalesRequestInput): Promise<SalesRequest> {
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const status =
|
||||
input.status ?? Status.create('draft', SALES_REQUEST_STATUSES);
|
||||
try {
|
||||
return await this.db.transaction(async (tx) => {
|
||||
const code =
|
||||
input.code ??
|
||||
(await this.documentCodeService.nextCode(
|
||||
DOCUMENT_PREFIXES.salesRequest,
|
||||
input.date,
|
||||
tx,
|
||||
));
|
||||
const inserted = await tx
|
||||
.insert(salesRequests)
|
||||
.values(this.toInsertValues(input, code, status, now, input.userId))
|
||||
.returning();
|
||||
const row = inserted[0];
|
||||
await this.replaceProducts(tx, row.id, input.products);
|
||||
await this.replaceImages(tx, row.id, input.images ?? []);
|
||||
const products = await this.selectProducts(tx, row.id);
|
||||
const images = await this.selectImages(tx, row.id);
|
||||
return this.toDomain(row, products, images);
|
||||
});
|
||||
} catch (error) {
|
||||
this.rethrowConstraintViolation(error);
|
||||
}
|
||||
}
|
||||
|
||||
async createMany(inputs: CreateSalesRequestInput[]): Promise<number> {
|
||||
if (inputs.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
for (const input of inputs) {
|
||||
await this.create(input);
|
||||
}
|
||||
return inputs.length;
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
input: UpdateSalesRequestInput,
|
||||
): Promise<SalesRequest> {
|
||||
const existing = await this.findById(id);
|
||||
if (!existing) {
|
||||
throw new NotFoundException('Sales request not found');
|
||||
}
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
try {
|
||||
return await this.db.transaction(async (tx) => {
|
||||
const updated = await tx
|
||||
.update(salesRequests)
|
||||
.set({
|
||||
code: input.code ?? existing.code,
|
||||
date: input.date?.value ?? existing.date.value,
|
||||
salesPersonId: input.salesPersonId ?? existing.salesPersonId,
|
||||
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,
|
||||
})
|
||||
.where(eq(salesRequests.id, id))
|
||||
.returning();
|
||||
const row = updated[0];
|
||||
if (!row) {
|
||||
throw new NotFoundException('Sales request not found');
|
||||
}
|
||||
if (input.products !== undefined) {
|
||||
await this.replaceProducts(tx, id, input.products);
|
||||
}
|
||||
if (input.images !== undefined) {
|
||||
await this.replaceImages(tx, id, input.images);
|
||||
}
|
||||
const products = await this.selectProducts(tx, id);
|
||||
const images = await this.selectImages(tx, id);
|
||||
return this.toDomain(row, products, images);
|
||||
});
|
||||
} catch (error) {
|
||||
this.rethrowConstraintViolation(error);
|
||||
}
|
||||
}
|
||||
|
||||
async updateStatus(
|
||||
id: string,
|
||||
status: Status,
|
||||
userId: string,
|
||||
): Promise<SalesRequest> {
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const updated = await this.db
|
||||
.update(salesRequests)
|
||||
.set({
|
||||
status: status.value,
|
||||
updatedAt: now.value,
|
||||
updatedBy: userId,
|
||||
})
|
||||
.where(eq(salesRequests.id, id))
|
||||
.returning();
|
||||
const row = updated[0];
|
||||
if (!row) {
|
||||
throw new NotFoundException('Sales request not found');
|
||||
}
|
||||
const products = await this.selectProducts(this.db, id);
|
||||
const images = await this.selectImages(this.db, id);
|
||||
return this.toDomain(row, products, images);
|
||||
}
|
||||
|
||||
async bulkUpdateStatus(
|
||||
ids: string[],
|
||||
status: Status,
|
||||
userId: string,
|
||||
): Promise<number> {
|
||||
if (ids.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const now = DateTime.fromUnixMs(Date.now());
|
||||
const rows = await this.db
|
||||
.update(salesRequests)
|
||||
.set({
|
||||
status: status.value,
|
||||
updatedAt: now.value,
|
||||
updatedBy: userId,
|
||||
})
|
||||
.where(inArray(salesRequests.id, ids))
|
||||
.returning({ id: salesRequests.id });
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
const deleted = await this.db
|
||||
.delete(salesRequests)
|
||||
.where(eq(salesRequests.id, id))
|
||||
.returning({ id: salesRequests.id });
|
||||
if (deleted.length === 0) {
|
||||
throw new NotFoundException('Sales request not found');
|
||||
}
|
||||
}
|
||||
|
||||
async bulkDelete(ids: string[]): Promise<number> {
|
||||
if (ids.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
const deleted = await this.db
|
||||
.delete(salesRequests)
|
||||
.where(inArray(salesRequests.id, ids))
|
||||
.returning({ id: salesRequests.id });
|
||||
return deleted.length;
|
||||
}
|
||||
|
||||
private async selectProducts(
|
||||
executor: QueryExecutor,
|
||||
salesRequestId: string,
|
||||
): Promise<SalesRequestProductRow[]> {
|
||||
return executor
|
||||
.select()
|
||||
.from(salesRequestProducts)
|
||||
.where(eq(salesRequestProducts.salesRequestId, salesRequestId));
|
||||
}
|
||||
|
||||
private async selectImages(
|
||||
executor: QueryExecutor,
|
||||
salesRequestId: string,
|
||||
): Promise<SalesRequestImageRow[]> {
|
||||
return executor
|
||||
.select()
|
||||
.from(salesRequestImages)
|
||||
.where(eq(salesRequestImages.salesRequestId, salesRequestId));
|
||||
}
|
||||
|
||||
private async replaceProducts(
|
||||
executor: QueryExecutor,
|
||||
salesRequestId: string,
|
||||
products: readonly SalesRequestLineInput[],
|
||||
): Promise<void> {
|
||||
await executor
|
||||
.delete(salesRequestProducts)
|
||||
.where(eq(salesRequestProducts.salesRequestId, salesRequestId));
|
||||
if (products.length === 0) {
|
||||
return;
|
||||
}
|
||||
await executor.insert(salesRequestProducts).values(
|
||||
products.map((line) => ({
|
||||
salesRequestId,
|
||||
productId: line.productId,
|
||||
quantity: line.quantity.value,
|
||||
price: line.price.value,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
private async replaceImages(
|
||||
executor: QueryExecutor,
|
||||
salesRequestId: string,
|
||||
images: readonly SalesRequestImageInput[],
|
||||
): Promise<void> {
|
||||
await executor
|
||||
.delete(salesRequestImages)
|
||||
.where(eq(salesRequestImages.salesRequestId, salesRequestId));
|
||||
if (images.length === 0) {
|
||||
return;
|
||||
}
|
||||
await executor.insert(salesRequestImages).values(
|
||||
images.map((image) => ({
|
||||
salesRequestId,
|
||||
url: image.url,
|
||||
description: image.description ?? null,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
private buildListWhere(filters: ListSalesRequestsFilters): SQL | undefined {
|
||||
const parts: SQL[] = [];
|
||||
if (filters.code) {
|
||||
parts.push(ilike(salesRequests.code, `%${filters.code}%`));
|
||||
}
|
||||
if (filters.status) {
|
||||
parts.push(eq(salesRequests.status, filters.status));
|
||||
}
|
||||
if (filters.customerId) {
|
||||
parts.push(eq(salesRequests.customerId, filters.customerId));
|
||||
}
|
||||
if (filters.salesPersonId) {
|
||||
parts.push(eq(salesRequests.salesPersonId, filters.salesPersonId));
|
||||
}
|
||||
if (filters.branchId) {
|
||||
parts.push(eq(salesRequests.branchId, filters.branchId));
|
||||
}
|
||||
if (filters.divisionId) {
|
||||
parts.push(eq(salesRequests.divisionId, filters.divisionId));
|
||||
}
|
||||
if (filters.search) {
|
||||
const search = or(
|
||||
ilike(salesRequests.code, `%${filters.search}%`),
|
||||
ilike(salesRequests.address, `%${filters.search}%`),
|
||||
ilike(salesRequests.notes, `%${filters.search}%`),
|
||||
);
|
||||
if (search) {
|
||||
parts.push(search);
|
||||
}
|
||||
}
|
||||
if (parts.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return parts.length === 1 ? parts[0] : and(...parts);
|
||||
}
|
||||
|
||||
private toInsertValues(
|
||||
input: CreateSalesRequestInput,
|
||||
code: string,
|
||||
status: Status,
|
||||
now: DateTime,
|
||||
userId: string,
|
||||
) {
|
||||
return {
|
||||
code,
|
||||
date: input.date.value,
|
||||
salesPersonId: input.salesPersonId,
|
||||
branchId: input.branchId,
|
||||
divisionId: input.divisionId,
|
||||
customerId: input.customerId,
|
||||
address: input.address,
|
||||
latitude: input.latitude ?? null,
|
||||
longitude: input.longitude ?? null,
|
||||
notes: input.notes ?? null,
|
||||
status: status.value,
|
||||
createdAt: now.value,
|
||||
updatedAt: now.value,
|
||||
createdBy: userId,
|
||||
updatedBy: userId,
|
||||
};
|
||||
}
|
||||
|
||||
private toDomain(
|
||||
row: SalesRequestRow,
|
||||
productRows: SalesRequestProductRow[],
|
||||
imageRows: SalesRequestImageRow[],
|
||||
): SalesRequest {
|
||||
return {
|
||||
id: row.id,
|
||||
code: row.code,
|
||||
date: DateTime.fromUnixMs(row.date),
|
||||
salesPersonId: row.salesPersonId,
|
||||
branchId: row.branchId,
|
||||
divisionId: row.divisionId,
|
||||
customerId: row.customerId,
|
||||
address: row.address,
|
||||
latitude: row.latitude,
|
||||
longitude: row.longitude,
|
||||
notes: row.notes,
|
||||
products: productRows.map((line) => ({
|
||||
id: line.id,
|
||||
productId: line.productId,
|
||||
quantity: Decimal.create(line.quantity),
|
||||
price: Decimal.create(line.price),
|
||||
})),
|
||||
images: imageRows.map((image) => ({
|
||||
id: image.id,
|
||||
url: image.url,
|
||||
description: image.description,
|
||||
})),
|
||||
status: Status.create(row.status, SALES_REQUEST_STATUSES),
|
||||
createdAt: DateTime.fromUnixMs(row.createdAt),
|
||||
updatedAt: DateTime.fromUnixMs(row.updatedAt),
|
||||
createdBy: row.createdBy,
|
||||
updatedBy: row.updatedBy,
|
||||
};
|
||||
}
|
||||
|
||||
private rethrowConstraintViolation(error: unknown): never {
|
||||
if (error instanceof NotFoundException) {
|
||||
throw error;
|
||||
}
|
||||
const err = this.unwrapDbError(error);
|
||||
if (err.code === '23505') {
|
||||
throw new ConflictException('Sales request code already exists');
|
||||
}
|
||||
if (err.code === '23503') {
|
||||
throw new ConflictException('Related record was not found');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
private unwrapDbError(error: unknown): { code?: string } {
|
||||
let current: unknown = error;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (!current || typeof current !== 'object') {
|
||||
break;
|
||||
}
|
||||
const obj = current as { code?: string; cause?: unknown };
|
||||
if (obj.code === '23505' || obj.code === '23503') {
|
||||
return { code: obj.code };
|
||||
}
|
||||
current = obj.cause;
|
||||
}
|
||||
return error as { code?: string };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
import { BranchesService } from '../../configuration/branches/branches.service';
|
||||
import { CustomersService } from '../../configuration/customers/customers.service';
|
||||
import { DivisionsService } from '../../configuration/divisions/divisions.service';
|
||||
import { EmployeesService } from '../../configuration/employees/employees.service';
|
||||
import { ProductsService } from '../../configuration/products/products.service';
|
||||
import { SALES_REQUEST_STATUSES } from '../shared/sales-fields';
|
||||
import type { SalesRequest } from './sales-request';
|
||||
import { SalesRequestsRepository } from './sales-requests.repository';
|
||||
import { SalesRequestsService } from './sales-requests.service';
|
||||
|
||||
describe('SalesRequestsService', () => {
|
||||
let service: SalesRequestsService;
|
||||
const repository: jest.Mocked<
|
||||
Pick<
|
||||
SalesRequestsRepository,
|
||||
| 'list'
|
||||
| 'findById'
|
||||
| 'create'
|
||||
| 'createMany'
|
||||
| 'update'
|
||||
| 'updateStatus'
|
||||
| 'bulkUpdateStatus'
|
||||
| 'delete'
|
||||
| 'bulkDelete'
|
||||
>
|
||||
> = {
|
||||
list: jest.fn(),
|
||||
findById: jest.fn(),
|
||||
create: jest.fn(),
|
||||
createMany: jest.fn(),
|
||||
update: jest.fn(),
|
||||
updateStatus: jest.fn(),
|
||||
bulkUpdateStatus: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
bulkDelete: jest.fn(),
|
||||
};
|
||||
const employeesService = { findById: jest.fn() };
|
||||
const branchesService = { findById: jest.fn() };
|
||||
const divisionsService = { findById: jest.fn() };
|
||||
const customersService = { findById: jest.fn() };
|
||||
const productsService = { findById: jest.fn() };
|
||||
|
||||
const now = DateTime.fromUnixMs(1_700_000_000_000);
|
||||
const sample: SalesRequest = {
|
||||
id: 'sr-1',
|
||||
code: 'SR-20260824-0001',
|
||||
date: now,
|
||||
salesPersonId: 'emp-1',
|
||||
branchId: 'br-1',
|
||||
divisionId: 'div-1',
|
||||
customerId: 'cus-1',
|
||||
address: 'Jl Sudirman 1',
|
||||
latitude: -6.2,
|
||||
longitude: 106.8,
|
||||
notes: null,
|
||||
products: [
|
||||
{
|
||||
id: 'line-1',
|
||||
productId: 'prd-1',
|
||||
quantity: Decimal.create('2'),
|
||||
price: Decimal.create('12500'),
|
||||
},
|
||||
],
|
||||
images: [],
|
||||
status: Status.create('draft', SALES_REQUEST_STATUSES),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
createdBy: 'user-1',
|
||||
updatedBy: 'user-1',
|
||||
};
|
||||
|
||||
const createBody = {
|
||||
date: '2026-08-24T10:00:00+07:00',
|
||||
salesPersonId: 'emp-1',
|
||||
branchId: 'br-1',
|
||||
divisionId: 'div-1',
|
||||
customerId: 'cus-1',
|
||||
address: 'Jl Sudirman 1',
|
||||
products: [{ productId: 'prd-1', quantity: '2' }],
|
||||
userId: 'user-1',
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
employeesService.findById.mockResolvedValue({ id: 'emp-1' });
|
||||
branchesService.findById.mockResolvedValue({ id: 'br-1' });
|
||||
divisionsService.findById.mockResolvedValue({ id: 'div-1' });
|
||||
customersService.findById.mockResolvedValue({ id: 'cus-1' });
|
||||
productsService.findById.mockResolvedValue({
|
||||
id: 'prd-1',
|
||||
price: '12500.0000',
|
||||
});
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
SalesRequestsService,
|
||||
{ provide: SalesRequestsRepository, useValue: repository },
|
||||
{ provide: EmployeesService, useValue: employeesService },
|
||||
{ provide: BranchesService, useValue: branchesService },
|
||||
{ provide: DivisionsService, useValue: divisionsService },
|
||||
{ provide: CustomersService, useValue: customersService },
|
||||
{ provide: ProductsService, useValue: productsService },
|
||||
],
|
||||
}).compile();
|
||||
service = moduleRef.get(SalesRequestsService);
|
||||
});
|
||||
|
||||
it('create defaults price from the product catalog', async () => {
|
||||
repository.create.mockResolvedValue(sample);
|
||||
const result = await service.create(createBody);
|
||||
expect(result.code).toBe('SR-20260824-0001');
|
||||
expect(repository.create).toHaveBeenCalled();
|
||||
const [arg] = repository.create.mock.calls[0];
|
||||
expect(arg.products[0]?.price.value).toBe('12500.0000');
|
||||
expect(arg.status?.value).toBe('draft');
|
||||
});
|
||||
|
||||
it('create rejects a missing product line list', async () => {
|
||||
await expect(
|
||||
service.create({ ...createBody, products: [] }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('findById throws when missing', async () => {
|
||||
repository.findById.mockResolvedValue(null);
|
||||
await expect(service.findById('missing')).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('update rejects status on PATCH', async () => {
|
||||
await expect(
|
||||
service.update('sr-1', { status: 'approved', userId: 'user-1' }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('updateStatus uses the sales-request allow-list', async () => {
|
||||
repository.updateStatus.mockResolvedValue(sample);
|
||||
await service.updateStatus('sr-1', 'pending', 'user-1');
|
||||
expect(repository.updateStatus).toHaveBeenCalledWith(
|
||||
'sr-1',
|
||||
expect.objectContaining({ value: 'pending' }),
|
||||
'user-1',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,523 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type { PaginationResponse } from '../../../common/http/response';
|
||||
import { toListPage } from '../../../common/http/response';
|
||||
import { InvalidDateTimeError } from '../../../common/value-objects/date-time/invalid-date-time.error';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { Decimal } from '../../../common/value-objects/decimal/decimal';
|
||||
import { InvalidDecimalError } from '../../../common/value-objects/decimal/invalid-decimal.error';
|
||||
import { Status } from '../../../common/value-objects/status/status';
|
||||
import { BranchesService } from '../../configuration/branches/branches.service';
|
||||
import { CustomersService } from '../../configuration/customers/customers.service';
|
||||
import { DivisionsService } from '../../configuration/divisions/divisions.service';
|
||||
import { EmployeesService } from '../../configuration/employees/employees.service';
|
||||
import { ProductsService } from '../../configuration/products/products.service';
|
||||
import {
|
||||
isValidDocumentAddress,
|
||||
isValidDocumentCode,
|
||||
isValidDocumentNotes,
|
||||
isValidImageDescription,
|
||||
isValidImageUrl,
|
||||
isValidLatitude,
|
||||
isValidLongitude,
|
||||
parseCsvRecord,
|
||||
SALES_REQUEST_STATUSES,
|
||||
} from '../shared/sales-fields';
|
||||
import type {
|
||||
CreateSalesRequestInput,
|
||||
SalesRequest,
|
||||
SalesRequestImageInput,
|
||||
SalesRequestLineInput,
|
||||
UpdateSalesRequestInput,
|
||||
} from './sales-request';
|
||||
import { SalesRequestsRepository } from './sales-requests.repository';
|
||||
|
||||
export type SalesLineBody = {
|
||||
readonly productId: string;
|
||||
readonly quantity: string;
|
||||
readonly price?: string;
|
||||
};
|
||||
|
||||
export type SalesImageBody = {
|
||||
readonly url: string;
|
||||
readonly description?: string | null;
|
||||
};
|
||||
|
||||
export type ListSalesRequestsQuery = {
|
||||
readonly code?: string;
|
||||
readonly status?: string;
|
||||
readonly customerId?: string;
|
||||
readonly salesPersonId?: string;
|
||||
readonly branchId?: string;
|
||||
readonly divisionId?: string;
|
||||
readonly search?: string;
|
||||
readonly page?: number;
|
||||
readonly limit?: number;
|
||||
readonly offset?: number;
|
||||
};
|
||||
|
||||
const CSV_REQUIRED_HEADERS = [
|
||||
'date',
|
||||
'salesPersonId',
|
||||
'branchId',
|
||||
'divisionId',
|
||||
'customerId',
|
||||
'address',
|
||||
] as const;
|
||||
|
||||
@Injectable()
|
||||
export class SalesRequestsService {
|
||||
constructor(
|
||||
private readonly salesRequestsRepository: SalesRequestsRepository,
|
||||
private readonly employeesService: EmployeesService,
|
||||
private readonly branchesService: BranchesService,
|
||||
private readonly divisionsService: DivisionsService,
|
||||
private readonly customersService: CustomersService,
|
||||
private readonly productsService: ProductsService,
|
||||
) {}
|
||||
|
||||
async list(
|
||||
query: ListSalesRequestsQuery,
|
||||
): Promise<
|
||||
PaginationResponse<ReturnType<SalesRequestsService['toListItem']>>
|
||||
> {
|
||||
const page = toListPage(query);
|
||||
const { data, total } = await this.salesRequestsRepository.list({
|
||||
code: query.code,
|
||||
status: query.status,
|
||||
customerId: query.customerId,
|
||||
salesPersonId: query.salesPersonId,
|
||||
branchId: query.branchId,
|
||||
divisionId: query.divisionId,
|
||||
search: query.search,
|
||||
limit: page.limit,
|
||||
offset: page.offset,
|
||||
});
|
||||
return {
|
||||
data: data.map((item) => this.toListItem(item)),
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
async findById(
|
||||
id: string,
|
||||
): Promise<ReturnType<SalesRequestsService['toDetail']>> {
|
||||
const found = await this.salesRequestsRepository.findById(id);
|
||||
if (!found) {
|
||||
throw new NotFoundException('Sales request not found');
|
||||
}
|
||||
return this.toDetail(found);
|
||||
}
|
||||
|
||||
async create(input: {
|
||||
code?: string;
|
||||
date: string;
|
||||
salesPersonId: string;
|
||||
branchId: string;
|
||||
divisionId: string;
|
||||
customerId: string;
|
||||
address: string;
|
||||
latitude?: number | null;
|
||||
longitude?: number | null;
|
||||
notes?: string | null;
|
||||
products: SalesLineBody[];
|
||||
images?: SalesImageBody[];
|
||||
status?: string;
|
||||
userId: string;
|
||||
}): Promise<ReturnType<SalesRequestsService['toDetail']>> {
|
||||
await this.assertRelations(input);
|
||||
const created = await this.salesRequestsRepository.create(
|
||||
await this.toCreateInput(input),
|
||||
);
|
||||
return this.toDetail(created);
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
input: {
|
||||
code?: string;
|
||||
date?: string;
|
||||
salesPersonId?: string;
|
||||
branchId?: string;
|
||||
divisionId?: string;
|
||||
customerId?: string;
|
||||
address?: string;
|
||||
latitude?: number | null;
|
||||
longitude?: number | null;
|
||||
notes?: string | null;
|
||||
products?: SalesLineBody[];
|
||||
images?: SalesImageBody[];
|
||||
status?: unknown;
|
||||
userId: string;
|
||||
},
|
||||
): Promise<ReturnType<SalesRequestsService['toDetail']>> {
|
||||
if (input.status !== undefined) {
|
||||
throw new BadRequestException('status cannot be updated via PATCH');
|
||||
}
|
||||
await this.assertRelations(input);
|
||||
const payload: UpdateSalesRequestInput = {
|
||||
code: input.code !== undefined ? this.assertCode(input.code) : undefined,
|
||||
date: input.date !== undefined ? this.assertDate(input.date) : undefined,
|
||||
salesPersonId: input.salesPersonId,
|
||||
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:
|
||||
input.products !== undefined
|
||||
? await this.assertLines(input.products)
|
||||
: undefined,
|
||||
images:
|
||||
input.images !== undefined
|
||||
? input.images.map((image) => this.assertImage(image))
|
||||
: undefined,
|
||||
userId: input.userId,
|
||||
};
|
||||
const updated = await this.salesRequestsRepository.update(id, payload);
|
||||
return this.toDetail(updated);
|
||||
}
|
||||
|
||||
async updateStatus(
|
||||
id: string,
|
||||
statusRaw: string,
|
||||
userId: string,
|
||||
): Promise<ReturnType<SalesRequestsService['toDetail']>> {
|
||||
const status = this.assertStatus(statusRaw);
|
||||
const updated = await this.salesRequestsRepository.updateStatus(
|
||||
id,
|
||||
status,
|
||||
userId,
|
||||
);
|
||||
return this.toDetail(updated);
|
||||
}
|
||||
|
||||
async bulkUpdateStatus(
|
||||
ids: string[],
|
||||
statusRaw: string,
|
||||
userId: string,
|
||||
): Promise<{ updated: number }> {
|
||||
const status = this.assertStatus(statusRaw);
|
||||
const updated = await this.salesRequestsRepository.bulkUpdateStatus(
|
||||
ids,
|
||||
status,
|
||||
userId,
|
||||
);
|
||||
return { updated };
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.salesRequestsRepository.delete(id);
|
||||
}
|
||||
|
||||
async bulkDelete(ids: string[]): Promise<{ deleted: number }> {
|
||||
const deleted = await this.salesRequestsRepository.bulkDelete(ids);
|
||||
return { deleted };
|
||||
}
|
||||
|
||||
async importCsv(csv: string, userId: string): Promise<{ imported: number }> {
|
||||
const rawLines = csv.split(/\r?\n/);
|
||||
const filled = rawLines
|
||||
.map((line, index) => ({ line: line.trim(), lineNo: index + 1 }))
|
||||
.filter((entry) => entry.line.length > 0);
|
||||
if (filled.length === 0) {
|
||||
throw new BadRequestException('CSV is empty');
|
||||
}
|
||||
if (filled.length > 501) {
|
||||
throw new BadRequestException('CSV exceeds maximum of 500 data rows');
|
||||
}
|
||||
const header = parseCsvRecord(filled[0].line).map((h) =>
|
||||
h.trim().toLowerCase(),
|
||||
);
|
||||
const missing = CSV_REQUIRED_HEADERS.filter(
|
||||
(h) => header.indexOf(h.toLowerCase()) < 0,
|
||||
);
|
||||
if (missing.length > 0) {
|
||||
throw new BadRequestException('CSV must include required headers');
|
||||
}
|
||||
const idx = (key: string) => header.indexOf(key.toLowerCase());
|
||||
const errors: string[] = [];
|
||||
const rows: CreateSalesRequestInput[] = [];
|
||||
for (let i = 1; i < filled.length; i++) {
|
||||
const cols = parseCsvRecord(filled[i].line);
|
||||
const rowNum = filled[i].lineNo;
|
||||
try {
|
||||
rows.push(
|
||||
await this.toCreateInput({
|
||||
code: idx('code') >= 0 ? cols[idx('code')] : undefined,
|
||||
date: cols[idx('date')] ?? '',
|
||||
salesPersonId: cols[idx('salespersonid')] ?? '',
|
||||
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: [],
|
||||
images: [],
|
||||
status: idx('status') >= 0 ? cols[idx('status')] : undefined,
|
||||
userId,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
const reason =
|
||||
error instanceof BadRequestException ? error.message : 'invalid data';
|
||||
errors.push(`row ${rowNum}: ${reason}`);
|
||||
}
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
throw new BadRequestException({
|
||||
message: 'CSV validation failed',
|
||||
errors,
|
||||
});
|
||||
}
|
||||
await this.salesRequestsRepository.createMany(rows);
|
||||
return { imported: rows.length };
|
||||
}
|
||||
|
||||
toListItem(item: SalesRequest) {
|
||||
return {
|
||||
id: item.id,
|
||||
code: item.code,
|
||||
date: item.date.value,
|
||||
salesPersonId: item.salesPersonId,
|
||||
branchId: item.branchId,
|
||||
divisionId: item.divisionId,
|
||||
customerId: item.customerId,
|
||||
address: item.address,
|
||||
latitude: item.latitude,
|
||||
longitude: item.longitude,
|
||||
notes: item.notes,
|
||||
status: item.status.value,
|
||||
createdAt: item.createdAt.value,
|
||||
updatedAt: item.updatedAt.value,
|
||||
createdBy: item.createdBy,
|
||||
updatedBy: item.updatedBy,
|
||||
};
|
||||
}
|
||||
|
||||
toDetail(item: SalesRequest) {
|
||||
return {
|
||||
...this.toListItem(item),
|
||||
products: item.products.map((line) => ({
|
||||
id: line.id,
|
||||
productId: line.productId,
|
||||
quantity: line.quantity.value,
|
||||
price: line.price.value,
|
||||
})),
|
||||
images: item.images.map((image) => ({
|
||||
id: image.id,
|
||||
url: image.url,
|
||||
description: image.description,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
private async toCreateInput(input: {
|
||||
code?: string;
|
||||
date: string;
|
||||
salesPersonId: string;
|
||||
branchId: string;
|
||||
divisionId: string;
|
||||
customerId: string;
|
||||
address: string;
|
||||
latitude?: number | null;
|
||||
longitude?: number | null;
|
||||
notes?: string | null;
|
||||
products: SalesLineBody[];
|
||||
images?: SalesImageBody[];
|
||||
status?: string;
|
||||
userId: string;
|
||||
}): Promise<CreateSalesRequestInput> {
|
||||
return {
|
||||
code:
|
||||
input.code !== undefined && input.code !== ''
|
||||
? this.assertCode(input.code)
|
||||
: undefined,
|
||||
date: this.assertDate(input.date),
|
||||
salesPersonId: input.salesPersonId,
|
||||
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),
|
||||
images: (input.images ?? []).map((image) => this.assertImage(image)),
|
||||
status: input.status
|
||||
? this.assertStatus(input.status)
|
||||
: Status.create('draft', SALES_REQUEST_STATUSES),
|
||||
userId: input.userId,
|
||||
};
|
||||
}
|
||||
|
||||
private async assertRelations(input: {
|
||||
salesPersonId?: string;
|
||||
branchId?: string;
|
||||
divisionId?: string;
|
||||
customerId?: string;
|
||||
}): Promise<void> {
|
||||
if (input.salesPersonId) {
|
||||
await this.employeesService.findById(input.salesPersonId);
|
||||
}
|
||||
if (input.branchId) {
|
||||
await this.branchesService.findById(input.branchId);
|
||||
}
|
||||
if (input.divisionId) {
|
||||
await this.divisionsService.findById(input.divisionId);
|
||||
}
|
||||
if (input.customerId) {
|
||||
await this.customersService.findById(input.customerId);
|
||||
}
|
||||
}
|
||||
|
||||
private async assertLines(
|
||||
lines: SalesLineBody[],
|
||||
): Promise<SalesRequestLineInput[]> {
|
||||
if (!Array.isArray(lines) || lines.length === 0) {
|
||||
throw new BadRequestException('At least one product line is required');
|
||||
}
|
||||
const result: SalesRequestLineInput[] = [];
|
||||
for (const line of lines) {
|
||||
const product = await this.productsService.findById(line.productId);
|
||||
const quantity = this.assertPositiveDecimal(line.quantity, 'quantity');
|
||||
let price: Decimal;
|
||||
if (line.price === undefined || line.price === '') {
|
||||
if (product.price === null) {
|
||||
throw new BadRequestException('Product price is required');
|
||||
}
|
||||
price = Decimal.create(product.price);
|
||||
} else {
|
||||
price = this.assertNonNegativeDecimal(line.price, 'price');
|
||||
}
|
||||
result.push({ productId: product.id, quantity, price });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private assertImage(image: SalesImageBody): SalesRequestImageInput {
|
||||
if (!isValidImageUrl(image.url)) {
|
||||
throw new BadRequestException('Invalid image URL');
|
||||
}
|
||||
const description = image.description ?? null;
|
||||
if (description !== null && !isValidImageDescription(description)) {
|
||||
throw new BadRequestException('Invalid image description');
|
||||
}
|
||||
return { url: image.url, description };
|
||||
}
|
||||
|
||||
private assertCode(raw: string): string {
|
||||
const code = raw.trim();
|
||||
if (!isValidDocumentCode(code)) {
|
||||
throw new BadRequestException('Invalid document code');
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
private assertDate(raw: string): DateTime {
|
||||
try {
|
||||
return DateTime.create(raw);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidDateTimeError) {
|
||||
throw new BadRequestException('Invalid date');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
if (!isValidDocumentNotes(raw)) {
|
||||
throw new BadRequestException('Invalid notes');
|
||||
}
|
||||
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_REQUEST_STATUSES);
|
||||
} catch {
|
||||
throw new BadRequestException('Invalid status');
|
||||
}
|
||||
}
|
||||
|
||||
private assertPositiveDecimal(raw: string, label: string): Decimal {
|
||||
const value = this.parseDecimal(raw);
|
||||
if (!value.isPositive()) {
|
||||
throw new BadRequestException(`Invalid ${label}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private assertNonNegativeDecimal(raw: string, label: string): Decimal {
|
||||
const value = this.parseDecimal(raw);
|
||||
if (value.isNegative()) {
|
||||
throw new BadRequestException(`Invalid ${label}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private parseDecimal(raw: string): Decimal {
|
||||
try {
|
||||
return Decimal.create(raw);
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidDecimalError) {
|
||||
throw new BadRequestException('Invalid decimal');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PackingSlipsModule } from './packing-slips/packing-slips.module';
|
||||
import { SalesInvoicesModule } from './sales-invoices/sales-invoices.module';
|
||||
import { SalesOrdersModule } from './sales-orders/sales-orders.module';
|
||||
import { SalesPaymentsModule } from './sales-payments/sales-payments.module';
|
||||
import { SalesRequestsModule } from './sales-requests/sales-requests.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
SalesRequestsModule,
|
||||
SalesOrdersModule,
|
||||
PackingSlipsModule,
|
||||
SalesInvoicesModule,
|
||||
SalesPaymentsModule,
|
||||
],
|
||||
exports: [
|
||||
SalesRequestsModule,
|
||||
SalesOrdersModule,
|
||||
PackingSlipsModule,
|
||||
SalesInvoicesModule,
|
||||
SalesPaymentsModule,
|
||||
],
|
||||
})
|
||||
export class SalesModule {}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { DRIZZLE } from '../../../database/database.module';
|
||||
import { DocumentCodeService } from './document-code.service';
|
||||
|
||||
describe('DocumentCodeService', () => {
|
||||
it('returns PREFIX-YYYYMMDD-NNNN from the upserted sequence', async () => {
|
||||
const returning = jest.fn().mockResolvedValue([{ lastValue: 3 }]);
|
||||
const onConflictDoUpdate = jest.fn().mockReturnValue({ returning });
|
||||
const values = jest.fn().mockReturnValue({ onConflictDoUpdate });
|
||||
const insert = jest.fn().mockReturnValue({ values });
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
DocumentCodeService,
|
||||
{ provide: DRIZZLE, useValue: { insert } },
|
||||
],
|
||||
}).compile();
|
||||
const service = moduleRef.get(DocumentCodeService);
|
||||
const at = DateTime.fromUnixMs(Date.UTC(2026, 7, 24, 17, 0, 0));
|
||||
const code = await service.nextCode('SR', at);
|
||||
expect(code).toMatch(/^SR-\d{8}-0003$/);
|
||||
expect(insert).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { DRIZZLE, type DrizzleDB } from '../../../database/database.module';
|
||||
import { documentSequences } from '../../../database/document-sequences-table';
|
||||
import { formatDocumentCode, periodFromDateTime } from './document-code';
|
||||
import type { DocumentPrefix } from './document-prefixes';
|
||||
|
||||
type QueryExecutor = Pick<DrizzleDB, 'insert'>;
|
||||
|
||||
@Injectable()
|
||||
export class DocumentCodeService {
|
||||
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
|
||||
|
||||
async nextCode(
|
||||
prefix: DocumentPrefix,
|
||||
at: DateTime = DateTime.fromUnixMs(Date.now()),
|
||||
executor: QueryExecutor = this.db,
|
||||
): Promise<string> {
|
||||
const period = periodFromDateTime(at);
|
||||
const inserted = await executor
|
||||
.insert(documentSequences)
|
||||
.values({ prefix, period, lastValue: 1 })
|
||||
.onConflictDoUpdate({
|
||||
target: [documentSequences.prefix, documentSequences.period],
|
||||
set: { lastValue: sql`${documentSequences.lastValue} + 1` },
|
||||
})
|
||||
.returning({ lastValue: documentSequences.lastValue });
|
||||
const lastValue = inserted[0]?.lastValue ?? 1;
|
||||
return formatDocumentCode(prefix, period, lastValue);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
import { formatDocumentCode, periodFromDateTime } from './document-code';
|
||||
|
||||
describe('document code', () => {
|
||||
it('formats prefix, period, and a 4-digit sequence', () => {
|
||||
expect(formatDocumentCode('SR', '20260824', 1)).toBe('SR-20260824-0001');
|
||||
expect(formatDocumentCode('SO', '20260824', 12)).toBe('SO-20260824-0012');
|
||||
});
|
||||
|
||||
it('derives YYYYMMDD from DateTime in the default timezone', () => {
|
||||
const at = DateTime.fromUnixMs(Date.UTC(2026, 7, 24, 0, 0, 0));
|
||||
const period = periodFromDateTime(at);
|
||||
expect(period).toMatch(/^\d{8}$/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||
|
||||
export function periodFromDateTime(at: DateTime): string {
|
||||
return at.format().slice(0, 10).replaceAll('-', '');
|
||||
}
|
||||
|
||||
export function formatDocumentCode(
|
||||
prefix: string,
|
||||
period: string,
|
||||
sequence: number,
|
||||
): string {
|
||||
return `${prefix}-${period}-${String(sequence).padStart(4, '0')}`;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export const DOCUMENT_PREFIXES = {
|
||||
salesRequest: 'SR',
|
||||
salesOrder: 'SO',
|
||||
packingSlip: 'PS',
|
||||
salesInvoice: 'SI',
|
||||
salesPayment: 'SP',
|
||||
} as const;
|
||||
|
||||
export type DocumentPrefix =
|
||||
(typeof DOCUMENT_PREFIXES)[keyof typeof DOCUMENT_PREFIXES];
|
||||
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
isValidDocumentCode,
|
||||
isValidImageUrl,
|
||||
isValidLatitude,
|
||||
} from './sales-fields';
|
||||
|
||||
describe('sales fields', () => {
|
||||
it('accepts generated and user document codes', () => {
|
||||
expect(isValidDocumentCode('SR-20260824-0001')).toBe(true);
|
||||
expect(isValidDocumentCode('REQ_01')).toBe(true);
|
||||
expect(isValidDocumentCode('')).toBe(false);
|
||||
expect(isValidDocumentCode('SR 1')).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts http(s) image URLs only', () => {
|
||||
expect(isValidImageUrl('https://cdn.example.com/a.png')).toBe(true);
|
||||
expect(isValidImageUrl('ftp://x/a.png')).toBe(false);
|
||||
expect(isValidImageUrl('not-a-url')).toBe(false);
|
||||
});
|
||||
|
||||
it('validates latitude', () => {
|
||||
expect(isValidLatitude(-6.2)).toBe(true);
|
||||
expect(isValidLatitude(100)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
export const DOCUMENT_CODE_MAX_LENGTH = 32;
|
||||
export const DOCUMENT_CODE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
||||
export const DOCUMENT_NOTES_MAX_LENGTH = 1024;
|
||||
export const DOCUMENT_ADDRESS_MAX_LENGTH = 255;
|
||||
export const IMAGE_URL_MAX_LENGTH = 2048;
|
||||
export const IMAGE_DESCRIPTION_MAX_LENGTH = 255;
|
||||
|
||||
export const SALES_REQUEST_STATUSES = [
|
||||
'draft',
|
||||
'pending',
|
||||
'approved',
|
||||
'rejected',
|
||||
] as const;
|
||||
|
||||
export const SALES_ORDER_STATUSES = [
|
||||
'draft',
|
||||
'processed',
|
||||
'completed',
|
||||
'cancelled',
|
||||
] as const;
|
||||
|
||||
export const PACKING_SLIP_STATUSES = [
|
||||
'draft',
|
||||
'processed',
|
||||
'completed',
|
||||
'cancelled',
|
||||
] as const;
|
||||
|
||||
export const SALES_INVOICE_STATUSES = [
|
||||
'draft',
|
||||
'processed',
|
||||
'partial',
|
||||
'completed',
|
||||
'cancelled',
|
||||
] as const;
|
||||
|
||||
export const SALES_PAYMENT_STATUSES = [
|
||||
'draft',
|
||||
'pending',
|
||||
'approved',
|
||||
'rejected',
|
||||
] as const;
|
||||
|
||||
export type SalesRequestStatus = (typeof SALES_REQUEST_STATUSES)[number];
|
||||
export type SalesOrderStatus = (typeof SALES_ORDER_STATUSES)[number];
|
||||
export type PackingSlipStatus = (typeof PACKING_SLIP_STATUSES)[number];
|
||||
export type SalesInvoiceStatus = (typeof SALES_INVOICE_STATUSES)[number];
|
||||
export type SalesPaymentStatus = (typeof SALES_PAYMENT_STATUSES)[number];
|
||||
|
||||
export function isValidDocumentCode(raw: string): boolean {
|
||||
return (
|
||||
typeof raw === 'string' &&
|
||||
raw.length > 0 &&
|
||||
raw.length <= DOCUMENT_CODE_MAX_LENGTH &&
|
||||
DOCUMENT_CODE_PATTERN.test(raw)
|
||||
);
|
||||
}
|
||||
|
||||
export function isValidDocumentNotes(raw: string): boolean {
|
||||
return typeof raw === 'string' && raw.length <= DOCUMENT_NOTES_MAX_LENGTH;
|
||||
}
|
||||
|
||||
export function isValidDocumentAddress(raw: string): boolean {
|
||||
return (
|
||||
typeof raw === 'string' &&
|
||||
raw.length > 0 &&
|
||||
raw.length <= DOCUMENT_ADDRESS_MAX_LENGTH
|
||||
);
|
||||
}
|
||||
|
||||
export function isValidLatitude(raw: number): boolean {
|
||||
return Number.isFinite(raw) && raw >= -90 && raw <= 90;
|
||||
}
|
||||
|
||||
export function isValidLongitude(raw: number): boolean {
|
||||
return Number.isFinite(raw) && raw >= -180 && raw <= 180;
|
||||
}
|
||||
|
||||
export function isValidImageUrl(raw: string): boolean {
|
||||
if (
|
||||
typeof raw !== 'string' ||
|
||||
raw.length === 0 ||
|
||||
raw.length > IMAGE_URL_MAX_LENGTH
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(raw);
|
||||
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isValidImageDescription(raw: string): boolean {
|
||||
return typeof raw === 'string' && raw.length <= IMAGE_DESCRIPTION_MAX_LENGTH;
|
||||
}
|
||||
|
||||
export function parseCsvRecord(line: string): string[] {
|
||||
const cells: string[] = [];
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
if (inQuotes) {
|
||||
if (ch === '"') {
|
||||
if (line[i + 1] === '"') {
|
||||
current += '"';
|
||||
i += 1;
|
||||
} else {
|
||||
inQuotes = false;
|
||||
}
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
} else if (ch === '"') {
|
||||
inQuotes = true;
|
||||
} else if (ch === ',') {
|
||||
cells.push(current.trim());
|
||||
current = '';
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
}
|
||||
cells.push(current.trim());
|
||||
return cells;
|
||||
}
|
||||
|
||||
export function isAllowedCsvUpload(file: {
|
||||
mimetype: string;
|
||||
originalname: string;
|
||||
}): boolean {
|
||||
return (
|
||||
file.mimetype.includes('csv') ||
|
||||
file.originalname.toLowerCase().endsWith('.csv')
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user