From 34d4f2c120145ad172419b2467fb5571d4490d72 Mon Sep 17 00:00:00 2001 From: shancheas Date: Tue, 25 Aug 2026 09:54:16 +0700 Subject: [PATCH] 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. --- drizzle/migrations/0008_products.sql | 20 + drizzle/migrations/0009_sales.sql | 252 ++++++++ drizzle/migrations/0010_phase2_shape.sql | 16 + drizzle/migrations/meta/_journal.json | 21 + src/app.module.ts | 2 + .../value-objects/decimal/decimal.spec.ts | 96 +++ src/common/value-objects/decimal/decimal.ts | 145 +++++ .../decimal/invalid-decimal.error.ts | 6 + src/database/document-sequences-table.ts | 17 + src/database/packing-slips-table.ts | 60 ++ src/database/products-table.ts | 30 + src/database/sales-invoices-table.ts | 71 +++ src/database/sales-orders-table.ts | 88 +++ src/database/sales-payments-table.ts | 58 ++ src/database/sales-requests-table.ts | 81 +++ src/database/schema.ts | 54 ++ .../configuration/configuration.module.ts | 17 +- .../configuration/products/dto/product.dto.ts | 208 +++++++ .../products/product-fields.spec.ts | 92 +++ .../configuration/products/product-fields.ts | 89 +++ src/modules/configuration/products/product.ts | 47 ++ .../products/products-read.controller.spec.ts | 36 ++ .../products/products-read.controller.ts | 64 ++ .../products-write.controller.spec.ts | 84 +++ .../products/products-write.controller.ts | 185 ++++++ .../configuration/products/products.module.ts | 12 + .../products/products.repository.spec.ts | 211 +++++++ .../products/products.repository.ts | 306 +++++++++ .../products/products.service.spec.ts | 168 +++++ .../products/products.service.ts | 329 ++++++++++ .../packing-slips/dto/packing-slip.dto.ts | 246 ++++++++ .../sales/packing-slips/packing-slip.ts | 74 +++ .../packing-slips-read.controller.ts | 67 ++ .../packing-slips-write.controller.ts | 198 ++++++ .../packing-slips/packing-slips.module.ts | 17 + .../packing-slips/packing-slips.repository.ts | 371 +++++++++++ .../packing-slips.service.spec.ts | 236 +++++++ .../packing-slips/packing-slips.service.ts | 506 +++++++++++++++ .../sales-invoices/dto/sales-invoice.dto.ts | 267 ++++++++ .../sales/sales-invoices/sales-invoice.ts | 85 +++ .../sales-invoices-read.controller.ts | 67 ++ .../sales-invoices-write.controller.ts | 199 ++++++ .../sales-invoices/sales-invoices.module.ts | 33 + .../sales-invoices.repository.ts | 461 ++++++++++++++ .../sales-invoices.service.spec.ts | 216 +++++++ .../sales-invoices/sales-invoices.service.ts | 567 +++++++++++++++++ .../sales/sales-orders/dto/sales-order.dto.ts | 305 +++++++++ src/modules/sales/sales-orders/sales-order.ts | 95 +++ .../sales-orders-read.controller.ts | 64 ++ .../sales-orders-write.controller.ts | 204 ++++++ .../sales/sales-orders/sales-orders.module.ts | 27 + .../sales-orders/sales-orders.repository.ts | 419 +++++++++++++ .../sales-orders/sales-orders.service.spec.ts | 154 +++++ .../sales-orders/sales-orders.service.ts | 588 ++++++++++++++++++ .../sales-payments/dto/sales-payment.dto.ts | 184 ++++++ .../sales/sales-payments/sales-payment.ts | 66 ++ .../sales-payments-read.controller.ts | 67 ++ .../sales-payments-write.controller.ts | 189 ++++++ .../sales-payments/sales-payments.module.ts | 19 + .../sales-payments.repository.ts | 381 ++++++++++++ .../sales-payments.service.spec.ts | 135 ++++ .../sales-payments/sales-payments.service.ts | 396 ++++++++++++ .../sales-requests/dto/sales-request.dto.ts | 298 +++++++++ .../sales/sales-requests/sales-request.ts | 93 +++ .../sales-requests-read.controller.ts | 67 ++ .../sales-requests-write.controller.ts | 203 ++++++ .../sales-requests/sales-requests.module.ts | 29 + .../sales-requests.repository.ts | 421 +++++++++++++ .../sales-requests.service.spec.ts | 150 +++++ .../sales-requests/sales-requests.service.ts | 523 ++++++++++++++++ src/modules/sales/sales.module.ts | 24 + .../shared/document-code.service.spec.ts | 24 + .../sales/shared/document-code.service.ts | 32 + .../sales/shared/document-code.spec.ts | 15 + src/modules/sales/shared/document-code.ts | 13 + src/modules/sales/shared/document-prefixes.ts | 10 + src/modules/sales/shared/sales-fields.spec.ts | 25 + src/modules/sales/shared/sales-fields.ts | 137 ++++ test/packing-slips.e2e-spec.ts | 163 +++++ test/products.e2e-spec.ts | 229 +++++++ test/sales-invoices.e2e-spec.ts | 188 ++++++ test/sales-payments.e2e-spec.ts | 199 ++++++ 82 files changed, 12609 insertions(+), 2 deletions(-) create mode 100644 drizzle/migrations/0008_products.sql create mode 100644 drizzle/migrations/0009_sales.sql create mode 100644 drizzle/migrations/0010_phase2_shape.sql create mode 100644 src/common/value-objects/decimal/decimal.spec.ts create mode 100644 src/common/value-objects/decimal/decimal.ts create mode 100644 src/common/value-objects/decimal/invalid-decimal.error.ts create mode 100644 src/database/document-sequences-table.ts create mode 100644 src/database/packing-slips-table.ts create mode 100644 src/database/products-table.ts create mode 100644 src/database/sales-invoices-table.ts create mode 100644 src/database/sales-orders-table.ts create mode 100644 src/database/sales-payments-table.ts create mode 100644 src/database/sales-requests-table.ts create mode 100644 src/modules/configuration/products/dto/product.dto.ts create mode 100644 src/modules/configuration/products/product-fields.spec.ts create mode 100644 src/modules/configuration/products/product-fields.ts create mode 100644 src/modules/configuration/products/product.ts create mode 100644 src/modules/configuration/products/products-read.controller.spec.ts create mode 100644 src/modules/configuration/products/products-read.controller.ts create mode 100644 src/modules/configuration/products/products-write.controller.spec.ts create mode 100644 src/modules/configuration/products/products-write.controller.ts create mode 100644 src/modules/configuration/products/products.module.ts create mode 100644 src/modules/configuration/products/products.repository.spec.ts create mode 100644 src/modules/configuration/products/products.repository.ts create mode 100644 src/modules/configuration/products/products.service.spec.ts create mode 100644 src/modules/configuration/products/products.service.ts create mode 100644 src/modules/sales/packing-slips/dto/packing-slip.dto.ts create mode 100644 src/modules/sales/packing-slips/packing-slip.ts create mode 100644 src/modules/sales/packing-slips/packing-slips-read.controller.ts create mode 100644 src/modules/sales/packing-slips/packing-slips-write.controller.ts create mode 100644 src/modules/sales/packing-slips/packing-slips.module.ts create mode 100644 src/modules/sales/packing-slips/packing-slips.repository.ts create mode 100644 src/modules/sales/packing-slips/packing-slips.service.spec.ts create mode 100644 src/modules/sales/packing-slips/packing-slips.service.ts create mode 100644 src/modules/sales/sales-invoices/dto/sales-invoice.dto.ts create mode 100644 src/modules/sales/sales-invoices/sales-invoice.ts create mode 100644 src/modules/sales/sales-invoices/sales-invoices-read.controller.ts create mode 100644 src/modules/sales/sales-invoices/sales-invoices-write.controller.ts create mode 100644 src/modules/sales/sales-invoices/sales-invoices.module.ts create mode 100644 src/modules/sales/sales-invoices/sales-invoices.repository.ts create mode 100644 src/modules/sales/sales-invoices/sales-invoices.service.spec.ts create mode 100644 src/modules/sales/sales-invoices/sales-invoices.service.ts create mode 100644 src/modules/sales/sales-orders/dto/sales-order.dto.ts create mode 100644 src/modules/sales/sales-orders/sales-order.ts create mode 100644 src/modules/sales/sales-orders/sales-orders-read.controller.ts create mode 100644 src/modules/sales/sales-orders/sales-orders-write.controller.ts create mode 100644 src/modules/sales/sales-orders/sales-orders.module.ts create mode 100644 src/modules/sales/sales-orders/sales-orders.repository.ts create mode 100644 src/modules/sales/sales-orders/sales-orders.service.spec.ts create mode 100644 src/modules/sales/sales-orders/sales-orders.service.ts create mode 100644 src/modules/sales/sales-payments/dto/sales-payment.dto.ts create mode 100644 src/modules/sales/sales-payments/sales-payment.ts create mode 100644 src/modules/sales/sales-payments/sales-payments-read.controller.ts create mode 100644 src/modules/sales/sales-payments/sales-payments-write.controller.ts create mode 100644 src/modules/sales/sales-payments/sales-payments.module.ts create mode 100644 src/modules/sales/sales-payments/sales-payments.repository.ts create mode 100644 src/modules/sales/sales-payments/sales-payments.service.spec.ts create mode 100644 src/modules/sales/sales-payments/sales-payments.service.ts create mode 100644 src/modules/sales/sales-requests/dto/sales-request.dto.ts create mode 100644 src/modules/sales/sales-requests/sales-request.ts create mode 100644 src/modules/sales/sales-requests/sales-requests-read.controller.ts create mode 100644 src/modules/sales/sales-requests/sales-requests-write.controller.ts create mode 100644 src/modules/sales/sales-requests/sales-requests.module.ts create mode 100644 src/modules/sales/sales-requests/sales-requests.repository.ts create mode 100644 src/modules/sales/sales-requests/sales-requests.service.spec.ts create mode 100644 src/modules/sales/sales-requests/sales-requests.service.ts create mode 100644 src/modules/sales/sales.module.ts create mode 100644 src/modules/sales/shared/document-code.service.spec.ts create mode 100644 src/modules/sales/shared/document-code.service.ts create mode 100644 src/modules/sales/shared/document-code.spec.ts create mode 100644 src/modules/sales/shared/document-code.ts create mode 100644 src/modules/sales/shared/document-prefixes.ts create mode 100644 src/modules/sales/shared/sales-fields.spec.ts create mode 100644 src/modules/sales/shared/sales-fields.ts create mode 100644 test/packing-slips.e2e-spec.ts create mode 100644 test/products.e2e-spec.ts create mode 100644 test/sales-invoices.e2e-spec.ts create mode 100644 test/sales-payments.e2e-spec.ts diff --git a/drizzle/migrations/0008_products.sql b/drizzle/migrations/0008_products.sql new file mode 100644 index 0000000..cbd8d04 --- /dev/null +++ b/drizzle/migrations/0008_products.sql @@ -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); diff --git a/drizzle/migrations/0009_sales.sql b/drizzle/migrations/0009_sales.sql new file mode 100644 index 0000000..ddf9d83 --- /dev/null +++ b/drizzle/migrations/0009_sales.sql @@ -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; diff --git a/drizzle/migrations/0010_phase2_shape.sql b/drizzle/migrations/0010_phase2_shape.sql new file mode 100644 index 0000000..928bca7 --- /dev/null +++ b/drizzle/migrations/0010_phase2_shape.sql @@ -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"; diff --git a/drizzle/migrations/meta/_journal.json b/drizzle/migrations/meta/_journal.json index 030281d..524bcba 100644 --- a/drizzle/migrations/meta/_journal.json +++ b/drizzle/migrations/meta/_journal.json @@ -57,6 +57,27 @@ "when": 1787555000000, "tag": "0007_employees", "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 } ] } \ No newline at end of file diff --git a/src/app.module.ts b/src/app.module.ts index fd2d1b9..480643a 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -6,6 +6,7 @@ import loadEnv from './config/env'; import { DatabaseModule } from './database/database.module'; import { AuthModule } from './modules/auth/auth.module'; import { ConfigurationModule } from './modules/configuration/configuration.module'; +import { SalesModule } from './modules/sales/sales.module'; import { PrivilegesModule } from './modules/privileges/privileges.module'; import { UsersModule } from './modules/users/users.module'; @@ -20,6 +21,7 @@ import { UsersModule } from './modules/users/users.module'; AuthModule, PrivilegesModule, ConfigurationModule, + SalesModule, ], controllers: [AppController], providers: [AppService], diff --git a/src/common/value-objects/decimal/decimal.spec.ts b/src/common/value-objects/decimal/decimal.spec.ts new file mode 100644 index 0000000..e16af08 --- /dev/null +++ b/src/common/value-objects/decimal/decimal.spec.ts @@ -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); + }); + }); +}); diff --git a/src/common/value-objects/decimal/decimal.ts b/src/common/value-objects/decimal/decimal.ts new file mode 100644 index 0000000..c72f02e --- /dev/null +++ b/src/common/value-objects/decimal/decimal.ts @@ -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; + } +} diff --git a/src/common/value-objects/decimal/invalid-decimal.error.ts b/src/common/value-objects/decimal/invalid-decimal.error.ts new file mode 100644 index 0000000..730cbf6 --- /dev/null +++ b/src/common/value-objects/decimal/invalid-decimal.error.ts @@ -0,0 +1,6 @@ +export class InvalidDecimalError extends Error { + constructor() { + super('Invalid decimal'); + this.name = 'InvalidDecimalError'; + } +} diff --git a/src/database/document-sequences-table.ts b/src/database/document-sequences-table.ts new file mode 100644 index 0000000..7ca29d7 --- /dev/null +++ b/src/database/document-sequences-table.ts @@ -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; diff --git a/src/database/packing-slips-table.ts b/src/database/packing-slips-table.ts new file mode 100644 index 0000000..11b3d17 --- /dev/null +++ b/src/database/packing-slips-table.ts @@ -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; diff --git a/src/database/products-table.ts b/src/database/products-table.ts new file mode 100644 index 0000000..39c59a2 --- /dev/null +++ b/src/database/products-table.ts @@ -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; diff --git a/src/database/sales-invoices-table.ts b/src/database/sales-invoices-table.ts new file mode 100644 index 0000000..332e785 --- /dev/null +++ b/src/database/sales-invoices-table.ts @@ -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; diff --git a/src/database/sales-orders-table.ts b/src/database/sales-orders-table.ts new file mode 100644 index 0000000..75f0ad9 --- /dev/null +++ b/src/database/sales-orders-table.ts @@ -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; diff --git a/src/database/sales-payments-table.ts b/src/database/sales-payments-table.ts new file mode 100644 index 0000000..71ec6ec --- /dev/null +++ b/src/database/sales-payments-table.ts @@ -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; diff --git a/src/database/sales-requests-table.ts b/src/database/sales-requests-table.ts new file mode 100644 index 0000000..abe662a --- /dev/null +++ b/src/database/sales-requests-table.ts @@ -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; diff --git a/src/database/schema.ts b/src/database/schema.ts index 1a17e81..ad37520 100644 --- a/src/database/schema.ts +++ b/src/database/schema.ts @@ -156,3 +156,57 @@ export { type EmployeeRow, type NewEmployeeRow, } from './employees-table'; + +export { + products, + type ProductRow, + type NewProductRow, +} from './products-table'; + +export { + documentSequences, + type DocumentSequenceRow, + type NewDocumentSequenceRow, +} from './document-sequences-table'; +export { + salesRequestImages, + salesRequestProducts, + salesRequests, + type NewSalesRequestRow, + type SalesRequestImageRow, + type SalesRequestProductRow, + type SalesRequestRow, +} from './sales-requests-table'; +export { + salesOrderImages, + salesOrderProducts, + salesOrders, + type NewSalesOrderRow, + type SalesOrderImageRow, + type SalesOrderProductRow, + type SalesOrderRow, +} from './sales-orders-table'; + +export { + packingSlipProducts, + packingSlips, + type NewPackingSlipRow, + type PackingSlipProductRow, + type PackingSlipRow, +} from './packing-slips-table'; +export { + salesInvoiceProducts, + salesInvoices, + type NewSalesInvoiceRow, + type SalesInvoiceProductRow, + type SalesInvoiceRow, +} from './sales-invoices-table'; +export { + salesPaymentImages, + salesPaymentInvoices, + salesPayments, + type NewSalesPaymentRow, + type SalesPaymentImageRow, + type SalesPaymentInvoiceRow, + type SalesPaymentRow, +} from './sales-payments-table'; diff --git a/src/modules/configuration/configuration.module.ts b/src/modules/configuration/configuration.module.ts index d614209..9efbde6 100644 --- a/src/modules/configuration/configuration.module.ts +++ b/src/modules/configuration/configuration.module.ts @@ -3,9 +3,22 @@ import { BranchesModule } from './branches/branches.module'; import { CustomersModule } from './customers/customers.module'; import { DivisionsModule } from './divisions/divisions.module'; import { EmployeesModule } from './employees/employees.module'; +import { ProductsModule } from './products/products.module'; @Module({ - imports: [DivisionsModule, BranchesModule, CustomersModule, EmployeesModule], - exports: [DivisionsModule, BranchesModule, CustomersModule, EmployeesModule], + imports: [ + DivisionsModule, + BranchesModule, + CustomersModule, + EmployeesModule, + ProductsModule, + ], + exports: [ + DivisionsModule, + BranchesModule, + CustomersModule, + EmployeesModule, + ProductsModule, + ], }) export class ConfigurationModule {} diff --git a/src/modules/configuration/products/dto/product.dto.ts b/src/modules/configuration/products/dto/product.dto.ts new file mode 100644 index 0000000..1b006b4 --- /dev/null +++ b/src/modules/configuration/products/dto/product.dto.ts @@ -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; +} diff --git a/src/modules/configuration/products/product-fields.spec.ts b/src/modules/configuration/products/product-fields.spec.ts new file mode 100644 index 0000000..fa46dae --- /dev/null +++ b/src/modules/configuration/products/product-fields.spec.ts @@ -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); + }); + }); +}); diff --git a/src/modules/configuration/products/product-fields.ts b/src/modules/configuration/products/product-fields.ts new file mode 100644 index 0000000..8499756 --- /dev/null +++ b/src/modules/configuration/products/product-fields.ts @@ -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') + ); +} diff --git a/src/modules/configuration/products/product.ts b/src/modules/configuration/products/product.ts new file mode 100644 index 0000000..5b2fe4d --- /dev/null +++ b/src/modules/configuration/products/product.ts @@ -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; +}; diff --git a/src/modules/configuration/products/products-read.controller.spec.ts b/src/modules/configuration/products/products-read.controller.spec.ts new file mode 100644 index 0000000..f47be9e --- /dev/null +++ b/src/modules/configuration/products/products-read.controller.spec.ts @@ -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', + }); + }); +}); diff --git a/src/modules/configuration/products/products-read.controller.ts b/src/modules/configuration/products/products-read.controller.ts new file mode 100644 index 0000000..c6c559c --- /dev/null +++ b/src/modules/configuration/products/products-read.controller.ts @@ -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> { + 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 { + return this.productsService.findById(id); + } +} + +void PaginationMetaDto; diff --git a/src/modules/configuration/products/products-write.controller.spec.ts b/src/modules/configuration/products/products-write.controller.spec.ts new file mode 100644 index 0000000..8d7a36e --- /dev/null +++ b/src/modules/configuration/products/products-write.controller.spec.ts @@ -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'); + }); +}); diff --git a/src/modules/configuration/products/products-write.controller.ts b/src/modules/configuration/products/products-write.controller.ts new file mode 100644 index 0000000..2d186bc --- /dev/null +++ b/src/modules/configuration/products/products-write.controller.ts @@ -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 { + 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 { + 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 { + 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 { + await this.productsService.delete(id); + } +} diff --git a/src/modules/configuration/products/products.module.ts b/src/modules/configuration/products/products.module.ts new file mode 100644 index 0000000..0953f1b --- /dev/null +++ b/src/modules/configuration/products/products.module.ts @@ -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 {} diff --git a/src/modules/configuration/products/products.repository.spec.ts b/src/modules/configuration/products/products.repository.spec.ts new file mode 100644 index 0000000..5e7af7c --- /dev/null +++ b/src/modules/configuration/products/products.repository.spec.ts @@ -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); + }); +}); diff --git a/src/modules/configuration/products/products.repository.ts b/src/modules/configuration/products/products.repository.ts new file mode 100644 index 0000000..80d2375 --- /dev/null +++ b/src/modules/configuration/products/products.repository.ts @@ -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(qb: T, filters: ListProductsFilters): T { + void filters; + return qb; + } + + async findById(id: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 }; + } +} diff --git a/src/modules/configuration/products/products.service.spec.ts b/src/modules/configuration/products/products.service.spec.ts new file mode 100644 index 0000000..493d62d --- /dev/null +++ b/src/modules/configuration/products/products.service.spec.ts @@ -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); + }); +}); diff --git a/src/modules/configuration/products/products.service.ts b/src/modules/configuration/products/products.service.ts new file mode 100644 index 0000000..07b133b --- /dev/null +++ b/src/modules/configuration/products/products.service.ts @@ -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>> { + 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> { + 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> { + 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> { + 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> { + 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 { + 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; + } + } +} diff --git a/src/modules/sales/packing-slips/dto/packing-slip.dto.ts b/src/modules/sales/packing-slips/dto/packing-slip.dto.ts new file mode 100644 index 0000000..4c5e005 --- /dev/null +++ b/src/modules/sales/packing-slips/dto/packing-slip.dto.ts @@ -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; +} diff --git a/src/modules/sales/packing-slips/packing-slip.ts b/src/modules/sales/packing-slips/packing-slip.ts new file mode 100644 index 0000000..eac2a8b --- /dev/null +++ b/src/modules/sales/packing-slips/packing-slip.ts @@ -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; +}; diff --git a/src/modules/sales/packing-slips/packing-slips-read.controller.ts b/src/modules/sales/packing-slips/packing-slips-read.controller.ts new file mode 100644 index 0000000..c2ee727 --- /dev/null +++ b/src/modules/sales/packing-slips/packing-slips-read.controller.ts @@ -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> { + 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 { + return this.packingSlipsService.findById(id); + } +} + +void PaginationMetaDto; diff --git a/src/modules/sales/packing-slips/packing-slips-write.controller.ts b/src/modules/sales/packing-slips/packing-slips-write.controller.ts new file mode 100644 index 0000000..3d98533 --- /dev/null +++ b/src/modules/sales/packing-slips/packing-slips-write.controller.ts @@ -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 { + 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 { + 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 { + 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 { + await this.packingSlipsService.delete(id); + } +} diff --git a/src/modules/sales/packing-slips/packing-slips.module.ts b/src/modules/sales/packing-slips/packing-slips.module.ts new file mode 100644 index 0000000..3d93796 --- /dev/null +++ b/src/modules/sales/packing-slips/packing-slips.module.ts @@ -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 {} diff --git a/src/modules/sales/packing-slips/packing-slips.repository.ts b/src/modules/sales/packing-slips/packing-slips.repository.ts new file mode 100644 index 0000000..cc40cb2 --- /dev/null +++ b/src/modules/sales/packing-slips/packing-slips.repository.ts @@ -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; + +@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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + return executor + .select() + .from(packingSlipProducts) + .where(eq(packingSlipProducts.packingSlipId, packingSlipId)); + } + + private async replaceProducts( + executor: QueryExecutor, + packingSlipId: string, + products: readonly PackingSlipLineInput[], + ): Promise { + 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 }; + } +} diff --git a/src/modules/sales/packing-slips/packing-slips.service.spec.ts b/src/modules/sales/packing-slips/packing-slips.service.spec.ts new file mode 100644 index 0000000..85a0720 --- /dev/null +++ b/src/modules/sales/packing-slips/packing-slips.service.spec.ts @@ -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', + ); + }); +}); diff --git a/src/modules/sales/packing-slips/packing-slips.service.ts b/src/modules/sales/packing-slips/packing-slips.service.ts new file mode 100644 index 0000000..bdc78f8 --- /dev/null +++ b/src/modules/sales/packing-slips/packing-slips.service.ts @@ -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> + > { + 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> { + 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> { + 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> { + 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> { + 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 { + 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 { + 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 { + if (input.customerId) { + await this.customersService.findById(input.customerId); + } + } + + private async assertLines( + lines: SalesLineBody[], + ): Promise { + 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; + } + } +} diff --git a/src/modules/sales/sales-invoices/dto/sales-invoice.dto.ts b/src/modules/sales/sales-invoices/dto/sales-invoice.dto.ts new file mode 100644 index 0000000..abef278 --- /dev/null +++ b/src/modules/sales/sales-invoices/dto/sales-invoice.dto.ts @@ -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; +} diff --git a/src/modules/sales/sales-invoices/sales-invoice.ts b/src/modules/sales/sales-invoices/sales-invoice.ts new file mode 100644 index 0000000..d880ec2 --- /dev/null +++ b/src/modules/sales/sales-invoices/sales-invoice.ts @@ -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; +}; diff --git a/src/modules/sales/sales-invoices/sales-invoices-read.controller.ts b/src/modules/sales/sales-invoices/sales-invoices-read.controller.ts new file mode 100644 index 0000000..0f6b251 --- /dev/null +++ b/src/modules/sales/sales-invoices/sales-invoices-read.controller.ts @@ -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> { + 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 { + return this.salesInvoicesService.findById(id); + } +} + +void PaginationMetaDto; diff --git a/src/modules/sales/sales-invoices/sales-invoices-write.controller.ts b/src/modules/sales/sales-invoices/sales-invoices-write.controller.ts new file mode 100644 index 0000000..0c5f174 --- /dev/null +++ b/src/modules/sales/sales-invoices/sales-invoices-write.controller.ts @@ -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 { + 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 { + 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 { + 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 { + await this.salesInvoicesService.delete(id); + } +} diff --git a/src/modules/sales/sales-invoices/sales-invoices.module.ts b/src/modules/sales/sales-invoices/sales-invoices.module.ts new file mode 100644 index 0000000..237f10d --- /dev/null +++ b/src/modules/sales/sales-invoices/sales-invoices.module.ts @@ -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 {} diff --git a/src/modules/sales/sales-invoices/sales-invoices.repository.ts b/src/modules/sales/sales-invoices/sales-invoices.repository.ts new file mode 100644 index 0000000..470e390 --- /dev/null +++ b/src/modules/sales/sales-invoices/sales-invoices.repository.ts @@ -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; + +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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const products = await this.selectProducts(this.db, invoiceId); + return this.totalsFrom(invoiceId, products, this.db); + } + + async refreshStoredBalance(invoiceId: string): Promise { + 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 { + 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 { + return executor + .select() + .from(salesInvoiceProducts) + .where(eq(salesInvoiceProducts.salesInvoiceId, salesInvoiceId)); + } + + private async replaceProducts( + executor: QueryExecutor, + salesInvoiceId: string, + products: readonly SalesInvoiceLineInput[], + ): Promise { + 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 { + 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 }; + } +} diff --git a/src/modules/sales/sales-invoices/sales-invoices.service.spec.ts b/src/modules/sales/sales-invoices/sales-invoices.service.spec.ts new file mode 100644 index 0000000..3bb4b37 --- /dev/null +++ b/src/modules/sales/sales-invoices/sales-invoices.service.spec.ts @@ -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(); + }); +}); diff --git a/src/modules/sales/sales-invoices/sales-invoices.service.ts b/src/modules/sales/sales-invoices/sales-invoices.service.ts new file mode 100644 index 0000000..42a130c --- /dev/null +++ b/src/modules/sales/sales-invoices/sales-invoices.service.ts @@ -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> + > { + 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> { + 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> { + 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> { + 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> { + 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> { + 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 { + 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 { + 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 { + 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 { + 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; + } + } +} diff --git a/src/modules/sales/sales-orders/dto/sales-order.dto.ts b/src/modules/sales/sales-orders/dto/sales-order.dto.ts new file mode 100644 index 0000000..1f765e1 --- /dev/null +++ b/src/modules/sales/sales-orders/dto/sales-order.dto.ts @@ -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; +} diff --git a/src/modules/sales/sales-orders/sales-order.ts b/src/modules/sales/sales-orders/sales-order.ts new file mode 100644 index 0000000..49ae87a --- /dev/null +++ b/src/modules/sales/sales-orders/sales-order.ts @@ -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; +}; diff --git a/src/modules/sales/sales-orders/sales-orders-read.controller.ts b/src/modules/sales/sales-orders/sales-orders-read.controller.ts new file mode 100644 index 0000000..1872181 --- /dev/null +++ b/src/modules/sales/sales-orders/sales-orders-read.controller.ts @@ -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> { + 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 { + return this.salesOrdersService.findById(id); + } +} + +void PaginationMetaDto; diff --git a/src/modules/sales/sales-orders/sales-orders-write.controller.ts b/src/modules/sales/sales-orders/sales-orders-write.controller.ts new file mode 100644 index 0000000..5bf94f6 --- /dev/null +++ b/src/modules/sales/sales-orders/sales-orders-write.controller.ts @@ -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 { + 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 { + 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 { + 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 { + await this.salesOrdersService.delete(id); + } +} diff --git a/src/modules/sales/sales-orders/sales-orders.module.ts b/src/modules/sales/sales-orders/sales-orders.module.ts new file mode 100644 index 0000000..07c69f8 --- /dev/null +++ b/src/modules/sales/sales-orders/sales-orders.module.ts @@ -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 {} diff --git a/src/modules/sales/sales-orders/sales-orders.repository.ts b/src/modules/sales/sales-orders/sales-orders.repository.ts new file mode 100644 index 0000000..710e802 --- /dev/null +++ b/src/modules/sales/sales-orders/sales-orders.repository.ts @@ -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; + +@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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + return executor + .select() + .from(salesOrderProducts) + .where(eq(salesOrderProducts.salesOrderId, salesOrderId)); + } + + private async selectImages( + executor: QueryExecutor, + salesOrderId: string, + ): Promise { + return executor + .select() + .from(salesOrderImages) + .where(eq(salesOrderImages.salesOrderId, salesOrderId)); + } + + private async replaceProducts( + executor: QueryExecutor, + salesOrderId: string, + products: readonly SalesOrderLineInput[], + ): Promise { + 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 { + 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 }; + } +} diff --git a/src/modules/sales/sales-orders/sales-orders.service.spec.ts b/src/modules/sales/sales-orders/sales-orders.service.spec.ts new file mode 100644 index 0000000..f8ea58c --- /dev/null +++ b/src/modules/sales/sales-orders/sales-orders.service.spec.ts @@ -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', + ); + }); +}); diff --git a/src/modules/sales/sales-orders/sales-orders.service.ts b/src/modules/sales/sales-orders/sales-orders.service.ts new file mode 100644 index 0000000..98fb595 --- /dev/null +++ b/src/modules/sales/sales-orders/sales-orders.service.ts @@ -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>> { + 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> { + 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> { + 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> { + 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> { + 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 { + 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 { + 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 { + 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 { + 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; + } + } +} diff --git a/src/modules/sales/sales-payments/dto/sales-payment.dto.ts b/src/modules/sales/sales-payments/dto/sales-payment.dto.ts new file mode 100644 index 0000000..eecc3e6 --- /dev/null +++ b/src/modules/sales/sales-payments/dto/sales-payment.dto.ts @@ -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; +} diff --git a/src/modules/sales/sales-payments/sales-payment.ts b/src/modules/sales/sales-payments/sales-payment.ts new file mode 100644 index 0000000..374f2f4 --- /dev/null +++ b/src/modules/sales/sales-payments/sales-payment.ts @@ -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; +}; diff --git a/src/modules/sales/sales-payments/sales-payments-read.controller.ts b/src/modules/sales/sales-payments/sales-payments-read.controller.ts new file mode 100644 index 0000000..b371fa5 --- /dev/null +++ b/src/modules/sales/sales-payments/sales-payments-read.controller.ts @@ -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> { + 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 { + return this.salesPaymentsService.findById(id); + } +} + +void PaginationMetaDto; diff --git a/src/modules/sales/sales-payments/sales-payments-write.controller.ts b/src/modules/sales/sales-payments/sales-payments-write.controller.ts new file mode 100644 index 0000000..84891e8 --- /dev/null +++ b/src/modules/sales/sales-payments/sales-payments-write.controller.ts @@ -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 { + 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 { + 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 { + 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 { + await this.salesPaymentsService.delete(id); + } +} diff --git a/src/modules/sales/sales-payments/sales-payments.module.ts b/src/modules/sales/sales-payments/sales-payments.module.ts new file mode 100644 index 0000000..4252282 --- /dev/null +++ b/src/modules/sales/sales-payments/sales-payments.module.ts @@ -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 {} diff --git a/src/modules/sales/sales-payments/sales-payments.repository.ts b/src/modules/sales/sales-payments/sales-payments.repository.ts new file mode 100644 index 0000000..cd45d2e --- /dev/null +++ b/src/modules/sales/sales-payments/sales-payments.repository.ts @@ -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; + +@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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + return executor + .select() + .from(salesPaymentInvoices) + .where(eq(salesPaymentInvoices.salesPaymentId, salesPaymentId)); + } + + private async selectImages( + executor: QueryExecutor, + salesPaymentId: string, + ): Promise { + return executor + .select() + .from(salesPaymentImages) + .where(eq(salesPaymentImages.salesPaymentId, salesPaymentId)); + } + + private async replaceAllocations( + executor: QueryExecutor, + salesPaymentId: string, + invoices: readonly SalesPaymentAllocationInput[], + ): Promise { + 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 { + 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 }; + } +} diff --git a/src/modules/sales/sales-payments/sales-payments.service.spec.ts b/src/modules/sales/sales-payments/sales-payments.service.spec.ts new file mode 100644 index 0000000..9c2d74b --- /dev/null +++ b/src/modules/sales/sales-payments/sales-payments.service.spec.ts @@ -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(); + }); +}); diff --git a/src/modules/sales/sales-payments/sales-payments.service.ts b/src/modules/sales/sales-payments/sales-payments.service.ts new file mode 100644 index 0000000..288f952 --- /dev/null +++ b/src/modules/sales/sales-payments/sales-payments.service.ts @@ -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> + > { + 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> { + 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> { + 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> { + 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> { + 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 { + 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 { + 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 { + const extras = new Map(); + 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 { + 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; + } + } +} diff --git a/src/modules/sales/sales-requests/dto/sales-request.dto.ts b/src/modules/sales/sales-requests/dto/sales-request.dto.ts new file mode 100644 index 0000000..6f90f80 --- /dev/null +++ b/src/modules/sales/sales-requests/dto/sales-request.dto.ts @@ -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; +} diff --git a/src/modules/sales/sales-requests/sales-request.ts b/src/modules/sales/sales-requests/sales-request.ts new file mode 100644 index 0000000..ddf494b --- /dev/null +++ b/src/modules/sales/sales-requests/sales-request.ts @@ -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; +}; diff --git a/src/modules/sales/sales-requests/sales-requests-read.controller.ts b/src/modules/sales/sales-requests/sales-requests-read.controller.ts new file mode 100644 index 0000000..b304215 --- /dev/null +++ b/src/modules/sales/sales-requests/sales-requests-read.controller.ts @@ -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> { + 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 { + return this.salesRequestsService.findById(id); + } +} + +void PaginationMetaDto; diff --git a/src/modules/sales/sales-requests/sales-requests-write.controller.ts b/src/modules/sales/sales-requests/sales-requests-write.controller.ts new file mode 100644 index 0000000..41ff555 --- /dev/null +++ b/src/modules/sales/sales-requests/sales-requests-write.controller.ts @@ -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 { + 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 { + 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 { + 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 { + await this.salesRequestsService.delete(id); + } +} diff --git a/src/modules/sales/sales-requests/sales-requests.module.ts b/src/modules/sales/sales-requests/sales-requests.module.ts new file mode 100644 index 0000000..e03b1e2 --- /dev/null +++ b/src/modules/sales/sales-requests/sales-requests.module.ts @@ -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 {} diff --git a/src/modules/sales/sales-requests/sales-requests.repository.ts b/src/modules/sales/sales-requests/sales-requests.repository.ts new file mode 100644 index 0000000..6a55e75 --- /dev/null +++ b/src/modules/sales/sales-requests/sales-requests.repository.ts @@ -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; + +@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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + return executor + .select() + .from(salesRequestProducts) + .where(eq(salesRequestProducts.salesRequestId, salesRequestId)); + } + + private async selectImages( + executor: QueryExecutor, + salesRequestId: string, + ): Promise { + return executor + .select() + .from(salesRequestImages) + .where(eq(salesRequestImages.salesRequestId, salesRequestId)); + } + + private async replaceProducts( + executor: QueryExecutor, + salesRequestId: string, + products: readonly SalesRequestLineInput[], + ): Promise { + 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 { + 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 }; + } +} diff --git a/src/modules/sales/sales-requests/sales-requests.service.spec.ts b/src/modules/sales/sales-requests/sales-requests.service.spec.ts new file mode 100644 index 0000000..8b5d1a8 --- /dev/null +++ b/src/modules/sales/sales-requests/sales-requests.service.spec.ts @@ -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', + ); + }); +}); diff --git a/src/modules/sales/sales-requests/sales-requests.service.ts b/src/modules/sales/sales-requests/sales-requests.service.ts new file mode 100644 index 0000000..4b37a8c --- /dev/null +++ b/src/modules/sales/sales-requests/sales-requests.service.ts @@ -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> + > { + 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> { + 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> { + 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> { + 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> { + 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 { + 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 { + 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 { + 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 { + 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; + } + } +} diff --git a/src/modules/sales/sales.module.ts b/src/modules/sales/sales.module.ts new file mode 100644 index 0000000..1dacaa8 --- /dev/null +++ b/src/modules/sales/sales.module.ts @@ -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 {} diff --git a/src/modules/sales/shared/document-code.service.spec.ts b/src/modules/sales/shared/document-code.service.spec.ts new file mode 100644 index 0000000..568b7db --- /dev/null +++ b/src/modules/sales/shared/document-code.service.spec.ts @@ -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(); + }); +}); diff --git a/src/modules/sales/shared/document-code.service.ts b/src/modules/sales/shared/document-code.service.ts new file mode 100644 index 0000000..68b9097 --- /dev/null +++ b/src/modules/sales/shared/document-code.service.ts @@ -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; + +@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 { + 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); + } +} diff --git a/src/modules/sales/shared/document-code.spec.ts b/src/modules/sales/shared/document-code.spec.ts new file mode 100644 index 0000000..2a5273d --- /dev/null +++ b/src/modules/sales/shared/document-code.spec.ts @@ -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}$/); + }); +}); diff --git a/src/modules/sales/shared/document-code.ts b/src/modules/sales/shared/document-code.ts new file mode 100644 index 0000000..d76ce85 --- /dev/null +++ b/src/modules/sales/shared/document-code.ts @@ -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')}`; +} diff --git a/src/modules/sales/shared/document-prefixes.ts b/src/modules/sales/shared/document-prefixes.ts new file mode 100644 index 0000000..d3869fe --- /dev/null +++ b/src/modules/sales/shared/document-prefixes.ts @@ -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]; diff --git a/src/modules/sales/shared/sales-fields.spec.ts b/src/modules/sales/shared/sales-fields.spec.ts new file mode 100644 index 0000000..1629569 --- /dev/null +++ b/src/modules/sales/shared/sales-fields.spec.ts @@ -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); + }); +}); diff --git a/src/modules/sales/shared/sales-fields.ts b/src/modules/sales/shared/sales-fields.ts new file mode 100644 index 0000000..548a69d --- /dev/null +++ b/src/modules/sales/shared/sales-fields.ts @@ -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') + ); +} diff --git a/test/packing-slips.e2e-spec.ts b/test/packing-slips.e2e-spec.ts new file mode 100644 index 0000000..2f5b60c --- /dev/null +++ b/test/packing-slips.e2e-spec.ts @@ -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; + 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); + }); +}); diff --git a/test/products.e2e-spec.ts b/test/products.e2e-spec.ts new file mode 100644 index 0000000..5fc5f27 --- /dev/null +++ b/test/products.e2e-spec.ts @@ -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; + 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 }); + }); +}); diff --git a/test/sales-invoices.e2e-spec.ts b/test/sales-invoices.e2e-spec.ts new file mode 100644 index 0000000..1885371 --- /dev/null +++ b/test/sales-invoices.e2e-spec.ts @@ -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; + 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); + }); +}); diff --git a/test/sales-payments.e2e-spec.ts b/test/sales-payments.e2e-spec.ts new file mode 100644 index 0000000..bfc6a1e --- /dev/null +++ b/test/sales-payments.e2e-spec.ts @@ -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; + 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', + }); + }); +});