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:
@@ -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);
|
||||||
@@ -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;
|
||||||
@@ -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";
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { InvalidDecimalError } from './invalid-decimal.error';
|
||||||
|
|
||||||
|
export const DECIMAL_SCALE = 4;
|
||||||
|
export const DECIMAL_PRECISION = 18;
|
||||||
|
const DECIMAL_FACTOR = 10n ** BigInt(DECIMAL_SCALE);
|
||||||
|
const MAX_UNSCALED =
|
||||||
|
10n ** BigInt(DECIMAL_PRECISION) - 1n; /* 18 digits of unscaled integer */
|
||||||
|
|
||||||
|
const DECIMAL_PATTERN = /^[+-]?(?:\d+|\d+\.\d{1,4}|\.\d{1,4})$/;
|
||||||
|
|
||||||
|
export class Decimal {
|
||||||
|
private static readonly createToken = Symbol('Decimal.create');
|
||||||
|
|
||||||
|
private constructor(
|
||||||
|
private readonly unscaled: bigint,
|
||||||
|
token: symbol,
|
||||||
|
) {
|
||||||
|
if (token !== Decimal.createToken) {
|
||||||
|
throw new TypeError('Decimal can only be created via Decimal.create()');
|
||||||
|
}
|
||||||
|
Object.freeze(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a Decimal from a string or an integer-looking number.
|
||||||
|
* Canonical scale is 4 (e.g. 10.5 → 10.5000). Precision is 18.
|
||||||
|
*/
|
||||||
|
static create(raw: string | number): Decimal {
|
||||||
|
const text = Decimal.normalizeRaw(raw);
|
||||||
|
if (!DECIMAL_PATTERN.test(text)) {
|
||||||
|
throw new InvalidDecimalError();
|
||||||
|
}
|
||||||
|
|
||||||
|
const negative = text.startsWith('-');
|
||||||
|
const unsigned =
|
||||||
|
text.startsWith('+') || text.startsWith('-') ? text.slice(1) : text;
|
||||||
|
const [wholePart, fractionPart = ''] = unsigned.split('.');
|
||||||
|
const whole = wholePart === '' ? '0' : wholePart;
|
||||||
|
const fraction = fractionPart.padEnd(DECIMAL_SCALE, '0');
|
||||||
|
const digits = `${whole}${fraction}`.replace(/^0+(?=\d)/, '');
|
||||||
|
let unscaled = BigInt(digits);
|
||||||
|
if (negative) {
|
||||||
|
unscaled = -unscaled;
|
||||||
|
}
|
||||||
|
if (unscaled > MAX_UNSCALED || unscaled < -MAX_UNSCALED) {
|
||||||
|
throw new InvalidDecimalError();
|
||||||
|
}
|
||||||
|
return new Decimal(unscaled, Decimal.createToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
static zero(): Decimal {
|
||||||
|
return new Decimal(0n, Decimal.createToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
get value(): string {
|
||||||
|
return this.format();
|
||||||
|
}
|
||||||
|
|
||||||
|
add(other: Decimal): Decimal {
|
||||||
|
return Decimal.fromUnscaled(this.unscaled + other.unscaled);
|
||||||
|
}
|
||||||
|
|
||||||
|
subtract(other: Decimal): Decimal {
|
||||||
|
return Decimal.fromUnscaled(this.unscaled - other.unscaled);
|
||||||
|
}
|
||||||
|
|
||||||
|
multiply(other: Decimal): Decimal {
|
||||||
|
const product = this.unscaled * other.unscaled;
|
||||||
|
const half = DECIMAL_FACTOR / 2n;
|
||||||
|
const remainder = product % DECIMAL_FACTOR;
|
||||||
|
let quotient = product / DECIMAL_FACTOR;
|
||||||
|
const absRemainder = remainder < 0n ? -remainder : remainder;
|
||||||
|
if (absRemainder >= half) {
|
||||||
|
quotient += product < 0n ? -1n : 1n;
|
||||||
|
}
|
||||||
|
return Decimal.fromUnscaled(quotient);
|
||||||
|
}
|
||||||
|
|
||||||
|
compare(other: Decimal): -1 | 0 | 1 {
|
||||||
|
if (this.unscaled < other.unscaled) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (this.unscaled > other.unscaled) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
equals(other: Decimal): boolean {
|
||||||
|
return other instanceof Decimal && this.unscaled === other.unscaled;
|
||||||
|
}
|
||||||
|
|
||||||
|
isZero(): boolean {
|
||||||
|
return this.unscaled === 0n;
|
||||||
|
}
|
||||||
|
|
||||||
|
isNegative(): boolean {
|
||||||
|
return this.unscaled < 0n;
|
||||||
|
}
|
||||||
|
|
||||||
|
isPositive(): boolean {
|
||||||
|
return this.unscaled > 0n;
|
||||||
|
}
|
||||||
|
|
||||||
|
toString(): string {
|
||||||
|
return this.format();
|
||||||
|
}
|
||||||
|
|
||||||
|
toJSON(): string {
|
||||||
|
return this.format();
|
||||||
|
}
|
||||||
|
|
||||||
|
private format(): string {
|
||||||
|
const negative = this.unscaled < 0n;
|
||||||
|
const abs = negative ? -this.unscaled : this.unscaled;
|
||||||
|
const padded = abs.toString().padStart(DECIMAL_SCALE + 1, '0');
|
||||||
|
const whole = padded.slice(0, -DECIMAL_SCALE);
|
||||||
|
const fraction = padded.slice(-DECIMAL_SCALE);
|
||||||
|
return `${negative ? '-' : ''}${whole}.${fraction}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static fromUnscaled(unscaled: bigint): Decimal {
|
||||||
|
if (unscaled > MAX_UNSCALED || unscaled < -MAX_UNSCALED) {
|
||||||
|
throw new InvalidDecimalError();
|
||||||
|
}
|
||||||
|
return new Decimal(unscaled, Decimal.createToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static normalizeRaw(raw: string | number): string {
|
||||||
|
if (typeof raw === 'number') {
|
||||||
|
if (!Number.isInteger(raw) || !Number.isSafeInteger(raw)) {
|
||||||
|
throw new InvalidDecimalError();
|
||||||
|
}
|
||||||
|
return String(raw);
|
||||||
|
}
|
||||||
|
if (typeof raw !== 'string') {
|
||||||
|
throw new InvalidDecimalError();
|
||||||
|
}
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (trimmed === '') {
|
||||||
|
throw new InvalidDecimalError();
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export class InvalidDecimalError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super('Invalid decimal');
|
||||||
|
this.name = 'InvalidDecimalError';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { integer, pgTable, primaryKey, varchar } from 'drizzle-orm/pg-core';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-prefix daily counters used to generate document codes.
|
||||||
|
*/
|
||||||
|
export const documentSequences = pgTable(
|
||||||
|
'document_sequences',
|
||||||
|
{
|
||||||
|
prefix: varchar('prefix', { length: 8 }).notNull(),
|
||||||
|
period: varchar('period', { length: 8 }).notNull(),
|
||||||
|
lastValue: integer('last_value').notNull(),
|
||||||
|
},
|
||||||
|
(t) => [primaryKey({ columns: [t.prefix, t.period] })],
|
||||||
|
);
|
||||||
|
|
||||||
|
export type DocumentSequenceRow = typeof documentSequences.$inferSelect;
|
||||||
|
export type NewDocumentSequenceRow = typeof documentSequences.$inferInsert;
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import {
|
||||||
|
bigint,
|
||||||
|
doublePrecision,
|
||||||
|
index,
|
||||||
|
numeric,
|
||||||
|
pgTable,
|
||||||
|
text,
|
||||||
|
uniqueIndex,
|
||||||
|
uuid,
|
||||||
|
varchar,
|
||||||
|
} from 'drizzle-orm/pg-core';
|
||||||
|
import { customers } from './customers-table';
|
||||||
|
import { products } from './products-table';
|
||||||
|
import { primaryEntityColumns } from './primary-entity-columns';
|
||||||
|
import { salesOrders } from './sales-orders-table';
|
||||||
|
import { users } from './schema';
|
||||||
|
|
||||||
|
export const packingSlips = pgTable(
|
||||||
|
'packing_slips',
|
||||||
|
{
|
||||||
|
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||||
|
code: varchar('code', { length: 32 }).notNull(),
|
||||||
|
salesOrderId: uuid('sales_order_id').references(() => salesOrders.id, {
|
||||||
|
onDelete: 'restrict',
|
||||||
|
}),
|
||||||
|
salesOrderNumber: varchar('sales_order_number', { length: 32 }),
|
||||||
|
date: bigint('date', { mode: 'number' }).notNull(),
|
||||||
|
customerId: uuid('customer_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => customers.id, { onDelete: 'restrict' }),
|
||||||
|
address: text('address').notNull(),
|
||||||
|
latitude: doublePrecision('latitude'),
|
||||||
|
longitude: doublePrecision('longitude'),
|
||||||
|
notes: text('notes'),
|
||||||
|
...primaryEntityColumns(users),
|
||||||
|
},
|
||||||
|
(t) => [uniqueIndex('packing_slips_code_unique').on(t.code)],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const packingSlipProducts = pgTable(
|
||||||
|
'packing_slip_products',
|
||||||
|
{
|
||||||
|
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||||
|
packingSlipId: uuid('packing_slip_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => packingSlips.id, { onDelete: 'cascade' }),
|
||||||
|
productId: uuid('product_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => products.id, { onDelete: 'restrict' }),
|
||||||
|
quantity: numeric('quantity', { precision: 18, scale: 4 }).notNull(),
|
||||||
|
price: numeric('price', { precision: 18, scale: 4 }).notNull(),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
index('packing_slip_products_packing_slip_id_idx').on(t.packingSlipId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export type PackingSlipRow = typeof packingSlips.$inferSelect;
|
||||||
|
export type NewPackingSlipRow = typeof packingSlips.$inferInsert;
|
||||||
|
export type PackingSlipProductRow = typeof packingSlipProducts.$inferSelect;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import {
|
||||||
|
numeric,
|
||||||
|
pgTable,
|
||||||
|
uniqueIndex,
|
||||||
|
uuid,
|
||||||
|
varchar,
|
||||||
|
} from 'drizzle-orm/pg-core';
|
||||||
|
import { primaryEntityColumns } from './primary-entity-columns';
|
||||||
|
import { users } from './schema';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Products (primary aggregate).
|
||||||
|
* Kept in a separate module so Drizzle's table type stays resolvable.
|
||||||
|
*/
|
||||||
|
export const products = pgTable(
|
||||||
|
'products',
|
||||||
|
{
|
||||||
|
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||||
|
code: varchar('code', { length: 32 }).notNull(),
|
||||||
|
name: varchar('name', { length: 128 }).notNull(),
|
||||||
|
unit: varchar('unit', { length: 16 }),
|
||||||
|
price: numeric('price', { precision: 18, scale: 4 }),
|
||||||
|
brand: varchar('brand', { length: 64 }),
|
||||||
|
...primaryEntityColumns(users),
|
||||||
|
},
|
||||||
|
(t) => [uniqueIndex('products_code_unique').on(t.code)],
|
||||||
|
);
|
||||||
|
|
||||||
|
export type ProductRow = typeof products.$inferSelect;
|
||||||
|
export type NewProductRow = typeof products.$inferInsert;
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import {
|
||||||
|
bigint,
|
||||||
|
index,
|
||||||
|
numeric,
|
||||||
|
pgTable,
|
||||||
|
text,
|
||||||
|
uniqueIndex,
|
||||||
|
uuid,
|
||||||
|
varchar,
|
||||||
|
} from 'drizzle-orm/pg-core';
|
||||||
|
import { branches } from './branches-table';
|
||||||
|
import { customers } from './customers-table';
|
||||||
|
import { employees } from './employees-table';
|
||||||
|
import { packingSlips } from './packing-slips-table';
|
||||||
|
import { products } from './products-table';
|
||||||
|
import { primaryEntityColumns } from './primary-entity-columns';
|
||||||
|
import { salesOrders } from './sales-orders-table';
|
||||||
|
import { divisions, users } from './schema';
|
||||||
|
|
||||||
|
export const salesInvoices = pgTable(
|
||||||
|
'sales_invoices',
|
||||||
|
{
|
||||||
|
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||||
|
code: varchar('code', { length: 32 }).notNull(),
|
||||||
|
date: bigint('date', { mode: 'number' }).notNull(),
|
||||||
|
salesPersonId: uuid('sales_person_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => employees.id, { onDelete: 'restrict' }),
|
||||||
|
branchId: uuid('branch_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => branches.id, { onDelete: 'restrict' }),
|
||||||
|
divisionId: uuid('division_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => divisions.id, { onDelete: 'restrict' }),
|
||||||
|
customerId: uuid('customer_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => customers.id, { onDelete: 'restrict' }),
|
||||||
|
salesOrderId: uuid('sales_order_id').references(() => salesOrders.id, {
|
||||||
|
onDelete: 'restrict',
|
||||||
|
}),
|
||||||
|
salesOrderCode: varchar('sales_order_code', { length: 32 }),
|
||||||
|
packingSlipId: uuid('packing_slip_id').references(() => packingSlips.id, {
|
||||||
|
onDelete: 'restrict',
|
||||||
|
}),
|
||||||
|
packingSlipCode: varchar('packing_slip_code', { length: 32 }),
|
||||||
|
balance: numeric('balance', { precision: 18, scale: 4 }).notNull(),
|
||||||
|
notes: text('notes'),
|
||||||
|
...primaryEntityColumns(users),
|
||||||
|
},
|
||||||
|
(t) => [uniqueIndex('sales_invoices_code_unique').on(t.code)],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const salesInvoiceProducts = pgTable(
|
||||||
|
'sales_invoice_products',
|
||||||
|
{
|
||||||
|
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||||
|
salesInvoiceId: uuid('sales_invoice_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => salesInvoices.id, { onDelete: 'cascade' }),
|
||||||
|
productId: uuid('product_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => products.id, { onDelete: 'restrict' }),
|
||||||
|
quantity: numeric('quantity', { precision: 18, scale: 4 }).notNull(),
|
||||||
|
price: numeric('price', { precision: 18, scale: 4 }).notNull(),
|
||||||
|
},
|
||||||
|
(t) => [index('sales_invoice_products_invoice_id_idx').on(t.salesInvoiceId)],
|
||||||
|
);
|
||||||
|
|
||||||
|
export type SalesInvoiceRow = typeof salesInvoices.$inferSelect;
|
||||||
|
export type NewSalesInvoiceRow = typeof salesInvoices.$inferInsert;
|
||||||
|
export type SalesInvoiceProductRow = typeof salesInvoiceProducts.$inferSelect;
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import {
|
||||||
|
bigint,
|
||||||
|
doublePrecision,
|
||||||
|
index,
|
||||||
|
numeric,
|
||||||
|
pgTable,
|
||||||
|
text,
|
||||||
|
uniqueIndex,
|
||||||
|
uuid,
|
||||||
|
varchar,
|
||||||
|
} from 'drizzle-orm/pg-core';
|
||||||
|
import { branches } from './branches-table';
|
||||||
|
import { customers } from './customers-table';
|
||||||
|
import { employees } from './employees-table';
|
||||||
|
import { products } from './products-table';
|
||||||
|
import { salesRequests } from './sales-requests-table';
|
||||||
|
import { primaryEntityColumns } from './primary-entity-columns';
|
||||||
|
import { divisions, users } from './schema';
|
||||||
|
|
||||||
|
export const salesOrders = pgTable(
|
||||||
|
'sales_orders',
|
||||||
|
{
|
||||||
|
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||||
|
code: varchar('code', { length: 32 }).notNull(),
|
||||||
|
salesRequestId: uuid('sales_request_id').references(
|
||||||
|
() => salesRequests.id,
|
||||||
|
{
|
||||||
|
onDelete: 'restrict',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
date: bigint('date', { mode: 'number' }).notNull(),
|
||||||
|
salesPersonId: uuid('sales_person_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => employees.id, { onDelete: 'restrict' }),
|
||||||
|
branchId: uuid('branch_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => branches.id, { onDelete: 'restrict' }),
|
||||||
|
divisionId: uuid('division_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => divisions.id, { onDelete: 'restrict' }),
|
||||||
|
customerId: uuid('customer_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => customers.id, { onDelete: 'restrict' }),
|
||||||
|
address: text('address').notNull(),
|
||||||
|
latitude: doublePrecision('latitude'),
|
||||||
|
longitude: doublePrecision('longitude'),
|
||||||
|
notes: text('notes'),
|
||||||
|
...primaryEntityColumns(users),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
uniqueIndex('sales_orders_code_unique').on(t.code),
|
||||||
|
index('sales_orders_customer_id_idx').on(t.customerId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const salesOrderProducts = pgTable(
|
||||||
|
'sales_order_products',
|
||||||
|
{
|
||||||
|
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||||
|
salesOrderId: uuid('sales_order_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => salesOrders.id, { onDelete: 'cascade' }),
|
||||||
|
productId: uuid('product_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => products.id, { onDelete: 'restrict' }),
|
||||||
|
quantity: numeric('quantity', { precision: 18, scale: 4 }).notNull(),
|
||||||
|
price: numeric('price', { precision: 18, scale: 4 }).notNull(),
|
||||||
|
},
|
||||||
|
(t) => [index('sales_order_products_request_id_idx').on(t.salesOrderId)],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const salesOrderImages = pgTable(
|
||||||
|
'sales_order_images',
|
||||||
|
{
|
||||||
|
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||||
|
salesOrderId: uuid('sales_order_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => salesOrders.id, { onDelete: 'cascade' }),
|
||||||
|
url: varchar('url', { length: 2048 }).notNull(),
|
||||||
|
description: varchar('description', { length: 255 }),
|
||||||
|
},
|
||||||
|
(t) => [index('sales_order_images_request_id_idx').on(t.salesOrderId)],
|
||||||
|
);
|
||||||
|
|
||||||
|
export type SalesOrderRow = typeof salesOrders.$inferSelect;
|
||||||
|
export type NewSalesOrderRow = typeof salesOrders.$inferInsert;
|
||||||
|
export type SalesOrderProductRow = typeof salesOrderProducts.$inferSelect;
|
||||||
|
export type SalesOrderImageRow = typeof salesOrderImages.$inferSelect;
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import {
|
||||||
|
bigint,
|
||||||
|
index,
|
||||||
|
numeric,
|
||||||
|
pgTable,
|
||||||
|
text,
|
||||||
|
uniqueIndex,
|
||||||
|
uuid,
|
||||||
|
varchar,
|
||||||
|
} from 'drizzle-orm/pg-core';
|
||||||
|
import { primaryEntityColumns } from './primary-entity-columns';
|
||||||
|
import { salesInvoices } from './sales-invoices-table';
|
||||||
|
import { users } from './schema';
|
||||||
|
|
||||||
|
export const salesPayments = pgTable(
|
||||||
|
'sales_payments',
|
||||||
|
{
|
||||||
|
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||||
|
code: varchar('code', { length: 32 }).notNull(),
|
||||||
|
date: bigint('date', { mode: 'number' }).notNull(),
|
||||||
|
notes: text('notes'),
|
||||||
|
...primaryEntityColumns(users),
|
||||||
|
},
|
||||||
|
(t) => [uniqueIndex('sales_payments_code_unique').on(t.code)],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const salesPaymentImages = pgTable(
|
||||||
|
'sales_payment_images',
|
||||||
|
{
|
||||||
|
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||||
|
salesPaymentId: uuid('sales_payment_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => salesPayments.id, { onDelete: 'cascade' }),
|
||||||
|
url: varchar('url', { length: 2048 }).notNull(),
|
||||||
|
description: varchar('description', { length: 255 }),
|
||||||
|
},
|
||||||
|
(t) => [index('sales_payment_images_payment_id_idx').on(t.salesPaymentId)],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const salesPaymentInvoices = pgTable(
|
||||||
|
'sales_payment_invoices',
|
||||||
|
{
|
||||||
|
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||||
|
salesPaymentId: uuid('sales_payment_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => salesPayments.id, { onDelete: 'cascade' }),
|
||||||
|
salesInvoiceId: uuid('sales_invoice_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => salesInvoices.id, { onDelete: 'restrict' }),
|
||||||
|
amount: numeric('amount', { precision: 18, scale: 4 }).notNull(),
|
||||||
|
},
|
||||||
|
(t) => [index('sales_payment_invoices_payment_id_idx').on(t.salesPaymentId)],
|
||||||
|
);
|
||||||
|
|
||||||
|
export type SalesPaymentRow = typeof salesPayments.$inferSelect;
|
||||||
|
export type NewSalesPaymentRow = typeof salesPayments.$inferInsert;
|
||||||
|
export type SalesPaymentImageRow = typeof salesPaymentImages.$inferSelect;
|
||||||
|
export type SalesPaymentInvoiceRow = typeof salesPaymentInvoices.$inferSelect;
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import {
|
||||||
|
bigint,
|
||||||
|
doublePrecision,
|
||||||
|
index,
|
||||||
|
numeric,
|
||||||
|
pgTable,
|
||||||
|
text,
|
||||||
|
uniqueIndex,
|
||||||
|
uuid,
|
||||||
|
varchar,
|
||||||
|
} from 'drizzle-orm/pg-core';
|
||||||
|
import { branches } from './branches-table';
|
||||||
|
import { customers } from './customers-table';
|
||||||
|
import { employees } from './employees-table';
|
||||||
|
import { products } from './products-table';
|
||||||
|
import { primaryEntityColumns } from './primary-entity-columns';
|
||||||
|
import { divisions, users } from './schema';
|
||||||
|
|
||||||
|
export const salesRequests = pgTable(
|
||||||
|
'sales_requests',
|
||||||
|
{
|
||||||
|
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||||
|
code: varchar('code', { length: 32 }).notNull(),
|
||||||
|
date: bigint('date', { mode: 'number' }).notNull(),
|
||||||
|
salesPersonId: uuid('sales_person_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => employees.id, { onDelete: 'restrict' }),
|
||||||
|
branchId: uuid('branch_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => branches.id, { onDelete: 'restrict' }),
|
||||||
|
divisionId: uuid('division_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => divisions.id, { onDelete: 'restrict' }),
|
||||||
|
customerId: uuid('customer_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => customers.id, { onDelete: 'restrict' }),
|
||||||
|
address: text('address').notNull(),
|
||||||
|
latitude: doublePrecision('latitude'),
|
||||||
|
longitude: doublePrecision('longitude'),
|
||||||
|
notes: text('notes'),
|
||||||
|
...primaryEntityColumns(users),
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
uniqueIndex('sales_requests_code_unique').on(t.code),
|
||||||
|
index('sales_requests_customer_id_idx').on(t.customerId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const salesRequestProducts = pgTable(
|
||||||
|
'sales_request_products',
|
||||||
|
{
|
||||||
|
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||||
|
salesRequestId: uuid('sales_request_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => salesRequests.id, { onDelete: 'cascade' }),
|
||||||
|
productId: uuid('product_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => products.id, { onDelete: 'restrict' }),
|
||||||
|
quantity: numeric('quantity', { precision: 18, scale: 4 }).notNull(),
|
||||||
|
price: numeric('price', { precision: 18, scale: 4 }).notNull(),
|
||||||
|
},
|
||||||
|
(t) => [index('sales_request_products_request_id_idx').on(t.salesRequestId)],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const salesRequestImages = pgTable(
|
||||||
|
'sales_request_images',
|
||||||
|
{
|
||||||
|
id: uuid('id').defaultRandom().notNull().primaryKey(),
|
||||||
|
salesRequestId: uuid('sales_request_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => salesRequests.id, { onDelete: 'cascade' }),
|
||||||
|
url: varchar('url', { length: 2048 }).notNull(),
|
||||||
|
description: varchar('description', { length: 255 }),
|
||||||
|
},
|
||||||
|
(t) => [index('sales_request_images_request_id_idx').on(t.salesRequestId)],
|
||||||
|
);
|
||||||
|
|
||||||
|
export type SalesRequestRow = typeof salesRequests.$inferSelect;
|
||||||
|
export type NewSalesRequestRow = typeof salesRequests.$inferInsert;
|
||||||
|
export type SalesRequestProductRow = typeof salesRequestProducts.$inferSelect;
|
||||||
|
export type SalesRequestImageRow = typeof salesRequestImages.$inferSelect;
|
||||||
@@ -156,3 +156,57 @@ export {
|
|||||||
type EmployeeRow,
|
type 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PackingSlipsModule } from './packing-slips/packing-slips.module';
|
||||||
|
import { SalesInvoicesModule } from './sales-invoices/sales-invoices.module';
|
||||||
|
import { SalesOrdersModule } from './sales-orders/sales-orders.module';
|
||||||
|
import { SalesPaymentsModule } from './sales-payments/sales-payments.module';
|
||||||
|
import { SalesRequestsModule } from './sales-requests/sales-requests.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
SalesRequestsModule,
|
||||||
|
SalesOrdersModule,
|
||||||
|
PackingSlipsModule,
|
||||||
|
SalesInvoicesModule,
|
||||||
|
SalesPaymentsModule,
|
||||||
|
],
|
||||||
|
exports: [
|
||||||
|
SalesRequestsModule,
|
||||||
|
SalesOrdersModule,
|
||||||
|
PackingSlipsModule,
|
||||||
|
SalesInvoicesModule,
|
||||||
|
SalesPaymentsModule,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class SalesModule {}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||||
|
import { DRIZZLE } from '../../../database/database.module';
|
||||||
|
import { DocumentCodeService } from './document-code.service';
|
||||||
|
|
||||||
|
describe('DocumentCodeService', () => {
|
||||||
|
it('returns PREFIX-YYYYMMDD-NNNN from the upserted sequence', async () => {
|
||||||
|
const returning = jest.fn().mockResolvedValue([{ lastValue: 3 }]);
|
||||||
|
const onConflictDoUpdate = jest.fn().mockReturnValue({ returning });
|
||||||
|
const values = jest.fn().mockReturnValue({ onConflictDoUpdate });
|
||||||
|
const insert = jest.fn().mockReturnValue({ values });
|
||||||
|
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
DocumentCodeService,
|
||||||
|
{ provide: DRIZZLE, useValue: { insert } },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
const service = moduleRef.get(DocumentCodeService);
|
||||||
|
const at = DateTime.fromUnixMs(Date.UTC(2026, 7, 24, 17, 0, 0));
|
||||||
|
const code = await service.nextCode('SR', at);
|
||||||
|
expect(code).toMatch(/^SR-\d{8}-0003$/);
|
||||||
|
expect(insert).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||||
|
import { DRIZZLE, type DrizzleDB } from '../../../database/database.module';
|
||||||
|
import { documentSequences } from '../../../database/document-sequences-table';
|
||||||
|
import { formatDocumentCode, periodFromDateTime } from './document-code';
|
||||||
|
import type { DocumentPrefix } from './document-prefixes';
|
||||||
|
|
||||||
|
type QueryExecutor = Pick<DrizzleDB, 'insert'>;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DocumentCodeService {
|
||||||
|
constructor(@Inject(DRIZZLE) private readonly db: DrizzleDB) {}
|
||||||
|
|
||||||
|
async nextCode(
|
||||||
|
prefix: DocumentPrefix,
|
||||||
|
at: DateTime = DateTime.fromUnixMs(Date.now()),
|
||||||
|
executor: QueryExecutor = this.db,
|
||||||
|
): Promise<string> {
|
||||||
|
const period = periodFromDateTime(at);
|
||||||
|
const inserted = await executor
|
||||||
|
.insert(documentSequences)
|
||||||
|
.values({ prefix, period, lastValue: 1 })
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: [documentSequences.prefix, documentSequences.period],
|
||||||
|
set: { lastValue: sql`${documentSequences.lastValue} + 1` },
|
||||||
|
})
|
||||||
|
.returning({ lastValue: documentSequences.lastValue });
|
||||||
|
const lastValue = inserted[0]?.lastValue ?? 1;
|
||||||
|
return formatDocumentCode(prefix, period, lastValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||||
|
import { formatDocumentCode, periodFromDateTime } from './document-code';
|
||||||
|
|
||||||
|
describe('document code', () => {
|
||||||
|
it('formats prefix, period, and a 4-digit sequence', () => {
|
||||||
|
expect(formatDocumentCode('SR', '20260824', 1)).toBe('SR-20260824-0001');
|
||||||
|
expect(formatDocumentCode('SO', '20260824', 12)).toBe('SO-20260824-0012');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derives YYYYMMDD from DateTime in the default timezone', () => {
|
||||||
|
const at = DateTime.fromUnixMs(Date.UTC(2026, 7, 24, 0, 0, 0));
|
||||||
|
const period = periodFromDateTime(at);
|
||||||
|
expect(period).toMatch(/^\d{8}$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { DateTime } from '../../../common/value-objects/date-time/date-time';
|
||||||
|
|
||||||
|
export function periodFromDateTime(at: DateTime): string {
|
||||||
|
return at.format().slice(0, 10).replaceAll('-', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDocumentCode(
|
||||||
|
prefix: string,
|
||||||
|
period: string,
|
||||||
|
sequence: number,
|
||||||
|
): string {
|
||||||
|
return `${prefix}-${period}-${String(sequence).padStart(4, '0')}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export const DOCUMENT_PREFIXES = {
|
||||||
|
salesRequest: 'SR',
|
||||||
|
salesOrder: 'SO',
|
||||||
|
packingSlip: 'PS',
|
||||||
|
salesInvoice: 'SI',
|
||||||
|
salesPayment: 'SP',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type DocumentPrefix =
|
||||||
|
(typeof DOCUMENT_PREFIXES)[keyof typeof DOCUMENT_PREFIXES];
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import {
|
||||||
|
isValidDocumentCode,
|
||||||
|
isValidImageUrl,
|
||||||
|
isValidLatitude,
|
||||||
|
} from './sales-fields';
|
||||||
|
|
||||||
|
describe('sales fields', () => {
|
||||||
|
it('accepts generated and user document codes', () => {
|
||||||
|
expect(isValidDocumentCode('SR-20260824-0001')).toBe(true);
|
||||||
|
expect(isValidDocumentCode('REQ_01')).toBe(true);
|
||||||
|
expect(isValidDocumentCode('')).toBe(false);
|
||||||
|
expect(isValidDocumentCode('SR 1')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts http(s) image URLs only', () => {
|
||||||
|
expect(isValidImageUrl('https://cdn.example.com/a.png')).toBe(true);
|
||||||
|
expect(isValidImageUrl('ftp://x/a.png')).toBe(false);
|
||||||
|
expect(isValidImageUrl('not-a-url')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates latitude', () => {
|
||||||
|
expect(isValidLatitude(-6.2)).toBe(true);
|
||||||
|
expect(isValidLatitude(100)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
export const DOCUMENT_CODE_MAX_LENGTH = 32;
|
||||||
|
export const DOCUMENT_CODE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
||||||
|
export const DOCUMENT_NOTES_MAX_LENGTH = 1024;
|
||||||
|
export const DOCUMENT_ADDRESS_MAX_LENGTH = 255;
|
||||||
|
export const IMAGE_URL_MAX_LENGTH = 2048;
|
||||||
|
export const IMAGE_DESCRIPTION_MAX_LENGTH = 255;
|
||||||
|
|
||||||
|
export const SALES_REQUEST_STATUSES = [
|
||||||
|
'draft',
|
||||||
|
'pending',
|
||||||
|
'approved',
|
||||||
|
'rejected',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const SALES_ORDER_STATUSES = [
|
||||||
|
'draft',
|
||||||
|
'processed',
|
||||||
|
'completed',
|
||||||
|
'cancelled',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const PACKING_SLIP_STATUSES = [
|
||||||
|
'draft',
|
||||||
|
'processed',
|
||||||
|
'completed',
|
||||||
|
'cancelled',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const SALES_INVOICE_STATUSES = [
|
||||||
|
'draft',
|
||||||
|
'processed',
|
||||||
|
'partial',
|
||||||
|
'completed',
|
||||||
|
'cancelled',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const SALES_PAYMENT_STATUSES = [
|
||||||
|
'draft',
|
||||||
|
'pending',
|
||||||
|
'approved',
|
||||||
|
'rejected',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type SalesRequestStatus = (typeof SALES_REQUEST_STATUSES)[number];
|
||||||
|
export type SalesOrderStatus = (typeof SALES_ORDER_STATUSES)[number];
|
||||||
|
export type PackingSlipStatus = (typeof PACKING_SLIP_STATUSES)[number];
|
||||||
|
export type SalesInvoiceStatus = (typeof SALES_INVOICE_STATUSES)[number];
|
||||||
|
export type SalesPaymentStatus = (typeof SALES_PAYMENT_STATUSES)[number];
|
||||||
|
|
||||||
|
export function isValidDocumentCode(raw: string): boolean {
|
||||||
|
return (
|
||||||
|
typeof raw === 'string' &&
|
||||||
|
raw.length > 0 &&
|
||||||
|
raw.length <= DOCUMENT_CODE_MAX_LENGTH &&
|
||||||
|
DOCUMENT_CODE_PATTERN.test(raw)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidDocumentNotes(raw: string): boolean {
|
||||||
|
return typeof raw === 'string' && raw.length <= DOCUMENT_NOTES_MAX_LENGTH;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidDocumentAddress(raw: string): boolean {
|
||||||
|
return (
|
||||||
|
typeof raw === 'string' &&
|
||||||
|
raw.length > 0 &&
|
||||||
|
raw.length <= DOCUMENT_ADDRESS_MAX_LENGTH
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidLatitude(raw: number): boolean {
|
||||||
|
return Number.isFinite(raw) && raw >= -90 && raw <= 90;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidLongitude(raw: number): boolean {
|
||||||
|
return Number.isFinite(raw) && raw >= -180 && raw <= 180;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidImageUrl(raw: string): boolean {
|
||||||
|
if (
|
||||||
|
typeof raw !== 'string' ||
|
||||||
|
raw.length === 0 ||
|
||||||
|
raw.length > IMAGE_URL_MAX_LENGTH
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = new URL(raw);
|
||||||
|
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidImageDescription(raw: string): boolean {
|
||||||
|
return typeof raw === 'string' && raw.length <= IMAGE_DESCRIPTION_MAX_LENGTH;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCsvRecord(line: string): string[] {
|
||||||
|
const cells: string[] = [];
|
||||||
|
let current = '';
|
||||||
|
let inQuotes = false;
|
||||||
|
for (let i = 0; i < line.length; i++) {
|
||||||
|
const ch = line[i];
|
||||||
|
if (inQuotes) {
|
||||||
|
if (ch === '"') {
|
||||||
|
if (line[i + 1] === '"') {
|
||||||
|
current += '"';
|
||||||
|
i += 1;
|
||||||
|
} else {
|
||||||
|
inQuotes = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
current += ch;
|
||||||
|
}
|
||||||
|
} else if (ch === '"') {
|
||||||
|
inQuotes = true;
|
||||||
|
} else if (ch === ',') {
|
||||||
|
cells.push(current.trim());
|
||||||
|
current = '';
|
||||||
|
} else {
|
||||||
|
current += ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cells.push(current.trim());
|
||||||
|
return cells;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAllowedCsvUpload(file: {
|
||||||
|
mimetype: string;
|
||||||
|
originalname: string;
|
||||||
|
}): boolean {
|
||||||
|
return (
|
||||||
|
file.mimetype.includes('csv') ||
|
||||||
|
file.originalname.toLowerCase().endsWith('.csv')
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user