From 9b016dc30a0035eaecec3cd9bd99fc6d9609ac3f Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Tue, 14 Oct 2025 22:16:50 +0700 Subject: [PATCH] (BE-58,,59): extend db schema & build stock transfer api - Extend DB schema for stock transfers - Build stock transfer API (create,) --- ...1014024355_create_stock_transfers.down.sql | 4 + ...251014024355_create_stock_transfers.up.sql | 57 +++++ ...642_create_stock_transfer_details.down.sql | 2 + ...24642_create_stock_transfer_details.up.sql | 49 ++++ ..._create_stock_transfer_deliveries.down.sql | 2 + ...56_create_stock_transfer_deliveries.up.sql | 43 ++++ ...ate_stock_transfer_delivery_items.down.sql | 2 + ...reate_stock_transfer_delivery_items.up.sql | 35 +++ internal/entities/stock-transfer.go | 22 ++ internal/entities/stock_log.go | 1 + internal/entities/stock_transfer_delivery.go | 24 ++ .../entities/stock_transfer_delivery_item.go | 12 + internal/entities/stock_transfer_detail.go | 22 ++ internal/modules/inventory/route.go | 2 + .../controllers/transfer.controller.go | 92 +++++++ .../inventory/transfers/dto/transfer.dto.go | 64 +++++ .../modules/inventory/transfers/module.go | 31 +++ .../repositories/stock_transfer.repository.go | 34 +++ .../stock_transfer_delivery.repository.go | 22 ++ ...stock_transfer_delivery_item.repository.go | 22 ++ .../stock_transfer_detail.repository.go | 22 ++ internal/modules/inventory/transfers/route.go | 27 ++ .../transfers/services/transfer.service.go | 240 ++++++++++++++++++ .../validations/transfer.validation.go | 41 +++ internal/utils/time.go | 25 ++ 25 files changed, 897 insertions(+) create mode 100644 internal/database/migrations/20251014024355_create_stock_transfers.down.sql create mode 100644 internal/database/migrations/20251014024355_create_stock_transfers.up.sql create mode 100644 internal/database/migrations/20251014024642_create_stock_transfer_details.down.sql create mode 100644 internal/database/migrations/20251014024642_create_stock_transfer_details.up.sql create mode 100644 internal/database/migrations/20251014024656_create_stock_transfer_deliveries.down.sql create mode 100644 internal/database/migrations/20251014024656_create_stock_transfer_deliveries.up.sql create mode 100644 internal/database/migrations/20251014024702_create_stock_transfer_delivery_items.down.sql create mode 100644 internal/database/migrations/20251014024702_create_stock_transfer_delivery_items.up.sql create mode 100644 internal/entities/stock-transfer.go create mode 100644 internal/entities/stock_transfer_delivery.go create mode 100644 internal/entities/stock_transfer_delivery_item.go create mode 100644 internal/entities/stock_transfer_detail.go create mode 100644 internal/modules/inventory/transfers/controllers/transfer.controller.go create mode 100644 internal/modules/inventory/transfers/dto/transfer.dto.go create mode 100644 internal/modules/inventory/transfers/module.go create mode 100644 internal/modules/inventory/transfers/repositories/stock_transfer.repository.go create mode 100644 internal/modules/inventory/transfers/repositories/stock_transfer_delivery.repository.go create mode 100644 internal/modules/inventory/transfers/repositories/stock_transfer_delivery_item.repository.go create mode 100644 internal/modules/inventory/transfers/repositories/stock_transfer_detail.repository.go create mode 100644 internal/modules/inventory/transfers/route.go create mode 100644 internal/modules/inventory/transfers/services/transfer.service.go create mode 100644 internal/modules/inventory/transfers/validations/transfer.validation.go create mode 100644 internal/utils/time.go diff --git a/internal/database/migrations/20251014024355_create_stock_transfers.down.sql b/internal/database/migrations/20251014024355_create_stock_transfers.down.sql new file mode 100644 index 00000000..c2d70451 --- /dev/null +++ b/internal/database/migrations/20251014024355_create_stock_transfers.down.sql @@ -0,0 +1,4 @@ +-- DROP TABLE: STOCK_TRANSFERS DAN SEQUENCE-NYA +DROP TABLE IF EXISTS stock_transfers CASCADE; + +DROP SEQUENCE IF EXISTS stock_transfer_seq CASCADE; \ No newline at end of file diff --git a/internal/database/migrations/20251014024355_create_stock_transfers.up.sql b/internal/database/migrations/20251014024355_create_stock_transfers.up.sql new file mode 100644 index 00000000..766afe77 --- /dev/null +++ b/internal/database/migrations/20251014024355_create_stock_transfers.up.sql @@ -0,0 +1,57 @@ +-- =============================================================== +-- STOCK TRANSFERS (HEADER) +-- =============================================================== + +CREATE SEQUENCE IF NOT EXISTS stock_transfer_seq START 1; + +CREATE TABLE IF NOT EXISTS stock_transfers ( + id BIGSERIAL PRIMARY KEY, + movement_number VARCHAR(50) UNIQUE NOT NULL, + from_warehouse_id BIGINT NOT NULL, + to_warehouse_id BIGINT NOT NULL, + area_id BIGINT, + reason TEXT, + transfer_date DATE NOT NULL, + created_by BIGINT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); + +-- FOREIGN KEYS (dijalankan setelah semua tabel parent ada) +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'warehouses') THEN + ALTER TABLE stock_transfers + ADD CONSTRAINT fk_stock_transfers_from_warehouse + FOREIGN KEY (from_warehouse_id) + REFERENCES warehouses(id) + ON DELETE RESTRICT ON UPDATE CASCADE; + ALTER TABLE stock_transfers + ADD CONSTRAINT fk_stock_transfers_to_warehouse + FOREIGN KEY (to_warehouse_id) + REFERENCES warehouses(id) + ON DELETE RESTRICT ON UPDATE CASCADE; + END IF; + + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'areas') THEN + ALTER TABLE stock_transfers + ADD CONSTRAINT fk_stock_transfers_area + FOREIGN KEY (area_id) + REFERENCES areas(id) + ON DELETE SET NULL ON UPDATE CASCADE; + END IF; + + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'users') THEN + ALTER TABLE stock_transfers + ADD CONSTRAINT fk_stock_transfers_created_by + FOREIGN KEY (created_by) + REFERENCES users(id) + ON DELETE SET NULL ON UPDATE CASCADE; + END IF; +END $$; + +-- INDEXES +CREATE INDEX IF NOT EXISTS idx_stock_transfers_from_warehouse_id ON stock_transfers(from_warehouse_id); +CREATE INDEX IF NOT EXISTS idx_stock_transfers_to_warehouse_id ON stock_transfers(to_warehouse_id); +CREATE INDEX IF NOT EXISTS idx_stock_transfers_transfer_date ON stock_transfers(transfer_date); diff --git a/internal/database/migrations/20251014024642_create_stock_transfer_details.down.sql b/internal/database/migrations/20251014024642_create_stock_transfer_details.down.sql new file mode 100644 index 00000000..64c0c8ed --- /dev/null +++ b/internal/database/migrations/20251014024642_create_stock_transfer_details.down.sql @@ -0,0 +1,2 @@ +-- DROP TABLE: STOCK_TRANSFER_DETAILS +DROP TABLE IF EXISTS stock_transfer_details CASCADE; diff --git a/internal/database/migrations/20251014024642_create_stock_transfer_details.up.sql b/internal/database/migrations/20251014024642_create_stock_transfer_details.up.sql new file mode 100644 index 00000000..8ff8858c --- /dev/null +++ b/internal/database/migrations/20251014024642_create_stock_transfer_details.up.sql @@ -0,0 +1,49 @@ +-- =============================================================== +-- STOCK TRANSFER DETAILS (PRODUK) +-- =============================================================== + +CREATE TABLE IF NOT EXISTS stock_transfer_details ( + id BIGSERIAL PRIMARY KEY, + stock_transfer_id BIGINT NOT NULL, + product_id BIGINT NOT NULL, + quantity NUMERIC(15, 3) NOT NULL CHECK (quantity > 0), + before_quantity NUMERIC(15, 3), + after_quantity NUMERIC(15, 3), + note TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); + +-- =============================================================== +-- FOREIGN KEYS (dengan pengecekan tabel agar anti gagal) +-- =============================================================== + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'stock_transfers') THEN + EXECUTE + 'ALTER TABLE stock_transfer_details + ADD CONSTRAINT fk_stock_transfer_details_transfer + FOREIGN KEY (stock_transfer_id) + REFERENCES stock_transfers(id) + ON DELETE CASCADE ON UPDATE CASCADE'; + END IF; + + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'products') THEN + EXECUTE + 'ALTER TABLE stock_transfer_details + ADD CONSTRAINT fk_stock_transfer_details_product + FOREIGN KEY (product_id) + REFERENCES products(id) + ON DELETE RESTRICT ON UPDATE CASCADE'; + END IF; +END $$; + +-- =============================================================== +-- INDEXES +-- =============================================================== + +CREATE INDEX IF NOT EXISTS idx_stock_transfer_details_transfer_id ON stock_transfer_details (stock_transfer_id); + +CREATE INDEX IF NOT EXISTS idx_stock_transfer_details_product_id ON stock_transfer_details (product_id); \ No newline at end of file diff --git a/internal/database/migrations/20251014024656_create_stock_transfer_deliveries.down.sql b/internal/database/migrations/20251014024656_create_stock_transfer_deliveries.down.sql new file mode 100644 index 00000000..5167737f --- /dev/null +++ b/internal/database/migrations/20251014024656_create_stock_transfer_deliveries.down.sql @@ -0,0 +1,2 @@ +-- DROP TABLE: STOCK_TRANSFER_DELIVERIES +DROP TABLE IF EXISTS stock_transfer_deliveries CASCADE; diff --git a/internal/database/migrations/20251014024656_create_stock_transfer_deliveries.up.sql b/internal/database/migrations/20251014024656_create_stock_transfer_deliveries.up.sql new file mode 100644 index 00000000..f5887b16 --- /dev/null +++ b/internal/database/migrations/20251014024656_create_stock_transfer_deliveries.up.sql @@ -0,0 +1,43 @@ +-- =============================================================== +-- STOCK TRANSFER DELIVERIES (EKSPEDISI) +-- =============================================================== + +CREATE TABLE IF NOT EXISTS stock_transfer_deliveries ( + id BIGSERIAL PRIMARY KEY, + stock_transfer_id BIGINT NOT NULL, + supplier_id BIGINT, + vehicle_plate VARCHAR(20), + driver_name VARCHAR(100), + document_number VARCHAR(50), + document_path TEXT, + shipping_cost_item NUMERIC(15,3), + shipping_cost_total NUMERIC(15,3), + note TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); + +-- FOREIGN KEYS +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'stock_transfers') THEN + ALTER TABLE stock_transfer_deliveries + ADD CONSTRAINT fk_stock_transfer_deliveries_transfer + FOREIGN KEY (stock_transfer_id) + REFERENCES stock_transfers(id) + ON DELETE CASCADE ON UPDATE CASCADE; + END IF; + + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'suppliers') THEN + ALTER TABLE stock_transfer_deliveries + ADD CONSTRAINT fk_stock_transfer_deliveries_supplier + FOREIGN KEY (supplier_id) + REFERENCES suppliers(id) + ON DELETE SET NULL ON UPDATE CASCADE; + END IF; +END $$; + +-- INDEXES +CREATE INDEX IF NOT EXISTS idx_stock_transfer_deliveries_transfer_id ON stock_transfer_deliveries(stock_transfer_id); +CREATE INDEX IF NOT EXISTS idx_stock_transfer_deliveries_supplier_id ON stock_transfer_deliveries(supplier_id); diff --git a/internal/database/migrations/20251014024702_create_stock_transfer_delivery_items.down.sql b/internal/database/migrations/20251014024702_create_stock_transfer_delivery_items.down.sql new file mode 100644 index 00000000..15e1253d --- /dev/null +++ b/internal/database/migrations/20251014024702_create_stock_transfer_delivery_items.down.sql @@ -0,0 +1,2 @@ +-- DROP PIVOT TABLE: STOCK_TRANSFER_DELIVERY_ITEMS +DROP TABLE IF EXISTS stock_transfer_delivery_items CASCADE; diff --git a/internal/database/migrations/20251014024702_create_stock_transfer_delivery_items.up.sql b/internal/database/migrations/20251014024702_create_stock_transfer_delivery_items.up.sql new file mode 100644 index 00000000..cb4c7a11 --- /dev/null +++ b/internal/database/migrations/20251014024702_create_stock_transfer_delivery_items.up.sql @@ -0,0 +1,35 @@ +-- =============================================================== +-- STOCK TRANSFER DELIVERY ITEMS (PIVOT) +-- =============================================================== + +CREATE TABLE IF NOT EXISTS stock_transfer_delivery_items ( + id BIGSERIAL PRIMARY KEY, + stock_transfer_delivery_id BIGINT NOT NULL, + stock_transfer_detail_id BIGINT NOT NULL, + quantity NUMERIC(15, 3) NOT NULL CHECK (quantity > 0) +); + +-- FOREIGN KEYS +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'stock_transfer_deliveries') THEN + ALTER TABLE stock_transfer_delivery_items + ADD CONSTRAINT fk_delivery_items_delivery + FOREIGN KEY (stock_transfer_delivery_id) + REFERENCES stock_transfer_deliveries(id) + ON DELETE CASCADE ON UPDATE CASCADE; + END IF; + + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'stock_transfer_details') THEN + ALTER TABLE stock_transfer_delivery_items + ADD CONSTRAINT fk_delivery_items_detail + FOREIGN KEY (stock_transfer_detail_id) + REFERENCES stock_transfer_details(id) + ON DELETE CASCADE ON UPDATE CASCADE; + END IF; +END $$; + +-- INDEXES +CREATE INDEX IF NOT EXISTS idx_stock_transfer_delivery_items_delivery_id ON stock_transfer_delivery_items (stock_transfer_delivery_id); + +CREATE INDEX IF NOT EXISTS idx_stock_transfer_delivery_items_detail_id ON stock_transfer_delivery_items (stock_transfer_detail_id); \ No newline at end of file diff --git a/internal/entities/stock-transfer.go b/internal/entities/stock-transfer.go new file mode 100644 index 00000000..ca615f2d --- /dev/null +++ b/internal/entities/stock-transfer.go @@ -0,0 +1,22 @@ +package entities + +import "time" + +// HEADER +type StockTransfer struct { + Id uint64 `gorm:"primaryKey;autoIncrement"` + MovementNumber string `gorm:"uniqueIndex;not null"` + FromWarehouseId uint64 + ToWarehouseId uint64 + TransferDate time.Time + Reason string + CreatedBy uint64 + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt *time.Time `gorm:"index"` + // Relations + FromWarehouse *Warehouse `gorm:"foreignKey:FromWarehouseId"` + ToWarehouse *Warehouse `gorm:"foreignKey:ToWarehouseId"` + Details []StockTransferDetail `gorm:"foreignKey:StockTransferId"` + Deliveries []StockTransferDelivery `gorm:"foreignKey:StockTransferId"` +} diff --git a/internal/entities/stock_log.go b/internal/entities/stock_log.go index 21e86bd4..6546e790 100644 --- a/internal/entities/stock_log.go +++ b/internal/entities/stock_log.go @@ -8,6 +8,7 @@ import ( const ( LogTypeAdjustment = "ADJUSTMENT" + LogTypeTransfer = "TRANSFER" ) const ( diff --git a/internal/entities/stock_transfer_delivery.go b/internal/entities/stock_transfer_delivery.go new file mode 100644 index 00000000..bd156389 --- /dev/null +++ b/internal/entities/stock_transfer_delivery.go @@ -0,0 +1,24 @@ +package entities + +import "time" + +// DETAIL EKSPEDISI +type StockTransferDelivery struct { + Id uint64 `gorm:"primaryKey;autoIncrement"` + StockTransferId uint64 + SupplierId uint64 + VehiclePlate string + DriverName string + DocumentNumber string + DocumentPath string + ShippingCostItem float64 + ShippingCostTotal float64 + Note string + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt *time.Time `gorm:"index"` + // Relations + StockTransfer *StockTransfer `gorm:"foreignKey:StockTransferId"` + Supplier *Supplier `gorm:"foreignKey:SupplierId"` + Items []StockTransferDeliveryItem `gorm:"foreignKey:StockTransferDeliveryId"` +} \ No newline at end of file diff --git a/internal/entities/stock_transfer_delivery_item.go b/internal/entities/stock_transfer_delivery_item.go new file mode 100644 index 00000000..cbfa05fb --- /dev/null +++ b/internal/entities/stock_transfer_delivery_item.go @@ -0,0 +1,12 @@ +package entities + +// PIVOT TABLE TRANSFER +type StockTransferDeliveryItem struct { + Id uint64 `gorm:"primaryKey;autoIncrement"` + StockTransferDeliveryId uint64 + StockTransferDetailId uint64 + Quantity float64 + // Relations + StockTransferDelivery *StockTransferDelivery `gorm:"foreignKey:StockTransferDeliveryId"` + StockTransferDetail *StockTransferDetail `gorm:"foreignKey:StockTransferDetailId"` +} diff --git a/internal/entities/stock_transfer_detail.go b/internal/entities/stock_transfer_detail.go new file mode 100644 index 00000000..2a3f2fcf --- /dev/null +++ b/internal/entities/stock_transfer_detail.go @@ -0,0 +1,22 @@ +package entities + +import "time" + + +// DETAIL PRODUK +type StockTransferDetail struct { + Id uint64 `gorm:"primaryKey;autoIncrement"` + StockTransferId uint64 + ProductId uint64 + Quantity float64 + BeforeQuantity float64 + AfterQuantity float64 + Note string + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt *time.Time `gorm:"index"` + // Relations + StockTransfer *StockTransfer `gorm:"foreignKey:StockTransferId"` + Product *Product `gorm:"foreignKey:ProductId"` + DeliveryItems []StockTransferDeliveryItem `gorm:"foreignKey:StockTransferDetailId"` +} \ No newline at end of file diff --git a/internal/modules/inventory/route.go b/internal/modules/inventory/route.go index f37e8cad..fcb7881a 100644 --- a/internal/modules/inventory/route.go +++ b/internal/modules/inventory/route.go @@ -9,6 +9,7 @@ import ( productWarehouses "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses" adjustments "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments" + transfers "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers" // MODULE IMPORTS ) @@ -19,6 +20,7 @@ func RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Valida productWarehouses.ProductWarehouseModule{}, adjustments.AdjustmentModule{}, + transfers.TransferModule{}, // MODULE REGISTRY } diff --git a/internal/modules/inventory/transfers/controllers/transfer.controller.go b/internal/modules/inventory/transfers/controllers/transfer.controller.go new file mode 100644 index 00000000..d499639e --- /dev/null +++ b/internal/modules/inventory/transfers/controllers/transfer.controller.go @@ -0,0 +1,92 @@ +package controller + +import ( + "math" + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type TransferController struct { + TransferService service.TransferService +} + +func NewTransferController(transferService service.TransferService) *TransferController { + return &TransferController{ + TransferService: transferService, + } +} + +func (u *TransferController) GetAll(c *fiber.Ctx) error { + query := &validation.Query{ + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: c.Query("search", ""), + } + + result, totalResults, err := u.TransferService.GetAll(c, query) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.TransferListDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get all transfers successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: dto.ToTransferListDTOs(result), + }) +} + +func (u *TransferController) GetOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + result, err := u.TransferService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get transfer successfully", + Data: dto.ToTransferListDTO(*result), + }) +} + +func (u *TransferController) CreateOne(c *fiber.Ctx) error { + var req validation.TransferRequest + if err := c.BodyParser(&req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.TransferService.CreateOne(c, &req) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Create transfer successfully", + Data: dto.ToTransferListDTO(*result), + }) +} diff --git a/internal/modules/inventory/transfers/dto/transfer.dto.go b/internal/modules/inventory/transfers/dto/transfer.dto.go new file mode 100644 index 00000000..5ef74c14 --- /dev/null +++ b/internal/modules/inventory/transfers/dto/transfer.dto.go @@ -0,0 +1,64 @@ +package dto + +import ( + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" +) + +// === DTO Structs === + +type TransferBaseDTO struct { + Id uint64 `json:"id"` + TransferReason string `json:"transfer_reason"` + TransferDate string `json:"transfer_date"` + SourceWarehouseId uint64 `json:"source_warehouse_id"` + DestinationWarehouseId uint64 `json:"destination_warehouse_id"` +} + +type TransferListDTO struct { + TransferBaseDTO + CreatedBy uint64 `json:"created_by"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type TransferDetailDTO struct { + TransferListDTO + // Tambahkan detail produk, deliveries, dsb jika perlu +} + +// === Mapper Functions === + +func ToTransferBaseDTO(e entity.StockTransfer) TransferBaseDTO { + return TransferBaseDTO{ + Id: e.Id, + TransferReason: e.Reason, // atau field lain sesuai entity + TransferDate: e.CreatedAt.Format("2006-01-02"), + SourceWarehouseId: e.FromWarehouseId, + DestinationWarehouseId: e.ToWarehouseId, + } +} + +func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { + return TransferListDTO{ + TransferBaseDTO: ToTransferBaseDTO(e), + CreatedBy: e.CreatedBy, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + } +} + +func ToTransferListDTOs(e []entity.StockTransfer) []TransferListDTO { + result := make([]TransferListDTO, len(e)) + for i, r := range e { + result[i] = ToTransferListDTO(r) + } + return result +} + +func ToTransferDetailDTO(e entity.StockTransfer) TransferDetailDTO { + return TransferDetailDTO{ + TransferListDTO: ToTransferListDTO(e), + } +} diff --git a/internal/modules/inventory/transfers/module.go b/internal/modules/inventory/transfers/module.go new file mode 100644 index 00000000..fa0047d4 --- /dev/null +++ b/internal/modules/inventory/transfers/module.go @@ -0,0 +1,31 @@ +package transfers + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + rStockTransfer "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/repositories" + sTransfer "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/services" + rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/stock-logs/repositories" + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type TransferModule struct{} + +func (TransferModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + stockTransferRepo := rStockTransfer.NewStockTransferRepository(db) + stockTransferDetailRepo := rStockTransfer.NewStockTransferDetailRepository(db) + stockTransferDeliveryRepo := rStockTransfer.NewStockTransferDeliveryRepository(db) + StockTransferDeliveryItemRepo := rStockTransfer.NewStockTransferDeliveryItemRepository(db) + stockLogsRepo := rStockLogs.NewStockLogRepository(db) + productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + userRepo := rUser.NewUserRepository(db) + + transferService := sTransfer.NewTransferService(validate, stockTransferRepo, stockTransferDetailRepo, stockTransferDeliveryRepo, StockTransferDeliveryItemRepo, stockLogsRepo, productWarehouseRepo) + userService := sUser.NewUserService(userRepo, validate) + + TransferRoutes(router, userService, transferService) +} diff --git a/internal/modules/inventory/transfers/repositories/stock_transfer.repository.go b/internal/modules/inventory/transfers/repositories/stock_transfer.repository.go new file mode 100644 index 00000000..e79d6310 --- /dev/null +++ b/internal/modules/inventory/transfers/repositories/stock_transfer.repository.go @@ -0,0 +1,34 @@ +package repositories + +import ( + "context" + + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type StockTransferRepository interface { + repository.BaseRepository[entity.StockTransfer] + // get sequence for movement number + GetNextMovementNumber(ctx context.Context) (int64, error) +} + +type StockTransferRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.StockTransfer] +} + +func NewStockTransferRepository(db *gorm.DB) StockTransferRepository { + return &StockTransferRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.StockTransfer](db), + } +} + +func (r *StockTransferRepositoryImpl) GetNextMovementNumber(ctx context.Context) (int64, error) { + var seq int64 + err := r.DB().WithContext(ctx).Raw("SELECT nextval('stock_transfer_seq')").Scan(&seq).Error + if err != nil { + return 0, err + } + return seq, nil +} diff --git a/internal/modules/inventory/transfers/repositories/stock_transfer_delivery.repository.go b/internal/modules/inventory/transfers/repositories/stock_transfer_delivery.repository.go new file mode 100644 index 00000000..ae0bfcf5 --- /dev/null +++ b/internal/modules/inventory/transfers/repositories/stock_transfer_delivery.repository.go @@ -0,0 +1,22 @@ +package repositories + +import ( + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type StockTransferDeliveryRepository interface { + repository.BaseRepository[entity.StockTransferDelivery] + // Tambahkan custom method jika perlu +} + +type StockTransferDeliveryRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.StockTransferDelivery] +} + +func NewStockTransferDeliveryRepository(db *gorm.DB) StockTransferDeliveryRepository { + return &StockTransferDeliveryRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.StockTransferDelivery](db), + } +} diff --git a/internal/modules/inventory/transfers/repositories/stock_transfer_delivery_item.repository.go b/internal/modules/inventory/transfers/repositories/stock_transfer_delivery_item.repository.go new file mode 100644 index 00000000..86ba0e9b --- /dev/null +++ b/internal/modules/inventory/transfers/repositories/stock_transfer_delivery_item.repository.go @@ -0,0 +1,22 @@ +package repositories + +import ( + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type StockTransferDeliveryItemRepository interface { + repository.BaseRepository[entity.StockTransferDeliveryItem] + // Tambahkan custom method jika perlu +} + +type StockTransferDeliveryItemRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.StockTransferDeliveryItem] +} + +func NewStockTransferDeliveryItemRepository(db *gorm.DB) StockTransferDeliveryItemRepository { + return &StockTransferDeliveryItemRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.StockTransferDeliveryItem](db), + } +} diff --git a/internal/modules/inventory/transfers/repositories/stock_transfer_detail.repository.go b/internal/modules/inventory/transfers/repositories/stock_transfer_detail.repository.go new file mode 100644 index 00000000..7c8ab63d --- /dev/null +++ b/internal/modules/inventory/transfers/repositories/stock_transfer_detail.repository.go @@ -0,0 +1,22 @@ +package repositories + +import ( + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type StockTransferDetailRepository interface { + repository.BaseRepository[entity.StockTransferDetail] + // Tambahkan custom method jika perlu +} + +type StockTransferDetailRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.StockTransferDetail] +} + +func NewStockTransferDetailRepository(db *gorm.DB) StockTransferDetailRepository { + return &StockTransferDetailRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.StockTransferDetail](db), + } +} diff --git a/internal/modules/inventory/transfers/route.go b/internal/modules/inventory/transfers/route.go new file mode 100644 index 00000000..544a0674 --- /dev/null +++ b/internal/modules/inventory/transfers/route.go @@ -0,0 +1,27 @@ +package transfers + +import ( + // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/controllers" + transfer "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func TransferRoutes(v1 fiber.Router, u user.UserService, s transfer.TransferService) { + ctrl := controller.NewTransferController(s) + + route := v1.Group("/transfers") + + // route.Get("/", m.Auth(u), ctrl.GetAll) + // route.Post("/", m.Auth(u), ctrl.CreateOne) + // route.Get("/:id", m.Auth(u), ctrl.GetOne) + // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) + // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) + + route.Get("/", ctrl.GetAll) + route.Post("/", ctrl.CreateOne) + route.Get("/:id", ctrl.GetOne) + +} diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go new file mode 100644 index 00000000..ddc47c02 --- /dev/null +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -0,0 +1,240 @@ +package service + +import ( + "errors" + "fmt" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + rStockTransfer "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/validations" + rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/stock-logs/repositories" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type TransferService interface { + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.StockTransfer, int64, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.StockTransfer, error) + CreateOne(ctx *fiber.Ctx, req *validation.TransferRequest) (*entity.StockTransfer, error) +} + +type transferService struct { + Log *logrus.Logger + Validate *validator.Validate + StockTransferRepo rStockTransfer.StockTransferRepository + StockTransferDetailRepo rStockTransfer.StockTransferDetailRepository + StockTransferDeliveryRepo rStockTransfer.StockTransferDeliveryRepository + StockTransferDeliveryItemRepo rStockTransfer.StockTransferDeliveryItemRepository + StockLogsRepository rStockLogs.StockLogRepository + ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository +} + +func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTransfer.StockTransferRepository, stockTransferDetailRepo rStockTransfer.StockTransferDetailRepository, stockTransferDeliveryRepo rStockTransfer.StockTransferDeliveryRepository, stockTransferDeliveryItemRepo rStockTransfer.StockTransferDeliveryItemRepository, stockLogsRepo rStockLogs.StockLogRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository) TransferService { + return &transferService{ + Log: utils.Log, + Validate: validate, + StockTransferRepo: stockTransferRepo, + StockTransferDetailRepo: stockTransferDetailRepo, + StockTransferDeliveryRepo: stockTransferDeliveryRepo, + StockTransferDeliveryItemRepo: stockTransferDeliveryItemRepo, + StockLogsRepository: stockLogsRepo, + ProductWarehouseRepo: productWarehouseRepo, + } +} + +func (s transferService) withRelations(db *gorm.DB) *gorm.DB { + return db.Preload("CreatedUser") +} + +func (s transferService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.StockTransfer, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + // offset := (params.Page - 1) * params.Limit + + // transfers, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { + // db = s.withRelations(db) + // if params.Search != "" { + // return db.Where("name LIKE ?", "%"+params.Search+"%") + // } + // return db.Order("created_at DESC").Order("updated_at DESC") + // }) + + // if err != nil { + // s.Log.Errorf("Failed to get transfers: %+v", err) + // return nil, 0, err + // } + return nil, 0, nil +} + +func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, error) { + + return nil, nil +} + +func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferRequest) (*entity.StockTransfer, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + // Validasi stok di gudang asal + for _, product := range req.Products { + sourcePW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID( + c.Context(), uint(product.ProductID), uint(req.SourceWarehouseID), + ) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok produk %d tidak tersedia di gudang asal", product.ProductID)) + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal cek stok produk di gudang asal") + } + if sourcePW.Quantity < product.ProductQty { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok produk %d di gudang asal tidak cukup", product.ProductID)) + } + } + + // Generate movement number + // Format: PND-MBU-00001 + seqNum, err := s.StockTransferRepo.GetNextMovementNumber(c.Context()) + if err != nil { + s.Log.Errorf("Failed to get next movement number: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to generate movement number") + } + movementNumber := fmt.Sprintf("PND-MBU-%05d", seqNum) + transferDate, _ := utils.ParseDateString(req.TransferDate) + + entityTransfer := &entity.StockTransfer{ + FromWarehouseId: uint64(req.SourceWarehouseID), + ToWarehouseId: uint64(req.DestinationWarehouseID), + Reason: req.TransferReason, + TransferDate: transferDate, + MovementNumber: movementNumber, + CreatedBy: 1, //todo: get from token + } + + // Save the transfer entity to the database + err = s.StockTransferRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { + + // Insert header + if err := s.StockTransferRepo.WithTx(tx).CreateOne(c.Context(), entityTransfer, nil); err != nil { + s.Log.Errorf("Failed to create stock transfer: %+v", err) + return err + } + s.Log.Infof("Stock transfer created: %+v", entityTransfer.Id) + + // insert ke details + var details []*entity.StockTransferDetail + for _, product := range req.Products { + details = append(details, &entity.StockTransferDetail{ + StockTransferId: entityTransfer.Id, + ProductId: uint64(product.ProductID), + Quantity: product.ProductQty, + }) + } + if err := s.StockTransferDetailRepo.WithTx(tx).CreateMany(c.Context(), details, nil); err != nil { + s.Log.Errorf("Failed to create stock transfer details: %+v", err) + return err + } + s.Log.Infof("Stock transfer details created for transfer ID: %+v", entityTransfer.Id) + + // Tambahkan proses insert delivery + var deliveries []*entity.StockTransferDelivery + for _, delivery := range req.Deliveries { + deliveries = append(deliveries, &entity.StockTransferDelivery{ + StockTransferId: entityTransfer.Id, + SupplierId: uint64(delivery.SupplierID), + VehiclePlate: delivery.VehiclePlate, + DriverName: delivery.DriverName, + DocumentPath: delivery.Document, + ShippingCostItem: delivery.DeliveryCostPerItem, + ShippingCostTotal: delivery.DeliveryCost, + }) + } + if err := s.StockTransferDeliveryRepo.WithTx(tx).CreateMany(c.Context(), deliveries, nil); err != nil { + s.Log.Errorf("Failed to create stock transfer deliveries: %+v", err) + return err + } + + // tambahkan insert ke delivery items sebagai fivot + var deliveryItems []*entity.StockTransferDeliveryItem + for i, delivery := range req.Deliveries { + for _, item := range delivery.Products { + deliveryItems = append(deliveryItems, &entity.StockTransferDeliveryItem{ + StockTransferDeliveryId: deliveries[i].Id, + StockTransferDetailId: uint64(item.ProductID), + Quantity: item.ProductQty, + }) + } + } + if err := s.StockTransferDeliveryItemRepo.WithTx(tx).CreateMany(c.Context(), deliveryItems, nil); err != nil { + s.Log.Errorf("Failed to create stock transfer delivery items: %+v", err) + return err + } + s.Log.Infof("Stock transfer delivery items created for transfer ID: %+v", entityTransfer.Id) + + // Proses pengurangan stok di gudang asal dan penambahan stok di gudang tujuan + for _, product := range req.Products { + // Kurangi stok di gudang asal + sourcePW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID(c.Context(), uint(product.ProductID), uint(req.SourceWarehouseID)) + if err != nil { + s.Log.Errorf("Failed to get source product warehouse: %+v", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get source product warehouse") + } + if sourcePW.Quantity < product.ProductQty { + s.Log.Errorf("Insufficient stock in source warehouse for product ID: %+v", product.ProductID) + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Insufficient stock in source warehouse for product ID: %d", product.ProductID)) + } + sourcePW.Quantity -= product.ProductQty + if err := s.ProductWarehouseRepo.WithTx(tx).UpdateOne(c.Context(), sourcePW.Id, sourcePW, nil); err != nil { + s.Log.Errorf("Failed to update source product warehouse: %+v", err) + return err + } + s.Log.Infof("Source product warehouse updated: %+v", sourcePW.Id) + + // Tambah stok di gudang tujuan + destPW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID( + c.Context(), uint(product.ProductID), uint(req.DestinationWarehouseID), + ) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Failed to get destination product warehouse: %+v", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get destination product warehouse") + } + if err != nil && errors.Is(err, gorm.ErrRecordNotFound) { + // Jika belum ada record untuk produk di gudang tujuan, buat baru + destPW = &entity.ProductWarehouse{ + ProductId: uint(product.ProductID), + WarehouseId: uint(req.DestinationWarehouseID), + Quantity: 0, + CreatedBy: 1, // TODO: should Get from auth middleware + } + if err := s.ProductWarehouseRepo.WithTx(tx).CreateOne(c.Context(), destPW, nil); err != nil { + s.Log.Errorf("Failed to create destination product warehouse: %+v", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to create destination product warehouse") + } + s.Log.Infof("Destination product warehouse created: %+v", destPW.Id) + } + destPW.Quantity += product.ProductQty + if err := s.ProductWarehouseRepo.WithTx(tx).UpdateOne(c.Context(), destPW.Id, destPW, nil); err != nil { + s.Log.Errorf("Failed to update destination product warehouse: %+v", err) + return err + } + s.Log.Infof("Destination product warehouse updated: %+v", destPW.Id) + } + + + return nil + }) + + if err != nil { + s.Log.Errorf("Transaction failed in CreateOne: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to process transfer transaction") + } + + return entityTransfer, nil +} diff --git a/internal/modules/inventory/transfers/validations/transfer.validation.go b/internal/modules/inventory/transfers/validations/transfer.validation.go new file mode 100644 index 00000000..05c7215d --- /dev/null +++ b/internal/modules/inventory/transfers/validations/transfer.validation.go @@ -0,0 +1,41 @@ +package validation + +type Create struct { + Name string `json:"name" validate:"required_strict,min=3"` +} + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` + Search string `query:"search" validate:"omitempty,max=50"` +} + +type TransferProduct struct { + ProductID uint `json:"product_id" validate:"required"` + ProductQty float64 `json:"product_qty" validate:"required,gt=0"` +} + +type TransferDeliveryProduct struct { + ProductID uint `json:"product_id" validate:"required"` + ProductQty float64 `json:"product_qty" validate:"required,gt=0"` +} + +type TransferDelivery struct { + DeliveryCost float64 `json:"delivery_cost" validate:"required"` + DeliveryCostPerItem float64 `json:"delivery_cost_per_item" validate:"required"` + Document string `json:"document"` + DriverName string `json:"driver_name" validate:"required"` + DeliveryNoteNumber string `json:"delivery_note_number" validate:"required"` + VehiclePlate string `json:"vehicle_plate" validate:"required"` + SupplierID uint `json:"supplier_id" validate:"required"` + Products []TransferDeliveryProduct `json:"products" validate:"required,dive"` +} + +type TransferRequest struct { + TransferReason string `json:"transfer_reason" validate:"required"` + TransferDate string `json:"transfer_date" validate:"required,datetime=2006-01-02"` + SourceWarehouseID uint `json:"source_warehouse_id" validate:"required"` + DestinationWarehouseID uint `json:"destination_warehouse_id" validate:"required"` + Products []TransferProduct `json:"products" validate:"required,dive"` + Deliveries []TransferDelivery `json:"deliveries" validate:"required,dive"` +} diff --git a/internal/utils/time.go b/internal/utils/time.go new file mode 100644 index 00000000..f57a3bb3 --- /dev/null +++ b/internal/utils/time.go @@ -0,0 +1,25 @@ +package utils + +import ( + "time" + "errors" +) + +// ParseDateString mengubah string "YYYY-MM-DD" menjadi time.Time +func ParseDateString(dateStr string) (time.Time, error) { + if dateStr == "" { + return time.Time{}, errors.New("date string is empty") + } + + parsed, err := time.Parse("2006-01-02", dateStr) + if err != nil { + return time.Time{}, errors.New("invalid date format, expected YYYY-MM-DD") + } + + return parsed, nil +} + +// FormatDate mengubah time.Time menjadi string "YYYY-MM-DD" +func FormatDate(t time.Time) string { + return t.Format("2006-01-02") +}