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:
shancheas
2026-08-25 09:54:16 +07:00
parent ddbe8a9ef8
commit 34d4f2c120
82 changed files with 12609 additions and 2 deletions
+20
View File
@@ -0,0 +1,20 @@
CREATE TABLE "products" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"code" varchar(32) NOT NULL,
"name" varchar(128) NOT NULL,
"unit" varchar(16),
"price" numeric(18, 4),
"brand" varchar(64),
"status" text DEFAULT 'draft' NOT NULL,
"created_at" bigint NOT NULL,
"updated_at" bigint NOT NULL,
"created_by" uuid NOT NULL,
"updated_by" uuid NOT NULL
);
--> statement-breakpoint
ALTER TABLE "products" ADD CONSTRAINT "products_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "products" ADD CONSTRAINT "products_updated_by_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "products_code_unique" ON "products" USING btree ("code");
--> statement-breakpoint
INSERT INTO "privilege_keys" ("code", "label", "sort_order") VALUES
('CONFIGURATION.PRODUCT', 'Products', 7);
+252
View File
@@ -0,0 +1,252 @@
CREATE TABLE "document_sequences" (
"prefix" varchar(8) NOT NULL,
"period" varchar(8) NOT NULL,
"last_value" integer NOT NULL,
CONSTRAINT "document_sequences_prefix_period_pk" PRIMARY KEY("prefix","period")
);
--> statement-breakpoint
CREATE TABLE "sales_requests" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"code" varchar(32) NOT NULL,
"date" bigint NOT NULL,
"sales_person_id" uuid NOT NULL,
"branch_id" uuid NOT NULL,
"division_id" uuid NOT NULL,
"customer_id" uuid NOT NULL,
"address" text NOT NULL,
"latitude" double precision,
"longitude" double precision,
"notes" text,
"status" text DEFAULT 'draft' NOT NULL,
"created_at" bigint NOT NULL,
"updated_at" bigint NOT NULL,
"created_by" uuid NOT NULL,
"updated_by" uuid NOT NULL
);
--> statement-breakpoint
CREATE TABLE "sales_request_products" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"sales_request_id" uuid NOT NULL,
"product_id" uuid NOT NULL,
"quantity" numeric(18, 4) NOT NULL,
"price" numeric(18, 4) NOT NULL
);
--> statement-breakpoint
CREATE TABLE "sales_request_images" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"sales_request_id" uuid NOT NULL,
"url" varchar(2048) NOT NULL,
"description" varchar(255)
);
--> statement-breakpoint
CREATE TABLE "sales_orders" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"code" varchar(32) NOT NULL,
"sales_request_id" uuid,
"date" bigint NOT NULL,
"sales_person_id" uuid NOT NULL,
"branch_id" uuid NOT NULL,
"division_id" uuid NOT NULL,
"customer_id" uuid NOT NULL,
"address" text NOT NULL,
"latitude" double precision,
"longitude" double precision,
"notes" text,
"status" text DEFAULT 'draft' NOT NULL,
"created_at" bigint NOT NULL,
"updated_at" bigint NOT NULL,
"created_by" uuid NOT NULL,
"updated_by" uuid NOT NULL
);
--> statement-breakpoint
CREATE TABLE "sales_order_products" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"sales_order_id" uuid NOT NULL,
"product_id" uuid NOT NULL,
"quantity" numeric(18, 4) NOT NULL,
"price" numeric(18, 4) NOT NULL
);
--> statement-breakpoint
CREATE TABLE "sales_order_images" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"sales_order_id" uuid NOT NULL,
"url" varchar(2048) NOT NULL,
"description" varchar(255)
);
--> statement-breakpoint
CREATE TABLE "packing_slips" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"code" varchar(32) NOT NULL,
"sales_order_id" uuid,
"sales_order_number" varchar(32),
"date" bigint NOT NULL,
"customer_id" uuid NOT NULL,
"address" text NOT NULL,
"latitude" double precision,
"longitude" double precision,
"notes" text,
"status" text DEFAULT 'draft' NOT NULL,
"created_at" bigint NOT NULL,
"updated_at" bigint NOT NULL,
"created_by" uuid NOT NULL,
"updated_by" uuid NOT NULL
);
--> statement-breakpoint
CREATE TABLE "packing_slip_products" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"packing_slip_id" uuid NOT NULL,
"product_id" uuid NOT NULL,
"quantity" numeric(18, 4) NOT NULL,
"price" numeric(18, 4) NOT NULL
);
--> statement-breakpoint
CREATE TABLE "sales_invoices" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"code" varchar(32) NOT NULL,
"date" bigint NOT NULL,
"sales_person_id" uuid NOT NULL,
"branch_id" uuid NOT NULL,
"division_id" uuid NOT NULL,
"customer_id" uuid NOT NULL,
"sales_order_id" uuid,
"sales_order_code" varchar(32),
"packing_slip_id" uuid,
"packing_slip_code" varchar(32),
"balance" numeric(18, 4) NOT NULL,
"notes" text,
"status" text DEFAULT 'draft' NOT NULL,
"created_at" bigint NOT NULL,
"updated_at" bigint NOT NULL,
"created_by" uuid NOT NULL,
"updated_by" uuid NOT NULL
);
--> statement-breakpoint
CREATE TABLE "sales_invoice_products" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"sales_invoice_id" uuid NOT NULL,
"product_id" uuid NOT NULL,
"quantity" numeric(18, 4) NOT NULL,
"price" numeric(18, 4) NOT NULL
);
--> statement-breakpoint
CREATE TABLE "sales_payments" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"code" varchar(32) NOT NULL,
"date" bigint NOT NULL,
"notes" text,
"status" text DEFAULT 'draft' NOT NULL,
"created_at" bigint NOT NULL,
"updated_at" bigint NOT NULL,
"created_by" uuid NOT NULL,
"updated_by" uuid NOT NULL
);
--> statement-breakpoint
CREATE TABLE "sales_payment_images" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"sales_payment_id" uuid NOT NULL,
"url" varchar(2048) NOT NULL,
"description" varchar(255)
);
--> statement-breakpoint
CREATE TABLE "sales_payment_invoices" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"sales_payment_id" uuid NOT NULL,
"sales_invoice_id" uuid NOT NULL,
"amount" numeric(18, 4) NOT NULL
);
--> statement-breakpoint
ALTER TABLE "sales_requests" ADD CONSTRAINT "sales_requests_sales_person_id_employees_id_fk" FOREIGN KEY ("sales_person_id") REFERENCES "public"."employees"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_requests" ADD CONSTRAINT "sales_requests_branch_id_branches_id_fk" FOREIGN KEY ("branch_id") REFERENCES "public"."branches"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_requests" ADD CONSTRAINT "sales_requests_division_id_divisions_id_fk" FOREIGN KEY ("division_id") REFERENCES "public"."divisions"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_requests" ADD CONSTRAINT "sales_requests_customer_id_customers_id_fk" FOREIGN KEY ("customer_id") REFERENCES "public"."customers"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_requests" ADD CONSTRAINT "sales_requests_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_requests" ADD CONSTRAINT "sales_requests_updated_by_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_request_products" ADD CONSTRAINT "sales_request_products_sales_request_id_sales_requests_id_fk" FOREIGN KEY ("sales_request_id") REFERENCES "public"."sales_requests"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_request_products" ADD CONSTRAINT "sales_request_products_product_id_products_id_fk" FOREIGN KEY ("product_id") REFERENCES "public"."products"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_request_images" ADD CONSTRAINT "sales_request_images_sales_request_id_sales_requests_id_fk" FOREIGN KEY ("sales_request_id") REFERENCES "public"."sales_requests"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_orders" ADD CONSTRAINT "sales_orders_sales_request_id_sales_requests_id_fk" FOREIGN KEY ("sales_request_id") REFERENCES "public"."sales_requests"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_orders" ADD CONSTRAINT "sales_orders_sales_person_id_employees_id_fk" FOREIGN KEY ("sales_person_id") REFERENCES "public"."employees"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_orders" ADD CONSTRAINT "sales_orders_branch_id_branches_id_fk" FOREIGN KEY ("branch_id") REFERENCES "public"."branches"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_orders" ADD CONSTRAINT "sales_orders_division_id_divisions_id_fk" FOREIGN KEY ("division_id") REFERENCES "public"."divisions"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_orders" ADD CONSTRAINT "sales_orders_customer_id_customers_id_fk" FOREIGN KEY ("customer_id") REFERENCES "public"."customers"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_orders" ADD CONSTRAINT "sales_orders_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_orders" ADD CONSTRAINT "sales_orders_updated_by_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_order_products" ADD CONSTRAINT "sales_order_products_sales_order_id_sales_orders_id_fk" FOREIGN KEY ("sales_order_id") REFERENCES "public"."sales_orders"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_order_products" ADD CONSTRAINT "sales_order_products_product_id_products_id_fk" FOREIGN KEY ("product_id") REFERENCES "public"."products"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_order_images" ADD CONSTRAINT "sales_order_images_sales_order_id_sales_orders_id_fk" FOREIGN KEY ("sales_order_id") REFERENCES "public"."sales_orders"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "packing_slips" ADD CONSTRAINT "packing_slips_sales_order_id_sales_orders_id_fk" FOREIGN KEY ("sales_order_id") REFERENCES "public"."sales_orders"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "packing_slips" ADD CONSTRAINT "packing_slips_customer_id_customers_id_fk" FOREIGN KEY ("customer_id") REFERENCES "public"."customers"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "packing_slips" ADD CONSTRAINT "packing_slips_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "packing_slips" ADD CONSTRAINT "packing_slips_updated_by_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "packing_slip_products" ADD CONSTRAINT "packing_slip_products_packing_slip_id_packing_slips_id_fk" FOREIGN KEY ("packing_slip_id") REFERENCES "public"."packing_slips"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "packing_slip_products" ADD CONSTRAINT "packing_slip_products_product_id_products_id_fk" FOREIGN KEY ("product_id") REFERENCES "public"."products"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_invoices" ADD CONSTRAINT "sales_invoices_sales_person_id_employees_id_fk" FOREIGN KEY ("sales_person_id") REFERENCES "public"."employees"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_invoices" ADD CONSTRAINT "sales_invoices_branch_id_branches_id_fk" FOREIGN KEY ("branch_id") REFERENCES "public"."branches"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_invoices" ADD CONSTRAINT "sales_invoices_division_id_divisions_id_fk" FOREIGN KEY ("division_id") REFERENCES "public"."divisions"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_invoices" ADD CONSTRAINT "sales_invoices_customer_id_customers_id_fk" FOREIGN KEY ("customer_id") REFERENCES "public"."customers"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_invoices" ADD CONSTRAINT "sales_invoices_sales_order_id_sales_orders_id_fk" FOREIGN KEY ("sales_order_id") REFERENCES "public"."sales_orders"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_invoices" ADD CONSTRAINT "sales_invoices_packing_slip_id_packing_slips_id_fk" FOREIGN KEY ("packing_slip_id") REFERENCES "public"."packing_slips"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_invoices" ADD CONSTRAINT "sales_invoices_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_invoices" ADD CONSTRAINT "sales_invoices_updated_by_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_invoice_products" ADD CONSTRAINT "sales_invoice_products_sales_invoice_id_sales_invoices_id_fk" FOREIGN KEY ("sales_invoice_id") REFERENCES "public"."sales_invoices"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_invoice_products" ADD CONSTRAINT "sales_invoice_products_product_id_products_id_fk" FOREIGN KEY ("product_id") REFERENCES "public"."products"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_payments" ADD CONSTRAINT "sales_payments_created_by_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_payments" ADD CONSTRAINT "sales_payments_updated_by_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_payment_images" ADD CONSTRAINT "sales_payment_images_sales_payment_id_sales_payments_id_fk" FOREIGN KEY ("sales_payment_id") REFERENCES "public"."sales_payments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_payment_invoices" ADD CONSTRAINT "sales_payment_invoices_sales_payment_id_sales_payments_id_fk" FOREIGN KEY ("sales_payment_id") REFERENCES "public"."sales_payments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_payment_invoices" ADD CONSTRAINT "sales_payment_invoices_sales_invoice_id_sales_invoices_id_fk" FOREIGN KEY ("sales_invoice_id") REFERENCES "public"."sales_invoices"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "sales_requests_code_unique" ON "sales_requests" USING btree ("code");
--> statement-breakpoint
CREATE UNIQUE INDEX "sales_orders_code_unique" ON "sales_orders" USING btree ("code");
--> statement-breakpoint
CREATE UNIQUE INDEX "packing_slips_code_unique" ON "packing_slips" USING btree ("code");
--> statement-breakpoint
CREATE UNIQUE INDEX "sales_invoices_code_unique" ON "sales_invoices" USING btree ("code");
--> statement-breakpoint
CREATE UNIQUE INDEX "sales_payments_code_unique" ON "sales_payments" USING btree ("code");
--> statement-breakpoint
INSERT INTO "privilege_keys" ("code", "label", "sort_order") VALUES
('SALES.REQUEST', 'Sales requests', 8),
('SALES.ORDER', 'Sales orders', 9),
('SALES.PACKING_SLIP', 'Packing slips', 10),
('SALES.INVOICE', 'Sales invoices', 11),
('SALES.PAYMENT', 'Sales payments', 12);
--> statement-breakpoint
ALTER TABLE "packing_slips" ADD COLUMN "sales_person_id" uuid;--> statement-breakpoint
ALTER TABLE "packing_slips" ADD COLUMN "branch_id" uuid;--> statement-breakpoint
ALTER TABLE "packing_slips" ADD COLUMN "division_id" uuid;--> statement-breakpoint
ALTER TABLE "sales_invoices" ADD COLUMN "address" text;--> statement-breakpoint
ALTER TABLE "sales_invoices" ADD COLUMN "latitude" double precision;--> statement-breakpoint
ALTER TABLE "sales_invoices" ADD COLUMN "longitude" double precision;--> statement-breakpoint
ALTER TABLE "sales_payments" ADD COLUMN "sales_person_id" uuid;--> statement-breakpoint
ALTER TABLE "sales_payments" ADD COLUMN "branch_id" uuid;--> statement-breakpoint
ALTER TABLE "sales_payments" ADD COLUMN "division_id" uuid;--> statement-breakpoint
ALTER TABLE "sales_payments" ADD COLUMN "customer_id" uuid;--> statement-breakpoint
ALTER TABLE "sales_payments" ADD COLUMN "address" text;--> statement-breakpoint
ALTER TABLE "sales_payments" ADD COLUMN "latitude" double precision;--> statement-breakpoint
ALTER TABLE "sales_payments" ADD COLUMN "longitude" double precision;--> statement-breakpoint
CREATE TABLE "packing_slip_images" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"packing_slip_id" uuid NOT NULL,
"url" varchar(2048) NOT NULL,
"description" varchar(255)
);
--> statement-breakpoint
CREATE TABLE "sales_invoice_images" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"sales_invoice_id" uuid NOT NULL,
"url" varchar(2048) NOT NULL,
"description" varchar(255)
);
--> statement-breakpoint
CREATE TABLE "sales_payment_products" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"sales_payment_id" uuid NOT NULL,
"product_id" uuid NOT NULL,
"quantity" numeric(18, 4) NOT NULL,
"price" numeric(18, 4) NOT NULL
);
--> statement-breakpoint
ALTER TABLE "packing_slip_images" ADD CONSTRAINT "packing_slip_images_packing_slip_id_packing_slips_id_fk" FOREIGN KEY ("packing_slip_id") REFERENCES "public"."packing_slips"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_invoice_images" ADD CONSTRAINT "sales_invoice_images_sales_invoice_id_sales_invoices_id_fk" FOREIGN KEY ("sales_invoice_id") REFERENCES "public"."sales_invoices"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sales_payment_products" ADD CONSTRAINT "sales_payment_products_sales_payment_id_sales_payments_id_fk" FOREIGN KEY ("sales_payment_id") REFERENCES "public"."sales_payments"("id") ON DELETE cascade ON UPDATE no action;
+16
View File
@@ -0,0 +1,16 @@
ALTER TABLE "packing_slips" DROP COLUMN IF EXISTS "sales_person_id";--> statement-breakpoint
ALTER TABLE "packing_slips" DROP COLUMN IF EXISTS "branch_id";--> statement-breakpoint
ALTER TABLE "packing_slips" DROP COLUMN IF EXISTS "division_id";--> statement-breakpoint
DROP TABLE IF EXISTS "packing_slip_images";--> statement-breakpoint
ALTER TABLE "sales_invoices" DROP COLUMN IF EXISTS "address";--> statement-breakpoint
ALTER TABLE "sales_invoices" DROP COLUMN IF EXISTS "latitude";--> statement-breakpoint
ALTER TABLE "sales_invoices" DROP COLUMN IF EXISTS "longitude";--> statement-breakpoint
DROP TABLE IF EXISTS "sales_invoice_images";--> statement-breakpoint
ALTER TABLE "sales_payments" DROP COLUMN IF EXISTS "sales_person_id";--> statement-breakpoint
ALTER TABLE "sales_payments" DROP COLUMN IF EXISTS "branch_id";--> statement-breakpoint
ALTER TABLE "sales_payments" DROP COLUMN IF EXISTS "division_id";--> statement-breakpoint
ALTER TABLE "sales_payments" DROP COLUMN IF EXISTS "customer_id";--> statement-breakpoint
ALTER TABLE "sales_payments" DROP COLUMN IF EXISTS "address";--> statement-breakpoint
ALTER TABLE "sales_payments" DROP COLUMN IF EXISTS "latitude";--> statement-breakpoint
ALTER TABLE "sales_payments" DROP COLUMN IF EXISTS "longitude";--> statement-breakpoint
DROP TABLE IF EXISTS "sales_payment_products";
+21
View File
@@ -57,6 +57,27 @@
"when": 1787555000000, "when": 1787555000000,
"tag": "0007_employees", "tag": "0007_employees",
"breakpoints": true "breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1787556000000,
"tag": "0008_products",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1787557000000,
"tag": "0009_sales",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1787558000000,
"tag": "0010_phase2_shape",
"breakpoints": true
} }
] ]
} }
+2
View File
@@ -6,6 +6,7 @@ import loadEnv from './config/env';
import { DatabaseModule } from './database/database.module'; import { DatabaseModule } from './database/database.module';
import { AuthModule } from './modules/auth/auth.module'; import { AuthModule } from './modules/auth/auth.module';
import { ConfigurationModule } from './modules/configuration/configuration.module'; import { ConfigurationModule } from './modules/configuration/configuration.module';
import { SalesModule } from './modules/sales/sales.module';
import { PrivilegesModule } from './modules/privileges/privileges.module'; import { PrivilegesModule } from './modules/privileges/privileges.module';
import { UsersModule } from './modules/users/users.module'; import { UsersModule } from './modules/users/users.module';
@@ -20,6 +21,7 @@ import { UsersModule } from './modules/users/users.module';
AuthModule, AuthModule,
PrivilegesModule, PrivilegesModule,
ConfigurationModule, ConfigurationModule,
SalesModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [AppService], 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);
});
});
});
+145
View File
@@ -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';
}
}
+17
View File
@@ -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;
+60
View File
@@ -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;
+30
View File
@@ -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;
+71
View File
@@ -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;
+88
View File
@@ -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;
+58
View File
@@ -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;
+81
View File
@@ -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;
+54
View File
@@ -156,3 +156,57 @@ export {
type EmployeeRow, type EmployeeRow,
type NewEmployeeRow, type NewEmployeeRow,
} from './employees-table'; } 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 { CustomersModule } from './customers/customers.module';
import { DivisionsModule } from './divisions/divisions.module'; import { DivisionsModule } from './divisions/divisions.module';
import { EmployeesModule } from './employees/employees.module'; import { EmployeesModule } from './employees/employees.module';
import { ProductsModule } from './products/products.module';
@Module({ @Module({
imports: [DivisionsModule, BranchesModule, CustomersModule, EmployeesModule], imports: [
exports: [DivisionsModule, BranchesModule, CustomersModule, EmployeesModule], DivisionsModule,
BranchesModule,
CustomersModule,
EmployeesModule,
ProductsModule,
],
exports: [
DivisionsModule,
BranchesModule,
CustomersModule,
EmployeesModule,
ProductsModule,
],
}) })
export class ConfigurationModule {} 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;
}
}
}
+24
View File
@@ -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}$/);
});
});
+13
View File
@@ -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);
});
});
+137
View File
@@ -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')
);
}
+163
View File
@@ -0,0 +1,163 @@
import { INestApplication } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { eq } from 'drizzle-orm';
import request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from '../src/app.module';
import { configureApp } from '../src/common/configure-app';
import { DRIZZLE, type DrizzleDB } from '../src/database/database.module';
import {
privilegeDetails,
privilegeKeys,
privileges,
users,
} from '../src/database/schema';
import { PRIVILEGE_ACTIONS } from '../src/modules/privileges/privilege-action';
describe('Packing slips (e2e)', () => {
let app: INestApplication<App>;
let db: DrizzleDB;
let adminAccessToken: string;
let otherAccessToken: string;
let customerId: string;
let productId: string;
const password = 'password123';
const suffix = Date.now().toString().slice(-6);
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
configureApp(app, { NODE_ENV: 'test', SWAGGER_ENABLED: 'false' });
await app.init();
db = app.get(DRIZZLE);
const adminReg = await request(app.getHttpServer())
.post('/auth/register')
.send({ username: `ps_admin_${suffix}`, password })
.expect(201);
adminAccessToken = (adminReg.body as { accessToken: string }).accessToken;
const adminMe = await request(app.getHttpServer())
.get('/auth/me')
.set('Authorization', `Bearer ${adminAccessToken}`)
.expect(200);
const adminUserId = (adminMe.body as { id: string }).id;
const otherReg = await request(app.getHttpServer())
.post('/auth/register')
.send({ username: `ps_other_${suffix}`, password })
.expect(201);
otherAccessToken = (otherReg.body as { accessToken: string }).accessToken;
const now = Date.now();
const [priv] = await db
.insert(privileges)
.values({
name: 'Packing Admin',
code: `PS_ADMIN_${now}`,
status: 'active',
createdAt: now,
updatedAt: now,
createdBy: adminUserId,
updatedBy: adminUserId,
})
.returning();
const keys = await db.select().from(privilegeKeys);
await db.insert(privilegeDetails).values(
keys.flatMap((key) =>
PRIVILEGE_ACTIONS.map((action) => ({
privilegeId: priv.id,
privilegeKeyId: key.id,
action,
value: true,
})),
),
);
await db
.update(users)
.set({ privilegeId: priv.id, updatedAt: Date.now() })
.where(eq(users.id, adminUserId));
const customer = await request(app.getHttpServer())
.post('/customers')
.set('Authorization', `Bearer ${adminAccessToken}`)
.send({
code: `C_${suffix}`,
name: 'Acme Corp',
phone: '+6281234567890',
address: 'Jl Sudirman 1',
})
.expect(201);
customerId = (customer.body as { id: string }).id;
const product = await request(app.getHttpServer())
.post('/products')
.set('Authorization', `Bearer ${adminAccessToken}`)
.send({
code: `P_${suffix}`,
name: 'Fuel 95',
unit: 'L',
price: '12500.0000',
})
.expect(201);
productId = (product.body as { id: string }).id;
});
afterAll(async () => {
await app.close();
});
it('rejects unauthenticated access', async () => {
await request(app.getHttpServer()).get('/packing-slips').expect(401);
});
it('forbids list without permission', async () => {
await request(app.getHttpServer())
.get('/packing-slips')
.set('Authorization', `Bearer ${otherAccessToken}`)
.expect(403);
});
it('creates, patches, and deletes a packing slip', async () => {
const created = await request(app.getHttpServer())
.post('/packing-slips')
.set('Authorization', `Bearer ${adminAccessToken}`)
.send({
date: '2026-08-24T10:00:00+07:00',
customerId,
address: 'Jl Sudirman 1',
products: [{ productId, quantity: '2' }],
})
.expect(201);
expect(created.body).toMatchObject({
customerId,
address: 'Jl Sudirman 1',
status: 'draft',
});
expect((created.body as { code: string }).code).toMatch(/^PS-/);
const id = (created.body as { id: string }).id;
await request(app.getHttpServer())
.patch(`/packing-slips/${id}`)
.set('Authorization', `Bearer ${adminAccessToken}`)
.send({ status: 'processed' })
.expect(400);
await request(app.getHttpServer())
.patch(`/packing-slips/${id}/status`)
.set('Authorization', `Bearer ${adminAccessToken}`)
.send({ status: 'processed' })
.expect(200);
const detail = await request(app.getHttpServer())
.get(`/packing-slips/${id}`)
.set('Authorization', `Bearer ${adminAccessToken}`)
.expect(200);
expect((detail.body as { products: unknown[] }).products).toHaveLength(1);
await request(app.getHttpServer())
.delete(`/packing-slips/${id}`)
.set('Authorization', `Bearer ${adminAccessToken}`)
.expect(204);
});
});
+229
View File
@@ -0,0 +1,229 @@
import { INestApplication } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { eq } from 'drizzle-orm';
import request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from '../src/app.module';
import { configureApp } from '../src/common/configure-app';
import { DRIZZLE, type DrizzleDB } from '../src/database/database.module';
import {
privilegeDetails,
privilegeKeys,
privileges,
users,
} from '../src/database/schema';
import { PRIVILEGE_ACTIONS } from '../src/modules/privileges/privilege-action';
describe('Products (e2e)', () => {
let app: INestApplication<App>;
let db: DrizzleDB;
const password = 'password123';
const adminUsername = `prd_admin_${Date.now()}`;
const otherUsername = `prd_other_${Date.now()}`;
let adminAccessToken: string;
let adminUserId: string;
let otherAccessToken: string;
const payload = {
code: `FUEL_${Date.now().toString().slice(-6)}`,
name: 'Fuel 95',
unit: 'L',
price: '12500.0000',
brand: 'Pertamina',
};
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
configureApp(app, {
NODE_ENV: 'test',
SWAGGER_ENABLED: 'false',
});
await app.init();
db = app.get(DRIZZLE);
const adminReg = await request(app.getHttpServer())
.post('/auth/register')
.send({ username: adminUsername, password })
.expect(201);
adminAccessToken = (adminReg.body as { accessToken: string }).accessToken;
const adminMe = await request(app.getHttpServer())
.get('/auth/me')
.set('Authorization', `Bearer ${adminAccessToken}`)
.expect(200);
adminUserId = (adminMe.body as { id: string }).id;
const otherReg = await request(app.getHttpServer())
.post('/auth/register')
.send({ username: otherUsername, password })
.expect(201);
otherAccessToken = (otherReg.body as { accessToken: string }).accessToken;
const now = Date.now();
const [priv] = await db
.insert(privileges)
.values({
name: 'Employee Admin',
code: `EMP_ADMIN_${now}`,
status: 'active',
createdAt: now,
updatedAt: now,
createdBy: adminUserId,
updatedBy: adminUserId,
})
.returning();
const keys = await db.select().from(privilegeKeys);
const detailRows = keys.flatMap((key) =>
PRIVILEGE_ACTIONS.map((action) => ({
privilegeId: priv.id,
privilegeKeyId: key.id,
action,
value: true,
})),
);
await db.insert(privilegeDetails).values(detailRows);
await db
.update(users)
.set({ privilegeId: priv.id, updatedAt: Date.now() })
.where(eq(users.id, adminUserId));
});
afterAll(async () => {
await app.close();
});
it('forbids products list without permission', async () => {
await request(app.getHttpServer())
.get('/products')
.set('Authorization', `Bearer ${otherAccessToken}`)
.expect(403);
});
it('rejects unauthenticated access', async () => {
await request(app.getHttpServer()).get('/products').expect(401);
});
it('CRUD products with name/code/phone/position rules, status, search, and bulk', async () => {
const created = await request(app.getHttpServer())
.post('/products')
.set('Authorization', `Bearer ${adminAccessToken}`)
.send(payload)
.expect(201);
expect(created.body).toMatchObject({
code: payload.code,
name: 'Ada Lovelace',
phone: '+6281234567890',
position: 'sales',
status: 'draft',
createdBy: adminUserId,
});
const id = (created.body as { id: string }).id;
await request(app.getHttpServer())
.post('/products')
.set('Authorization', `Bearer ${adminAccessToken}`)
.send({ ...payload, code: 'BAD 01', name: 'Fuel 95' })
.expect(400);
await request(app.getHttpServer())
.post('/products')
.set('Authorization', `Bearer ${adminAccessToken}`)
.send({ ...payload, code: 'PHN_01', price: '1.23456' })
.expect(400);
await request(app.getHttpServer())
.post('/products')
.set('Authorization', `Bearer ${adminAccessToken}`)
.send({ ...payload, code: 'POS_01', unit: 'L!' })
.expect(400);
await request(app.getHttpServer())
.post('/products')
.set('Authorization', `Bearer ${adminAccessToken}`)
.send({ ...payload, code: payload.code, name: 'Fuel 98' })
.expect(409);
await request(app.getHttpServer())
.get(`/products/${id}`)
.set('Authorization', `Bearer ${adminAccessToken}`)
.expect(200);
await request(app.getHttpServer())
.patch(`/products/${id}`)
.set('Authorization', `Bearer ${adminAccessToken}`)
.send({ name: 'Fuel 98', unit: 'L' })
.expect(200);
await request(app.getHttpServer())
.patch(`/products/${id}`)
.set('Authorization', `Bearer ${adminAccessToken}`)
.send({ status: 'active' })
.expect(400);
await request(app.getHttpServer())
.patch(`/products/${id}/status`)
.set('Authorization', `Bearer ${adminAccessToken}`)
.send({ status: 'active' })
.expect(200);
const list = await request(app.getHttpServer())
.get('/products?search=Jean')
.set('Authorization', `Bearer ${adminAccessToken}`)
.expect(200);
expect(
(list.body as { data: unknown[] }).data.length,
).toBeGreaterThanOrEqual(1);
expect((list.body as { meta?: unknown }).meta).toBeDefined();
const extra = await request(app.getHttpServer())
.post('/products')
.set('Authorization', `Bearer ${adminAccessToken}`)
.send({
...payload,
code: `OTH_${Date.now().toString().slice(-6)}`,
name: 'Grace Hopper',
position: 'crew',
})
.expect(201);
await request(app.getHttpServer())
.post('/products/bulk-status')
.set('Authorization', `Bearer ${adminAccessToken}`)
.send({ ids: [(extra.body as { id: string }).id], status: 'archived' })
.expect(200);
await request(app.getHttpServer())
.post('/products/bulk-delete')
.set('Authorization', `Bearer ${adminAccessToken}`)
.send({ ids: [(extra.body as { id: string }).id] })
.expect(200);
await request(app.getHttpServer())
.delete(`/products/${id}`)
.set('Authorization', `Bearer ${adminAccessToken}`)
.expect(204);
});
it('imports products from CSV', async () => {
const suffix = Date.now().toString().slice(-6);
const csv =
'code,name,unit,price,brand,status\n' +
`IMP_${suffix},Imported Fuel,+6281234567890,L,12500.0000,Pertamina,draft\n`;
const res = await request(app.getHttpServer())
.post('/products/import')
.set('Authorization', `Bearer ${adminAccessToken}`)
.attach('file', Buffer.from(csv, 'utf8'), 'products.csv')
.expect(201);
expect(res.body).toMatchObject({ imported: 1 });
});
});
+188
View File
@@ -0,0 +1,188 @@
import { INestApplication } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { eq } from 'drizzle-orm';
import request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from '../src/app.module';
import { configureApp } from '../src/common/configure-app';
import { DRIZZLE, type DrizzleDB } from '../src/database/database.module';
import {
privilegeDetails,
privilegeKeys,
privileges,
users,
} from '../src/database/schema';
import { PRIVILEGE_ACTIONS } from '../src/modules/privileges/privilege-action';
describe('Sales invoices (e2e)', () => {
let app: INestApplication<App>;
let db: DrizzleDB;
let token: string;
let otherToken: string;
let salesPersonId: string;
let branchId: string;
let divisionId: string;
let customerId: string;
let productId: string;
const password = 'password123';
const suffix = Date.now().toString().slice(-6);
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
configureApp(app, { NODE_ENV: 'test', SWAGGER_ENABLED: 'false' });
await app.init();
db = app.get(DRIZZLE);
const adminReg = await request(app.getHttpServer())
.post('/auth/register')
.send({ username: `si_admin_${suffix}`, password })
.expect(201);
token = (adminReg.body as { accessToken: string }).accessToken;
const adminMe = await request(app.getHttpServer())
.get('/auth/me')
.set('Authorization', `Bearer ${token}`)
.expect(200);
const adminUserId = (adminMe.body as { id: string }).id;
const otherReg = await request(app.getHttpServer())
.post('/auth/register')
.send({ username: `si_other_${suffix}`, password })
.expect(201);
otherToken = (otherReg.body as { accessToken: string }).accessToken;
const now = Date.now();
const [priv] = await db
.insert(privileges)
.values({
name: 'Invoice Admin',
code: `SI_ADMIN_${now}`,
status: 'active',
createdAt: now,
updatedAt: now,
createdBy: adminUserId,
updatedBy: adminUserId,
})
.returning();
const keys = await db.select().from(privilegeKeys);
await db.insert(privilegeDetails).values(
keys.flatMap((key) =>
PRIVILEGE_ACTIONS.map((action) => ({
privilegeId: priv.id,
privilegeKeyId: key.id,
action,
value: true,
})),
),
);
await db
.update(users)
.set({ privilegeId: priv.id, updatedAt: Date.now() })
.where(eq(users.id, adminUserId));
const auth = { Authorization: `Bearer ${token}` };
const division = await request(app.getHttpServer())
.post('/divisions')
.set(auth)
.send({ code: `D_${suffix}`, name: 'Sales Division' })
.expect(201);
divisionId = (division.body as { id: string }).id;
const branch = await request(app.getHttpServer())
.post('/branches')
.set(auth)
.send({
code: `B_${suffix}`,
name: 'Jakarta Pusat',
phone: '+6281234567890',
address: 'Jl Sudirman 1',
workingDaysStart: 'monday',
workingDaysEnd: 'friday',
workingHoursStart: '08:00',
workingHoursEnd: '17:00',
divisionId,
})
.expect(201);
branchId = (branch.body as { id: string }).id;
const employee = await request(app.getHttpServer())
.post('/employees')
.set(auth)
.send({
code: `E_${suffix}`,
name: 'Ada Lovelace',
phone: '+6281234567890',
position: 'sales',
})
.expect(201);
salesPersonId = (employee.body as { id: string }).id;
const customer = await request(app.getHttpServer())
.post('/customers')
.set(auth)
.send({
code: `C_${suffix}`,
name: 'Acme Corp',
phone: '+6281234567890',
address: 'Jl Sudirman 1',
})
.expect(201);
customerId = (customer.body as { id: string }).id;
const product = await request(app.getHttpServer())
.post('/products')
.set(auth)
.send({
code: `P_${suffix}`,
name: 'Fuel 95',
price: '12500.0000',
})
.expect(201);
productId = (product.body as { id: string }).id;
});
afterAll(async () => {
await app.close();
});
it('rejects unauthenticated access', async () => {
await request(app.getHttpServer()).get('/sales-invoices').expect(401);
});
it('forbids list without permission', async () => {
await request(app.getHttpServer())
.get('/sales-invoices')
.set('Authorization', `Bearer ${otherToken}`)
.expect(403);
});
it('creates an invoice with catalog price and stored balance', async () => {
const created = await request(app.getHttpServer())
.post('/sales-invoices')
.set('Authorization', `Bearer ${token}`)
.send({
date: '2026-08-24T10:00:00+07:00',
salesPersonId,
branchId,
divisionId,
customerId,
products: [{ productId, quantity: '2' }],
})
.expect(201);
expect(created.body).toMatchObject({
status: 'draft',
balance: '25000.0000',
});
expect((created.body as { code: string }).code).toMatch(/^SI-/);
const id = (created.body as { id: string }).id;
await request(app.getHttpServer())
.patch(`/sales-invoices/${id}`)
.set('Authorization', `Bearer ${token}`)
.send({ status: 'processed' })
.expect(400);
await request(app.getHttpServer())
.delete(`/sales-invoices/${id}`)
.set('Authorization', `Bearer ${token}`)
.expect(204);
});
});
+199
View File
@@ -0,0 +1,199 @@
import { INestApplication } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { eq } from 'drizzle-orm';
import request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from '../src/app.module';
import { configureApp } from '../src/common/configure-app';
import { DRIZZLE, type DrizzleDB } from '../src/database/database.module';
import {
privilegeDetails,
privilegeKeys,
privileges,
users,
} from '../src/database/schema';
import { PRIVILEGE_ACTIONS } from '../src/modules/privileges/privilege-action';
describe('Sales payments (e2e)', () => {
let app: INestApplication<App>;
let db: DrizzleDB;
let token: string;
let otherToken: string;
let invoiceId: string;
const password = 'password123';
const suffix = Date.now().toString().slice(-6);
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
configureApp(app, { NODE_ENV: 'test', SWAGGER_ENABLED: 'false' });
await app.init();
db = app.get(DRIZZLE);
const adminReg = await request(app.getHttpServer())
.post('/auth/register')
.send({ username: `sp_admin_${suffix}`, password })
.expect(201);
token = (adminReg.body as { accessToken: string }).accessToken;
const adminMe = await request(app.getHttpServer())
.get('/auth/me')
.set('Authorization', `Bearer ${token}`)
.expect(200);
const adminUserId = (adminMe.body as { id: string }).id;
const otherReg = await request(app.getHttpServer())
.post('/auth/register')
.send({ username: `sp_other_${suffix}`, password })
.expect(201);
otherToken = (otherReg.body as { accessToken: string }).accessToken;
const now = Date.now();
const [priv] = await db
.insert(privileges)
.values({
name: 'Payment Admin',
code: `SP_ADMIN_${now}`,
status: 'active',
createdAt: now,
updatedAt: now,
createdBy: adminUserId,
updatedBy: adminUserId,
})
.returning();
const keys = await db.select().from(privilegeKeys);
await db.insert(privilegeDetails).values(
keys.flatMap((key) =>
PRIVILEGE_ACTIONS.map((action) => ({
privilegeId: priv.id,
privilegeKeyId: key.id,
action,
value: true,
})),
),
);
await db
.update(users)
.set({ privilegeId: priv.id, updatedAt: Date.now() })
.where(eq(users.id, adminUserId));
const auth = { Authorization: `Bearer ${token}` };
const division = await request(app.getHttpServer())
.post('/divisions')
.set(auth)
.send({ code: `D_${suffix}`, name: 'Sales Division' })
.expect(201);
const branch = await request(app.getHttpServer())
.post('/branches')
.set(auth)
.send({
code: `B_${suffix}`,
name: 'Jakarta Pusat',
phone: '+6281234567890',
address: 'Jl Sudirman 1',
workingDaysStart: 'monday',
workingDaysEnd: 'friday',
workingHoursStart: '08:00',
workingHoursEnd: '17:00',
divisionId: (division.body as { id: string }).id,
})
.expect(201);
const employee = await request(app.getHttpServer())
.post('/employees')
.set(auth)
.send({
code: `E_${suffix}`,
name: 'Ada Lovelace',
phone: '+6281234567890',
position: 'sales',
})
.expect(201);
const customer = await request(app.getHttpServer())
.post('/customers')
.set(auth)
.send({
code: `C_${suffix}`,
name: 'Acme Corp',
phone: '+6281234567890',
address: 'Jl Sudirman 1',
})
.expect(201);
const product = await request(app.getHttpServer())
.post('/products')
.set(auth)
.send({
code: `P_${suffix}`,
name: 'Fuel 95',
price: '12500.0000',
})
.expect(201);
const invoice = await request(app.getHttpServer())
.post('/sales-invoices')
.set(auth)
.send({
date: '2026-08-24T10:00:00+07:00',
salesPersonId: (employee.body as { id: string }).id,
branchId: (branch.body as { id: string }).id,
divisionId: (division.body as { id: string }).id,
customerId: (customer.body as { id: string }).id,
products: [
{
productId: (product.body as { id: string }).id,
quantity: '2',
},
],
})
.expect(201);
invoiceId = (invoice.body as { id: string }).id;
});
afterAll(async () => {
await app.close();
});
it('rejects unauthenticated access', async () => {
await request(app.getHttpServer()).get('/sales-payments').expect(401);
});
it('forbids list without permission', async () => {
await request(app.getHttpServer())
.get('/sales-payments')
.set('Authorization', `Bearer ${otherToken}`)
.expect(403);
});
it('creates a payment and marks the invoice partial when approved', async () => {
const created = await request(app.getHttpServer())
.post('/sales-payments')
.set('Authorization', `Bearer ${token}`)
.send({
date: '2026-08-24T10:00:00+07:00',
invoices: [{ invoiceId, amount: '10000.0000' }],
})
.expect(201);
expect((created.body as { code: string }).code).toMatch(/^SP-/);
const id = (created.body as { id: string }).id;
await request(app.getHttpServer())
.patch(`/sales-payments/${id}`)
.set('Authorization', `Bearer ${token}`)
.send({ status: 'approved' })
.expect(400);
await request(app.getHttpServer())
.patch(`/sales-payments/${id}/status`)
.set('Authorization', `Bearer ${token}`)
.send({ status: 'approved' })
.expect(200);
const invoice = await request(app.getHttpServer())
.get(`/sales-invoices/${invoiceId}`)
.set('Authorization', `Bearer ${token}`)
.expect(200);
expect(invoice.body).toMatchObject({
status: 'partial',
balance: '15000.0000',
});
});
});