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 28d1f9c3..15beaf58 100644 --- a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go +++ b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go @@ -53,6 +53,24 @@ func (s productWarehouseService) withRelations(db *gorm.DB) *gorm.DB { Preload("ProjectFlockKandang.Chickins") } +func applyWarehouseSelectionFilter(db *gorm.DB, kandangID, locationID uint) *gorm.DB { + switch { + case kandangID != 0 && locationID != 0: + return db.Where( + "w_scope.location_id = ? AND (w_scope.type = ? OR w_scope.kandang_id = ?)", + locationID, + "LOKASI", + kandangID, + ) + case kandangID != 0: + return db.Where("w_scope.kandang_id = ?", kandangID) + case locationID != 0: + return db.Where("w_scope.location_id = ?", locationID) + default: + return db + } +} + func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProductWarehouse, int64, error) { if err := s.Validate.Struct(params); err != nil { return nil, 0, err @@ -133,10 +151,7 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) db = db.Where("product_id = ?", params.ProductId) } - if params.KandangId != 0 { - db = db.Joins("JOIN warehouses ON product_warehouses.warehouse_id = warehouses.id"). - Where("warehouses.kandang_id = ?", params.KandangId) - } + db = applyWarehouseSelectionFilter(db, params.KandangId, params.LocationId) if params.WarehouseId != 0 { db = db.Where("warehouse_id = ?", params.WarehouseId) diff --git a/internal/modules/inventory/product-warehouses/services/product_warehouse.service_test.go b/internal/modules/inventory/product-warehouses/services/product_warehouse.service_test.go new file mode 100644 index 00000000..0dc954df --- /dev/null +++ b/internal/modules/inventory/product-warehouses/services/product_warehouse.service_test.go @@ -0,0 +1,111 @@ +package service + +import ( + "testing" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" +) + +func TestApplyWarehouseSelectionFilterIncludesFarmAndSelectedKandangInLocation(t *testing.T) { + db := setupProductWarehouseServiceTestDB(t) + + var ids []uint + err := applyWarehouseSelectionFilter(baseProductWarehouseSelectionQuery(db), 11, 101). + Order("product_warehouses.id"). + Pluck("product_warehouses.id", &ids).Error + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + assertUintIDs(t, ids, []uint{1, 2}) +} + +func TestApplyWarehouseSelectionFilterPreservesKandangOnlyBehavior(t *testing.T) { + db := setupProductWarehouseServiceTestDB(t) + + var ids []uint + err := applyWarehouseSelectionFilter(baseProductWarehouseSelectionQuery(db), 11, 0). + Order("product_warehouses.id"). + Pluck("product_warehouses.id", &ids).Error + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + assertUintIDs(t, ids, []uint{1}) +} + +func TestApplyWarehouseSelectionFilterSupportsLocationOnlyQuery(t *testing.T) { + db := setupProductWarehouseServiceTestDB(t) + + var ids []uint + err := applyWarehouseSelectionFilter(baseProductWarehouseSelectionQuery(db), 0, 101). + Order("product_warehouses.id"). + Pluck("product_warehouses.id", &ids).Error + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + assertUintIDs(t, ids, []uint{1, 2, 3}) +} + +func setupProductWarehouseServiceTestDB(t *testing.T) *gorm.DB { + t.Helper() + + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{}) + if err != nil { + t.Fatalf("failed opening sqlite db: %v", err) + } + + statements := []string{ + `CREATE TABLE warehouses ( + id INTEGER PRIMARY KEY, + type TEXT NOT NULL, + location_id INTEGER NULL, + kandang_id INTEGER NULL, + deleted_at TIMESTAMP NULL + )`, + `CREATE TABLE product_warehouses ( + id INTEGER PRIMARY KEY, + warehouse_id INTEGER NOT NULL + )`, + `INSERT INTO warehouses (id, type, location_id, kandang_id, deleted_at) VALUES + (1, 'KANDANG', 101, 11, NULL), + (2, 'LOKASI', 101, NULL, NULL), + (3, 'KANDANG', 101, 12, NULL), + (4, 'LOKASI', 102, NULL, NULL)`, + `INSERT INTO product_warehouses (id, warehouse_id) VALUES + (1, 1), + (2, 2), + (3, 3), + (4, 4)`, + } + + for _, stmt := range statements { + if err := db.Exec(stmt).Error; err != nil { + t.Fatalf("failed preparing schema: %v", err) + } + } + + return db +} + +func baseProductWarehouseSelectionQuery(db *gorm.DB) *gorm.DB { + return db.Table("product_warehouses"). + Joins("JOIN warehouses w_scope ON product_warehouses.warehouse_id = w_scope.id"). + Where("w_scope.deleted_at IS NULL") +} + +func assertUintIDs(t *testing.T, got []uint, want []uint) { + t.Helper() + + if len(got) != len(want) { + t.Fatalf("expected ids %v, got %v", want, got) + } + + for i := range want { + if got[i] != want[i] { + t.Fatalf("expected ids %v, got %v", want, got) + } + } +} diff --git a/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go b/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go index 348fd96d..0e3ad15d 100644 --- a/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go +++ b/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go @@ -17,6 +17,7 @@ type Query struct { Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` ProductId uint `query:"product_id" validate:"omitempty,number,min=1"` WarehouseId uint `query:"warehouse_id" validate:"omitempty,number,min=1"` + LocationId uint `query:"location_id" validate:"omitempty,number,min=1"` Flags string `query:"flags" validate:"omitempty"` KandangId uint `query:"kandang_id" validate:"omitempty,number,min=1"` TransferContext string `query:"transfer_context" validate:"omitempty,oneof=inventory_transfer"` diff --git a/internal/modules/purchases/repositories/purchase.repository.go b/internal/modules/purchases/repositories/purchase.repository.go index 2cb0ba75..56e6b8c6 100644 --- a/internal/modules/purchases/repositories/purchase.repository.go +++ b/internal/modules/purchases/repositories/purchase.repository.go @@ -143,6 +143,17 @@ func (r *PurchaseRepositoryImpl) CreateItems(ctx context.Context, purchaseID uin return r.DB().WithContext(ctx).Create(&items).Error } +func (r *PurchaseRepositoryImpl) purchaseItemExists(ctx context.Context, purchaseID uint, itemID uint) (bool, error) { + var count int64 + if err := r.DB().WithContext(ctx). + Model(&entity.PurchaseItem{}). + Where("purchase_id = ? AND id = ?", purchaseID, itemID). + Count(&count).Error; err != nil { + return false, err + } + return count > 0, nil +} + type PurchasePricingUpdate struct { ItemID uint ProductID *uint @@ -197,7 +208,13 @@ func (r *PurchaseRepositoryImpl) UpdatePricing( return result.Error } if result.RowsAffected == 0 { - return gorm.ErrRecordNotFound + exists, err := r.purchaseItemExists(ctx, purchaseID, upd.ItemID) + if err != nil { + return err + } + if !exists { + return gorm.ErrRecordNotFound + } } } @@ -251,7 +268,13 @@ func (r *PurchaseRepositoryImpl) UpdateReceivingDetails( return result.Error } if result.RowsAffected == 0 { - return gorm.ErrRecordNotFound + exists, err := r.purchaseItemExists(ctx, purchaseID, upd.ItemID) + if err != nil { + return err + } + if !exists { + return gorm.ErrRecordNotFound + } } } diff --git a/internal/modules/purchases/repositories/purchase.repository_test.go b/internal/modules/purchases/repositories/purchase.repository_test.go new file mode 100644 index 00000000..cf6ddb5d --- /dev/null +++ b/internal/modules/purchases/repositories/purchase.repository_test.go @@ -0,0 +1,152 @@ +package repositories + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/glebarez/sqlite" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +func TestUpdateReceivingDetailsAllowsNoOpUpdatesOnExistingItem(t *testing.T) { + db := setupPurchaseRepositoryTestDB(t) + repo := NewPurchaseRepository(db) + ctx := context.Background() + + receivedAt := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC) + travelNumber := "SJ-001" + vehicleNumber := "B 1234 CD" + + if err := db.WithContext(ctx).Create(&entity.PurchaseItem{ + Id: 10, + PurchaseId: 1, + ProductId: 2, + WarehouseId: 3, + SubQty: 10, + TotalQty: 10, + Price: 15000, + TotalPrice: 150000, + ReceivedDate: &receivedAt, + TravelNumber: &travelNumber, + VehicleNumber: &vehicleNumber, + }).Error; err != nil { + t.Fatalf("failed seeding purchase item: %v", err) + } + + pwID := uint(99) + if err := repo.UpdateReceivingDetails(ctx, 1, []PurchaseReceivingUpdate{ + { + ItemID: 10, + ReceivedDate: &receivedAt, + TravelNumber: &travelNumber, + VehicleNumber: &vehicleNumber, + ProductWarehouseID: &pwID, + }, + }); err != nil { + t.Fatalf("expected no-op receive update to succeed, got %v", err) + } +} + +func TestUpdateReceivingDetailsReturnsNotFoundForMissingItem(t *testing.T) { + db := setupPurchaseRepositoryTestDB(t) + repo := NewPurchaseRepository(db) + ctx := context.Background() + + receivedAt := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC) + + err := repo.UpdateReceivingDetails(ctx, 1, []PurchaseReceivingUpdate{ + { + ItemID: 999, + ReceivedDate: &receivedAt, + }, + }) + if !errors.Is(err, gorm.ErrRecordNotFound) { + t.Fatalf("expected gorm.ErrRecordNotFound, got %v", err) + } +} + +func TestUpdatePricingAllowsNoOpUpdatesOnExistingItem(t *testing.T) { + db := setupPurchaseRepositoryTestDB(t) + repo := NewPurchaseRepository(db) + ctx := context.Background() + + if err := db.WithContext(ctx).Create(&entity.PurchaseItem{ + Id: 20, + PurchaseId: 2, + ProductId: 5, + WarehouseId: 6, + SubQty: 5, + TotalQty: 5, + Price: 10000, + TotalPrice: 50000, + }).Error; err != nil { + t.Fatalf("failed seeding purchase item: %v", err) + } + + if err := repo.UpdatePricing(ctx, 2, []PurchasePricingUpdate{ + { + ItemID: 20, + Price: 10000, + TotalPrice: 50000, + }, + }); err != nil { + t.Fatalf("expected no-op pricing update to succeed, got %v", err) + } +} + +func TestUpdatePricingReturnsNotFoundForMissingItem(t *testing.T) { + db := setupPurchaseRepositoryTestDB(t) + repo := NewPurchaseRepository(db) + ctx := context.Background() + + err := repo.UpdatePricing(ctx, 2, []PurchasePricingUpdate{ + { + ItemID: 777, + Price: 10000, + TotalPrice: 50000, + }, + }) + if !errors.Is(err, gorm.ErrRecordNotFound) { + t.Fatalf("expected gorm.ErrRecordNotFound, got %v", err) + } +} + +func setupPurchaseRepositoryTestDB(t *testing.T) *gorm.DB { + t.Helper() + + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{}) + if err != nil { + t.Fatalf("failed opening sqlite db: %v", err) + } + + statements := []string{ + `CREATE TABLE purchase_items ( + id INTEGER PRIMARY KEY, + purchase_id INTEGER NOT NULL, + product_id INTEGER NOT NULL, + warehouse_id INTEGER NOT NULL, + product_warehouse_id INTEGER NULL, + project_flock_kandang_id INTEGER NULL, + received_date TIMESTAMP NULL, + travel_number TEXT NULL, + travel_number_docs TEXT NULL, + vehicle_number TEXT NULL, + sub_qty NUMERIC(15,3) NOT NULL DEFAULT 0, + total_qty NUMERIC(15,3) NOT NULL DEFAULT 0, + total_used NUMERIC(15,3) NOT NULL DEFAULT 0, + price NUMERIC(15,3) NOT NULL DEFAULT 0, + total_price NUMERIC(15,3) NOT NULL DEFAULT 0, + expense_nonstock_id INTEGER NULL + )`, + } + for _, stmt := range statements { + if err := db.Exec(stmt).Error; err != nil { + t.Fatalf("failed preparing schema: %v", err) + } + } + + return db +}