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 839854cc..21ce1a76 100644 --- a/internal/database/seed/seeder.go +++ b/internal/database/seed/seeder.go @@ -89,6 +89,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 }) @@ -936,7 +940,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 { @@ -960,6 +964,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/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/services/product_warehouse.service.go b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go index 7a1ff00e..9afe5707 100644 --- a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go +++ b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go @@ -34,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) { 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/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/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") +}