diff --git a/internal/database/migrations/20250925040409_create_master_tables.up.sql b/internal/database/migrations/20250925040409_create_master_tables.up.sql index 07e3005a..09b1c46e 100644 --- a/internal/database/migrations/20250925040409_create_master_tables.up.sql +++ b/internal/database/migrations/20250925040409_create_master_tables.up.sql @@ -316,7 +316,7 @@ CREATE TABLE stock_logs ( before_quantity NUMERIC(15, 3) NOT NULL, after_quantity NUMERIC(15, 3) NOT NULL, log_type VARCHAR(50) NOT NULL, - log_id BIGINT , + log_id BIGINT, note TEXT, product_warehouse_id BIGINT NOT NULL REFERENCES product_warehouses (id) ON DELETE CASCADE ON UPDATE CASCADE, created_by BIGINT NOT NULL REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE, 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..090014ff --- /dev/null +++ b/internal/database/migrations/20251014024642_create_stock_transfer_details.up.sql @@ -0,0 +1,48 @@ +-- =============================================================== +-- 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), + 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..52e5b5c2 --- /dev/null +++ b/internal/database/migrations/20251014024656_create_stock_transfer_deliveries.up.sql @@ -0,0 +1,42 @@ +-- =============================================================== +-- 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), + 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/database/seed/seeder.go b/internal/database/seed/seeder.go index b321a784..11717d2c 100644 --- a/internal/database/seed/seeder.go +++ b/internal/database/seed/seeder.go @@ -78,6 +78,10 @@ func Run(db *gorm.DB) error { return err } + if err := seedTransferStock(tx, adminID); err != nil { + return err + } + fmt.Println("✅ Master data seeding completed") return nil }) @@ -775,7 +779,7 @@ func seedProductWarehouse(tx *gorm.DB, createdBy uint) error { }{ {ProductID: 1, WarehouseID: 1, Quantity: 100}, {ProductID: 2, WarehouseID: 2, Quantity: 200}, - {ProductID: 1, WarehouseID: 1, Quantity: 300}, + {ProductID: 2, WarehouseID: 1, Quantity: 300}, } for _, seed := range seeds { @@ -799,6 +803,84 @@ func seedProductWarehouse(tx *gorm.DB, createdBy uint) error { return nil } +func seedTransferStock(tx *gorm.DB, createdBy uint) error { + // Seeder Transfer Stock + // 1. Insert StockTransfer (header) + transfer := entity.StockTransfer{ + FromWarehouseId: 1, + ToWarehouseId: 2, + Reason: "Seed transfer stock", + TransferDate: time.Now(), + MovementNumber: "SEED-TRF-00001", + CreatedBy: 1, + } + if err := tx.Create(&transfer).Error; err != nil { + return err + } + + // 2. Insert StockTransferDetail (detail) + details := []entity.StockTransferDetail{ + { + StockTransferId: transfer.Id, + ProductId: 1, + Quantity: 10, + }, + { + StockTransferId: transfer.Id, + ProductId: 2, + Quantity: 5, + }, + } + for i := range details { + if err := tx.Create(&details[i]).Error; err != nil { + return err + } + } + + // 3. Insert StockTransferDelivery (delivery) + deliveries := []entity.StockTransferDelivery{ + { + StockTransferId: transfer.Id, + SupplierId: 1, + VehiclePlate: "B 1234 XYZ", + DriverName: "Driver Seed", + DocumentPath: "seed.pdf", + ShippingCostItem: 1000, + ShippingCostTotal: 2000, + }, + } + for i := range deliveries { + if err := tx.Create(&deliveries[i]).Error; err != nil { + return err + } + } + + detailMap := make(map[uint64]uint64) + for _, d := range details { + detailMap[d.ProductId] = d.Id + } + + deliveryItems := []entity.StockTransferDeliveryItem{ + { + StockTransferDeliveryId: deliveries[0].Id, + StockTransferDetailId: detailMap[1], + Quantity: 50, + }, + { + StockTransferDeliveryId: deliveries[0].Id, + StockTransferDetailId: detailMap[2], + Quantity: 30, + }, + } + for i := range deliveryItems { + if err := tx.Create(&deliveryItems[i]).Error; err != nil { + return err + } + } + + return nil +} + func ptr[T any](v T) *T { return &v } diff --git a/internal/entities/stock-transfer.go b/internal/entities/stock-transfer.go new file mode 100644 index 00000000..e003d601 --- /dev/null +++ b/internal/entities/stock-transfer.go @@ -0,0 +1,23 @@ +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"` + CreatedUser *User `gorm:"foreignKey:CreatedBy"` +} diff --git a/internal/entities/stock_transfer_delivery.go b/internal/entities/stock_transfer_delivery.go new file mode 100644 index 00000000..3a7562ea --- /dev/null +++ b/internal/entities/stock_transfer_delivery.go @@ -0,0 +1,23 @@ +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 + 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..253a3bf8 --- /dev/null +++ b/internal/entities/stock_transfer_detail.go @@ -0,0 +1,18 @@ +package entities + +import "time" + +// DETAIL PRODUK +type StockTransferDetail struct { + Id uint64 `gorm:"primaryKey;autoIncrement"` + StockTransferId uint64 + ProductId uint64 + Quantity float64 + 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"` +} diff --git a/internal/modules/inventory/adjustments/controllers/adjustment.controller.go b/internal/modules/inventory/adjustments/controllers/adjustment.controller.go index d152f360..dc3df0a9 100644 --- a/internal/modules/inventory/adjustments/controllers/adjustment.controller.go +++ b/internal/modules/inventory/adjustments/controllers/adjustment.controller.go @@ -2,6 +2,7 @@ package controller import ( "math" + "strconv" "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/dto" service "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/services" @@ -78,3 +79,25 @@ func (u *AdjustmentController) AdjustmentHistory(c *fiber.Ctx) error { Data: adjustmentDTOs, }) } + +func (u *AdjustmentController) GetOne(c *fiber.Ctx) error { + param := c.Params("id") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + stockLog, err := u.AdjustmentService.GetOne(c, uint(id)) + if err != nil { + return err + } + // Use DTO for response + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get adjustment successfully", + Data: dto.ToAdjustmentDetailDTO(stockLog), + }) +} diff --git a/internal/modules/inventory/adjustments/dto/adjustment.dto.go b/internal/modules/inventory/adjustments/dto/adjustment.dto.go index 72d58c2a..d577e134 100644 --- a/internal/modules/inventory/adjustments/dto/adjustment.dto.go +++ b/internal/modules/inventory/adjustments/dto/adjustment.dto.go @@ -4,15 +4,17 @@ import ( "time" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + productCategoryDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/product-categories/dto" userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" ) // === DTO Structs === type ProductBaseDTO struct { - Id uint `json:"id"` - Name string `json:"name"` - SKU string `json:"sku"` + Id uint `json:"id"` + Name string `json:"name"` + SKU string `json:"sku"` + ProductCategory *productCategoryDTO.ProductCategoryBaseDTO `json:"product_category,omitempty"` } type WarehouseBaseDTO struct { @@ -61,10 +63,18 @@ func ToProductBaseDTO(e *entity.Product) *ProductBaseDTO { if e.Sku != nil { sku = *e.Sku } + + var category *productCategoryDTO.ProductCategoryBaseDTO + if e.ProductCategory.Id != 0 { + mapped := productCategoryDTO.ToProductCategoryBaseDTO(e.ProductCategory) + category = &mapped + } + return &ProductBaseDTO{ - Id: e.Id, - Name: e.Name, - SKU: sku, + Id: e.Id, + Name: e.Name, + SKU: sku, + ProductCategory: category, } } diff --git a/internal/modules/inventory/adjustments/module.go b/internal/modules/inventory/adjustments/module.go index 294cf9dc..cfe01118 100644 --- a/internal/modules/inventory/adjustments/module.go +++ b/internal/modules/inventory/adjustments/module.go @@ -7,6 +7,7 @@ import ( sAdjustment "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/services" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + rproduct "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories" rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/stock-logs/repositories" @@ -21,8 +22,9 @@ func (AdjustmentModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validat warehouseRepo := rWarehouse.NewWarehouseRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) userRepo := rUser.NewUserRepository(db) + productRepo := rproduct.NewProductRepository(db) - adjustmentService := sAdjustment.NewAdjustmentService(stockLogsRepo, warehouseRepo, productWarehouseRepo, validate) + adjustmentService := sAdjustment.NewAdjustmentService(productRepo, stockLogsRepo, warehouseRepo, productWarehouseRepo, validate) userService := sUser.NewUserService(userRepo, validate) AdjustmentRoutes(router, userService, adjustmentService) diff --git a/internal/modules/inventory/adjustments/route.go b/internal/modules/inventory/adjustments/route.go index cb63defa..8f58bb4d 100644 --- a/internal/modules/inventory/adjustments/route.go +++ b/internal/modules/inventory/adjustments/route.go @@ -17,5 +17,6 @@ func AdjustmentRoutes(v1 fiber.Router, u user.UserService, s adjustment.Adjustme // Standard CRUD routes following master data pattern route.Get("/", ctrl.AdjustmentHistory) // Get all with pagination and filters route.Post("/", ctrl.Adjustment) // Create adjustment + route.Get("/:id", ctrl.GetOne) } diff --git a/internal/modules/inventory/adjustments/services/adjustment.service.go b/internal/modules/inventory/adjustments/services/adjustment.service.go index 252cf7a3..929a5c8a 100644 --- a/internal/modules/inventory/adjustments/services/adjustment.service.go +++ b/internal/modules/inventory/adjustments/services/adjustment.service.go @@ -7,6 +7,7 @@ import ( entity "gitlab.com/mbugroup/lti-api.git/internal/entities" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments/validations" ProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + productRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories" warehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" stockLogsRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/stock-logs/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" @@ -29,15 +30,17 @@ type adjustmentService struct { StockLogsRepository stockLogsRepo.StockLogRepository WarehouseRepo warehouseRepo.WarehouseRepository ProductWarehouseRepo ProductWarehouse.ProductWarehouseRepository + ProductRepo productRepo.ProductRepository } -func NewAdjustmentService(stockLogsRepo stockLogsRepo.StockLogRepository, warehouseRepo warehouseRepo.WarehouseRepository, productWarehouseRepo ProductWarehouse.ProductWarehouseRepository, validate *validator.Validate) AdjustmentService { +func NewAdjustmentService(productRepo productRepo.ProductRepository, stockLogsRepo stockLogsRepo.StockLogRepository, warehouseRepo warehouseRepo.WarehouseRepository, productWarehouseRepo ProductWarehouse.ProductWarehouseRepository, validate *validator.Validate) AdjustmentService { return &adjustmentService{ Log: utils.Log, Validate: validate, StockLogsRepository: stockLogsRepo, WarehouseRepo: warehouseRepo, ProductWarehouseRepo: productWarehouseRepo, + ProductRepo: productRepo, } } @@ -50,7 +53,9 @@ func (s *adjustmentService) withRelations(db *gorm.DB) *gorm.DB { } func (s *adjustmentService) GetOne(c *fiber.Ctx, id uint) (*entity.StockLog, error) { - stockLog, err := s.StockLogsRepository.GetByID(c.Context(), id, s.withRelations) + stockLog, err := s.StockLogsRepository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { + return s.withRelations(db).Preload("ProductWarehouse.Product.ProductCategory") + }) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Adjustment not found") @@ -72,14 +77,27 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e } ctx := c.Context() - productWarehouseExists, err := s.ProductWarehouseRepo.ProductWarehouseExists(ctx, uint(req.ProductID), uint(req.WarehouseID), nil) + isProductExist, err := s.ProductRepo.IdExists(c.Context(), uint(req.ProductID)) if err != nil { - return nil, err + s.Log.Errorf("Failed to check product existence: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product") } - if !productWarehouseExists { - return nil, fiber.NewError(fiber.StatusBadRequest, "Product warehouse not found") + if !isProductExist { + return nil, fiber.NewError(fiber.StatusBadRequest, "Product not found") } + isWarehouseExist, err := s.WarehouseRepo.IdExists(c.Context(), uint(req.WarehouseID)) + if err != nil { + s.Log.Errorf("Failed to check warehouse existence: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate warehouse") + } + if !isWarehouseExist { + return nil, fiber.NewError(fiber.StatusBadRequest, "Warehouse not found") + } + + if req.Quantity <= 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Quantity must be greater than zero") + } transactionType := strings.ToUpper(req.TransactionType) if transactionType != entity.TransactionTypeIncrease && transactionType != entity.TransactionTypeDecrease { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid transaction type") @@ -87,16 +105,33 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e var createdLogId uint + isProductWarehouseExist, err := s.ProductWarehouseRepo.ProductWarehouseExistByProductAndWarehouseID(ctx, uint(req.ProductID), uint(req.WarehouseID)) + if err != nil { + s.Log.Errorf("Failed to check product warehouse existence: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product warehouse") + } + if !isProductWarehouseExist { + + newPW := &entity.ProductWarehouse{ + ProductId: uint(req.ProductID), + WarehouseId: uint(req.WarehouseID), + Quantity: 0, + CreatedBy: 1, // TODO: should Get from auth middleware + } + if err := s.ProductWarehouseRepo.CreateOne(ctx, newPW, nil); err != nil { + s.Log.Errorf("Failed to create product warehouse: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to create product warehouse") + } + s.Log.Infof("Product warehouse created: %+v", newPW.Id) + } + err = s.StockLogsRepository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { - // Get product warehouse by product id and warehouse id (read operation, no transaction needed) + productWarehouse, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID(ctx, uint(req.ProductID), uint(req.WarehouseID)) if err != nil { - return err + s.Log.Errorf("Failed to get product warehouse: %+v", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get product warehouse") } - if productWarehouse == nil { - return fiber.NewError(fiber.StatusBadRequest, "Product warehouse not found") - } - s.Log.Infof("Product Warehouse found: %+v", productWarehouse.Id) afterQuantity := productWarehouse.Quantity if transactionType == entity.TransactionTypeIncrease { @@ -133,7 +168,6 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e } s.Log.Infof("Product warehouse quantity updated: %+v", productWarehouse.Id) - // Set createdLogId to get the log with relations after transaction createdLogId = newLog.Id return nil }) diff --git a/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller.go b/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller.go index 5e0ea423..a0b72a4d 100644 --- a/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller.go +++ b/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller.go @@ -72,70 +72,4 @@ func (u *ProductWarehouseController) GetOne(c *fiber.Ctx) error { }) } -func (u *ProductWarehouseController) CreateOne(c *fiber.Ctx) error { - req := new(validation.Create) - if err := c.BodyParser(req); err != nil { - return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") - } - - result, err := u.ProductWarehouseService.CreateOne(c, req) - if err != nil { - return err - } - - return c.Status(fiber.StatusCreated). - JSON(response.Success{ - Code: fiber.StatusCreated, - Status: "success", - Message: "Create productWarehouse successfully", - Data: dto.ToProductWarehouseListDTO(*result), - }) -} - -func (u *ProductWarehouseController) UpdateOne(c *fiber.Ctx) error { - req := new(validation.Update) - param := c.Params("id") - - id, err := strconv.Atoi(param) - if err != nil { - return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") - } - - if err := c.BodyParser(req); err != nil { - return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") - } - - result, err := u.ProductWarehouseService.UpdateOne(c, req, uint(id)) - if err != nil { - return err - } - - return c.Status(fiber.StatusOK). - JSON(response.Success{ - Code: fiber.StatusOK, - Status: "success", - Message: "Update productWarehouse successfully", - Data: dto.ToProductWarehouseListDTO(*result), - }) -} - -func (u *ProductWarehouseController) DeleteOne(c *fiber.Ctx) error { - param := c.Params("id") - - id, err := strconv.Atoi(param) - if err != nil { - return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") - } - - if err := u.ProductWarehouseService.DeleteOne(c, uint(id)); err != nil { - return err - } - - return c.Status(fiber.StatusOK). - JSON(response.Common{ - Code: fiber.StatusOK, - Status: "success", - Message: "Delete productWarehouse successfully", - }) -} diff --git a/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go b/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go index 2260e834..fdebb519 100644 --- a/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go +++ b/internal/modules/inventory/product-warehouses/dto/product_warehouse.dto.go @@ -10,10 +10,10 @@ import ( // === DTO Structs === type ProductWarehouseBaseDTO struct { - Id uint `json:"id"` - ProductId uint `json:"product_id"` - WarehouseId uint `json:"warehouse_id"` - Quantity float64 `json:"quantity"` + Id uint `json:"id"` + ProductId uint `json:"product_id"` + WarehouseId uint `json:"warehouse_id"` + Quantity float64 `json:"quantity"` } type ProductWarehouseListDTO struct { @@ -31,9 +31,10 @@ type ProductWarehouseDetailDTO struct { // Nested DTOs for relations type ProductBaseDTO struct { - Id uint `json:"id"` - Name string `json:"name"` - Sku string `json:"sku"` + Id uint `json:"id"` + Name string `json:"name"` + Sku string `json:"sku"` + Flags []string `json:"flags"` } type WarehouseBaseDTO struct { @@ -68,6 +69,12 @@ func ToProductWarehouseListDTO(e entity.ProductWarehouse) ProductWarehouseListDT if e.Product.Sku != nil { product.Sku = *e.Product.Sku } + // Map flags from Product relation + if len(e.Product.Flags) > 0 { + for _, f := range e.Product.Flags { + product.Flags = append(product.Flags, f.Name) + } + } dto.Product = &product } diff --git a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go index 0398a825..cc4adf64 100644 --- a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go +++ b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go @@ -13,6 +13,7 @@ type ProductWarehouseRepository interface { ProductWarehouseExists(ctx context.Context, productId, warehouseId uint, excludeID *uint) (bool, error) IsProductExist(ctx context.Context, productId uint) (bool, error) IsWarehouseExist(ctx context.Context, warehouseId uint) (bool, error) + ProductWarehouseExistByProductAndWarehouseID(ctx context.Context, productId, warehouseId uint) (bool, error) ExistsByID(ctx context.Context, id uint) (bool, error) GetProductWarehouseByProductAndWarehouseID(ctx context.Context, productId, warehouseId uint) (*entity.ProductWarehouse, error) } @@ -53,6 +54,17 @@ func (r *ProductWarehouseRepositoryImpl) ExistsByID(ctx context.Context, id uint return repository.Exists[entity.ProductWarehouse](ctx, r.db, id) } +func (r *ProductWarehouseRepositoryImpl) ProductWarehouseExistByProductAndWarehouseID(ctx context.Context, productId, warehouseId uint) (bool, error) { + var count int64 + if err := r.db.WithContext(ctx). + Model(&entity.ProductWarehouse{}). + Where("product_id = ? AND warehouse_id = ?", productId, warehouseId). + Count(&count).Error; err != nil { + return false, err + } + return count > 0, nil +} + func (r *ProductWarehouseRepositoryImpl) GetProductWarehouseByProductAndWarehouseID(ctx context.Context, productId, warehouseId uint) (*entity.ProductWarehouse, error) { var productWarehouse entity.ProductWarehouse if err := r.DB().WithContext(ctx).Where("product_id = ? AND warehouse_id = ?", productId, warehouseId).First(&productWarehouse).Error; err != nil { diff --git a/internal/modules/inventory/product-warehouses/route.go b/internal/modules/inventory/product-warehouses/route.go index b0cc9c65..429c1d16 100644 --- a/internal/modules/inventory/product-warehouses/route.go +++ b/internal/modules/inventory/product-warehouses/route.go @@ -21,8 +21,6 @@ func ProductWarehouseRoutes(v1 fiber.Router, u user.UserService, s productWareho // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) route.Get("/", ctrl.GetAll) - route.Post("/", ctrl.CreateOne) route.Get("/:id", ctrl.GetOne) - route.Patch("/:id", ctrl.UpdateOne) - route.Delete("/:id", ctrl.DeleteOne) + } diff --git a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go index 03c7c9a1..9afe5707 100644 --- a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go +++ b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go @@ -17,9 +17,6 @@ import ( type ProductWarehouseService interface { GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProductWarehouse, int64, error) GetOne(ctx *fiber.Ctx, id uint) (*entity.ProductWarehouse, error) - CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.ProductWarehouse, error) - UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.ProductWarehouse, error) - DeleteOne(ctx *fiber.Ctx, id uint) error } type productWarehouseService struct { @@ -37,7 +34,7 @@ func NewProductWarehouseService(repo repository.ProductWarehouseRepository, vali } func (s productWarehouseService) withRelations(db *gorm.DB) *gorm.DB { - return db.Preload("Product").Preload("Warehouse").Preload("CreatedUser") + return db.Preload("Product.Flags").Preload("Product").Preload("Warehouse").Preload("CreatedUser") } func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProductWarehouse, int64, error) { @@ -79,125 +76,3 @@ func (s productWarehouseService) GetOne(c *fiber.Ctx, id uint) (*entity.ProductW } return productWarehouse, nil } - -func (s *productWarehouseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.ProductWarehouse, error) { - if err := s.Validate.Struct(req); err != nil { - return nil, err - } - - isProductExist, err := s.Repository.IsProductExist(c.Context(), req.ProductId) - if err != nil { - s.Log.Errorf("Failed to check product existence: %+v", err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check product existence") - } - if !isProductExist { - return nil, fiber.NewError(fiber.StatusBadRequest, "Product not found") - } - - isWarehouseExist, err := s.Repository.IsWarehouseExist(c.Context(), req.WarehouseId) - if err != nil { - s.Log.Errorf("Failed to check warehouse existence: %+v", err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check warehouse existence") - } - if !isWarehouseExist { - return nil, fiber.NewError(fiber.StatusBadRequest, "Warehouse not found") - } - - // chceking if productWarehouse with same product_id and warehouse_id already - exists, err := s.Repository.ProductWarehouseExists(c.Context(), req.ProductId, req.WarehouseId, nil) - if err != nil { - s.Log.Errorf("Failed to check productWarehouse existence: %+v", err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check productWarehouse existence") - } - - if exists { - return nil, fiber.NewError(fiber.StatusConflict, "ProductWarehouse already exists") - } - - createBody := &entity.ProductWarehouse{ - ProductId: req.ProductId, - WarehouseId: req.WarehouseId, - Quantity: req.Quantity, - CreatedBy: 1, // TODO: Ganti dengan user ID dari context setelah middleware auth diimplementasi - } - - if err := s.Repository.CreateOne(c.Context(), createBody, nil); err != nil { - s.Log.Errorf("Failed to create productWarehouse: %+v", err) - return nil, err - } - - return s.GetOne(c, createBody.Id) -} - -func (s productWarehouseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.ProductWarehouse, error) { - if err := s.Validate.Struct(req); err != nil { - return nil, err - } - - // validation Id exist - if exists, err := s.Repository.ExistsByID(c.Context(), id); err != nil { - s.Log.Errorf("Failed to check productWarehouse existence: %+v", err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check productWarehouse existence") - } else if !exists { - return nil, fiber.NewError(fiber.StatusNotFound, "ProductWarehouse not found") - } - // validation productId and warehouseId exist - if req.ProductId != nil { - isProductExist, err := s.Repository.IsProductExist(c.Context(), *req.ProductId) - if err != nil { - s.Log.Errorf("Failed to check product existence: %+v", err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check product existence") - } - if !isProductExist { - return nil, fiber.NewError(fiber.StatusBadRequest, "Product not found") - } - } - - if req.WarehouseId != nil { - isWarehouseExist, err := s.Repository.IsWarehouseExist(c.Context(), *req.WarehouseId) - if err != nil { - s.Log.Errorf("Failed to check warehouse existence: %+v", err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check warehouse existence") - } - if !isWarehouseExist { - return nil, fiber.NewError(fiber.StatusNotFound, "Warehouse not found") - } - } - - updateBody := make(map[string]any) - - if req.ProductId != nil { - updateBody["product_id"] = *req.ProductId - } - if req.WarehouseId != nil { - updateBody["warehouse_id"] = *req.WarehouseId - } - if req.Quantity != nil { - updateBody["quantity"] = *req.Quantity - } - - if len(updateBody) == 0 { - return s.GetOne(c, id) - } - - if err := s.Repository.PatchOne(c.Context(), id, updateBody, nil); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "ProductWarehouse not found") - } - s.Log.Errorf("Failed to update productWarehouse: %+v", err) - return nil, err - } - - return s.GetOne(c, id) -} - -func (s productWarehouseService) DeleteOne(c *fiber.Ctx, id uint) error { - if err := s.Repository.DeleteOne(c.Context(), id); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, "ProductWarehouse not found") - } - s.Log.Errorf("Failed to delete productWarehouse: %+v", err) - return err - } - return nil -} 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..b53d6e9a --- /dev/null +++ b/internal/modules/inventory/transfers/controllers/transfer.controller.go @@ -0,0 +1,103 @@ +package controller + +import ( + "encoding/json" + "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 { + data := c.FormValue("data") + + var req validation.TransferRequest + if err := json.Unmarshal([]byte(data), &req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + // ambil file + form, err := c.MultipartForm() + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid multipart form") + } + _ = form.File["documents"] + // todo: tunggu ada aws baru proses + + 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..217e5038 --- /dev/null +++ b/internal/modules/inventory/transfers/dto/transfer.dto.go @@ -0,0 +1,225 @@ +package dto + +import ( + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" +) + +// === DTO Structs === + +type TransferBaseDTO struct { + Id uint64 `json:"id"` + TransferReason string `json:"transfer_reason"` + TransferDate string `json:"transfer_date"` + SourceWarehouse *WarehouseDetailDTO `json:"source_warehouse,omitempty"` + DestinationWarehouse *WarehouseDetailDTO `json:"destination_warehouse,omitempty"` +} + +// Only id and name for warehouse simple view +type WarehouseSimpleDTO struct { + Id uint `json:"id"` + Name string `json:"name"` +} + +type AreaDTO struct { + Id uint `json:"id"` + Name string `json:"name"` +} + +type LocationDTO struct { + Id uint `json:"id"` + Name string `json:"name"` +} + +type WarehouseDetailDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + Location *LocationDTO `json:"location"` + Area *AreaDTO `json:"area"` +} + +type TransferListDTO struct { + TransferBaseDTO + CreatedUser *userDTO.UserBaseDTO `json:"created_user,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Details []TransferDetailItemDTO `json:"details"` + Deliveries []TransferDeliveryDTO `json:"deliveries"` +} + +type TransferDetailDTO struct { + TransferListDTO + Details []TransferDetailItemDTO `json:"details"` + Deliveries []TransferDeliveryDTO `json:"deliveries"` +} + +// Detail produk +type TransferDetailItemDTO struct { + Id uint64 `json:"id"` + ProductId uint64 `json:"product_id"` + Quantity float64 `json:"quantity"` + BeforeQuantity float64 `json:"before_quantity"` + AfterQuantity float64 `json:"after_quantity"` +} + +// Delivery ekspedisi +type TransferDeliveryDTO struct { + Id uint64 `json:"id"` + SupplierId uint64 `json:"supplier_id"` + VehiclePlate string `json:"vehicle_plate"` + DriverName string `json:"driver_name"` + DocumentNumber string `json:"document_number"` + DocumentPath string `json:"document_path"` + ShippingCostItem float64 `json:"shipping_cost_item"` + ShippingCostTotal float64 `json:"shipping_cost_total"` + Items []TransferDeliveryItemDTO `json:"items"` +} + +type TransferDeliveryItemDTO struct { + Id uint64 `json:"id"` + StockTransferDetailId uint64 `json:"stock_transfer_detail_id"` + Quantity float64 `json:"quantity"` +} + +// === Mapper Functions === + +func ToTransferBaseDTO(e entity.StockTransfer) TransferBaseDTO { + + var sourceWarehouse *WarehouseDetailDTO + if e.FromWarehouse != nil && e.FromWarehouse.Id != 0 { + sourceWarehouse = toWarehouseDetailDTO(e.FromWarehouse) + } + var destinationWarehouse *WarehouseDetailDTO + if e.ToWarehouse != nil && e.ToWarehouse.Id != 0 { + destinationWarehouse = toWarehouseDetailDTO(e.ToWarehouse) + } + return TransferBaseDTO{ + Id: e.Id, + TransferReason: e.Reason, + TransferDate: e.CreatedAt.Format("2006-01-02"), + SourceWarehouse: sourceWarehouse, + DestinationWarehouse: destinationWarehouse, + } +} + +func toAreaDTO(a *entity.Area) *AreaDTO { + if a == nil { + return nil + } + return &AreaDTO{ + Id: a.Id, + Name: a.Name, + } +} + +func toLocationDTO(l *entity.Location) *LocationDTO { + if l == nil { + return nil + } + return &LocationDTO{ + Id: l.Id, + Name: l.Name, + } +} + +func toWarehouseDetailDTO(w *entity.Warehouse) *WarehouseDetailDTO { + if w == nil { + return nil + } + return &WarehouseDetailDTO{ + Id: w.Id, + Name: w.Name, + Location: toLocationDTO(w.Location), + Area: toAreaDTO(&w.Area), // Ambil area langsung dari warehouse (area_id) + } +} + +func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { + var createdUser *userDTO.UserBaseDTO + if e.CreatedUser != nil { + mapped := userDTO.ToUserBaseDTO(*e.CreatedUser) + createdUser = &mapped + } + // Map details + var details []TransferDetailItemDTO + for _, d := range e.Details { + details = append(details, TransferDetailItemDTO{ + Id: d.Id, + ProductId: d.ProductId, + Quantity: d.Quantity, + }) + } + // Map deliveries + var deliveries []TransferDeliveryDTO + for _, del := range e.Deliveries { + // Map delivery items + var items []TransferDeliveryItemDTO + for _, item := range del.Items { + items = append(items, TransferDeliveryItemDTO{ + Id: item.Id, + StockTransferDetailId: item.StockTransferDetailId, + Quantity: item.Quantity, + }) + } + deliveries = append(deliveries, TransferDeliveryDTO{ + Id: del.Id, + SupplierId: del.SupplierId, + VehiclePlate: del.VehiclePlate, + DriverName: del.DriverName, + DocumentNumber: del.DocumentNumber, + DocumentPath: del.DocumentPath, + ShippingCostItem: del.ShippingCostItem, + ShippingCostTotal: del.ShippingCostTotal, + Items: items, + }) + } + return TransferListDTO{ + TransferBaseDTO: ToTransferBaseDTO(e), + CreatedUser: createdUser, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + Details: details, + Deliveries: deliveries, + } +} + +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 { + // Map details + var details []TransferDetailItemDTO + for _, d := range e.Details { + details = append(details, TransferDetailItemDTO{ + Id: d.Id, + ProductId: d.ProductId, + Quantity: d.Quantity, + }) + } + // Map deliveries + var deliveries []TransferDeliveryDTO + for _, del := range e.Deliveries { + deliveries = append(deliveries, TransferDeliveryDTO{ + Id: del.Id, + SupplierId: del.SupplierId, + VehiclePlate: del.VehiclePlate, + DriverName: del.DriverName, + DocumentNumber: del.DocumentNumber, + DocumentPath: del.DocumentPath, + ShippingCostItem: del.ShippingCostItem, + ShippingCostTotal: del.ShippingCostTotal, + }) + } + return TransferDetailDTO{ + TransferListDTO: ToTransferListDTO(e), + Details: details, + Deliveries: deliveries, + } +} diff --git a/internal/modules/inventory/transfers/module.go b/internal/modules/inventory/transfers/module.go new file mode 100644 index 00000000..21f0ec89 --- /dev/null +++ b/internal/modules/inventory/transfers/module.go @@ -0,0 +1,33 @@ +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" + rSupplier "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/repositories" + 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) + supplierRepo := rSupplier.NewSupplierRepository(db) + productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + userRepo := rUser.NewUserRepository(db) + + transferService := sTransfer.NewTransferService(validate, stockTransferRepo, stockTransferDetailRepo, stockTransferDeliveryRepo, StockTransferDeliveryItemRepo, stockLogsRepo, productWarehouseRepo, supplierRepo) + 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..fa9afd57 --- /dev/null +++ b/internal/modules/inventory/transfers/repositories/stock_transfer_detail.repository.go @@ -0,0 +1,29 @@ +// Find all details by StockTransferId + +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 StockTransferDetailRepository interface { + repository.BaseRepository[entity.StockTransferDetail] + FindByTransferId(ctx context.Context, transferId uint64, out *[]entity.StockTransferDetail) error +} + +type StockTransferDetailRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.StockTransferDetail] +} + +func NewStockTransferDetailRepository(db *gorm.DB) StockTransferDetailRepository { + return &StockTransferDetailRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.StockTransferDetail](db), + } +} +func (r *StockTransferDetailRepositoryImpl) FindByTransferId(ctx context.Context, transferId uint64, out *[]entity.StockTransferDetail) error { + return r.DB().WithContext(ctx).Where("stock_transfer_id = ?", transferId).Find(out).Error +} 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..7f18d257 --- /dev/null +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -0,0 +1,313 @@ +package service + +import ( + "errors" + "fmt" + "strings" + + 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" + rSupplier "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/repositories" + 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 + SupplierRepo rSupplier.SupplierRepository +} + +func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTransfer.StockTransferRepository, stockTransferDetailRepo rStockTransfer.StockTransferDetailRepository, stockTransferDeliveryRepo rStockTransfer.StockTransferDeliveryRepository, stockTransferDeliveryItemRepo rStockTransfer.StockTransferDeliveryItemRepository, stockLogsRepo rStockLogs.StockLogRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, supplierRepo rSupplier.SupplierRepository) TransferService { + return &transferService{ + Log: utils.Log, + Validate: validate, + StockTransferRepo: stockTransferRepo, + StockTransferDetailRepo: stockTransferDetailRepo, + StockTransferDeliveryRepo: stockTransferDeliveryRepo, + StockTransferDeliveryItemRepo: stockTransferDeliveryItemRepo, + StockLogsRepository: stockLogsRepo, + ProductWarehouseRepo: productWarehouseRepo, + SupplierRepo: supplierRepo, + } +} +func (s transferService) withRelations(db *gorm.DB) *gorm.DB { + return db. + Preload("CreatedUser"). + Preload("FromWarehouse"). + Preload("FromWarehouse.Location"). + Preload("FromWarehouse.Area"). + Preload("ToWarehouse"). + Preload("ToWarehouse.Location"). + Preload("ToWarehouse.Area"). + Preload("Details"). + Preload("Deliveries.Items") +} + +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.StockTransferRepo.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { + db = s.withRelations(db) + if params.Search != "" { + db = db.Where("movement_number LIKE ?", "%"+strings.TrimSpace(params.Search)+"%") + } + return db.Order("created_at DESC").Order("updated_at DESC") + }) + + if err != nil { + return nil, 0, err + } + + s.Log.Infof("Retrieved %d transfers", len(transfers)) + + return transfers, total, nil + +} + +func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, error) { + var transfer entity.StockTransfer + + // gunakan repo secara langsung + transferPtr, err := s.StockTransferRepo.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { + return s.withRelations(db) + }) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Transfer not found") + } + s.Log.Errorf("Failed to get transfer by ID: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get transfer") + } + + s.Log.Infof("Retrieved transfer: %+v", transfer) + + return transferPtr, nil +} + +func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferRequest) (*entity.StockTransfer, error) { + + // Validasi stok di gudang asal harus exist dan mencukupi + 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)) + } + } + + // validasi total qty harus lebih besar dari atau sama dengan total qty di delivery compare berdasarkan productid + deliveryQtyMap := make(map[uint]float64) + for _, delivery := range req.Deliveries { + for _, prod := range delivery.Products { + deliveryQtyMap[prod.ProductID] += prod.ProductQty + } + } + + // Cek: qty delivery tidak boleh melebihi qty di root + for _, product := range req.Products { + if deliveryQtyMap[product.ProductID] > product.ProductQty { + return nil, fiber.NewError(fiber.StatusBadRequest, + fmt.Sprintf("Total qty delivery untuk produk %d (%v) melebihi qty transfer (%v)", product.ProductID, deliveryQtyMap[product.ProductID], product.ProductQty)) + } + } + + // cek suplier id caegory BOP cek by id + for _, delivery := range req.Deliveries { + supplier, err := s.SupplierRepo.GetByID(c.Context(), uint(delivery.SupplierID), nil) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Supplier dengan ID %d tidak ditemukan", delivery.SupplierID)) + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal cek data supplier") + } + if supplier.Category != "BOP" { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Supplier dengan ID %d bukan kategori BOP", delivery.SupplierID)) + } + } + + // 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: "dummy duls", // todo: tunggu ada aws baru proses + 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 pivot + detailMap := make(map[uint64]uint64) + for _, d := range details { + detailMap[d.ProductId] = d.Id + } + + var deliveryItems []*entity.StockTransferDeliveryItem + + for i, delivery := range deliveries { + item := req.Deliveries[i] + for _, prod := range item.Products { + detailID, ok := detailMap[uint64(prod.ProductID)] + if !ok { + return fmt.Errorf("produk %d tidak ditemukan di detail", prod.ProductID) + } + deliveryItems = append(deliveryItems, &entity.StockTransferDeliveryItem{ + StockTransferDeliveryId: delivery.Id, + StockTransferDetailId: detailID, + Quantity: prod.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) + } + // Update stok di gudang tujuan + 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") + } + + // Ambil data lengkap hasil create dengan GetOne (agar preload relasi sama dengan GetOne) + result, err := s.GetOne(c, uint(entityTransfer.Id)) + if err != nil { + return nil, err + } + return result, 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..c64077ff --- /dev/null +++ b/internal/modules/inventory/transfers/validations/transfer.validation.go @@ -0,0 +1,40 @@ +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"` + DocumentIndex int `json:"document_index" validate:"min=0"` + DriverName string `json:"driver_name" 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/modules/master/products/repositories/product.repository.go b/internal/modules/master/products/repositories/product.repository.go index 283b8547..06672f5f 100644 --- a/internal/modules/master/products/repositories/product.repository.go +++ b/internal/modules/master/products/repositories/product.repository.go @@ -13,6 +13,7 @@ type ProductRepository interface { NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) SkuExists(ctx context.Context, sku string, excludeID *uint) (bool, error) UomExists(ctx context.Context, uomID uint) (bool, error) + IdExists(ctx context.Context, id uint) (bool, error) CategoryExists(ctx context.Context, categoryID uint) (bool, error) GetSuppliersByIDs(ctx context.Context, supplierIDs []uint) ([]entity.Supplier, error) SyncSuppliersDiff(ctx context.Context, tx *gorm.DB, productID uint, supplierIDs []uint) error @@ -194,3 +195,7 @@ func (r *ProductRepositoryImpl) GetFlags(ctx context.Context, productID uint) ([ } return flags, nil } + +func (r *ProductRepositoryImpl) IdExists(ctx context.Context, id uint) (bool, error) { + return repository.Exists[entity.Product](ctx, r.DB(), id) +} diff --git a/internal/modules/master/suppliers/repositories/supplier.repository.go b/internal/modules/master/suppliers/repositories/supplier.repository.go index ea4e43bf..46fb2983 100644 --- a/internal/modules/master/suppliers/repositories/supplier.repository.go +++ b/internal/modules/master/suppliers/repositories/supplier.repository.go @@ -11,6 +11,7 @@ import ( type SupplierRepository interface { repository.BaseRepository[entity.Supplier] NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) + } type SupplierRepositoryImpl struct { diff --git a/internal/modules/master/warehouses/repositories/warehouse.repository.go b/internal/modules/master/warehouses/repositories/warehouse.repository.go index 6a4e6c16..5c791e01 100644 --- a/internal/modules/master/warehouses/repositories/warehouse.repository.go +++ b/internal/modules/master/warehouses/repositories/warehouse.repository.go @@ -14,6 +14,7 @@ type WarehouseRepository interface { LocationExists(ctx context.Context, locationId uint) (bool, error) KandangExists(ctx context.Context, kandangId uint) (bool, error) NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) + IdExists(ctx context.Context, id uint) (bool, error) } type WarehouseRepositoryImpl struct { @@ -43,3 +44,6 @@ func (r *WarehouseRepositoryImpl) KandangExists(ctx context.Context, kandangId u func (r *WarehouseRepositoryImpl) NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) { return repository.ExistsByName[entity.Warehouse](ctx, r.db, name, excludeID) } +func (r *WarehouseRepositoryImpl) IdExists(ctx context.Context, id uint) (bool, error) { + return repository.Exists[entity.Warehouse](ctx, r.db, id) +} 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") +}