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 5737e9f0..5fd060dd 100644 --- a/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller.go +++ b/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller.go @@ -29,6 +29,7 @@ func (u *ProductWarehouseController) GetAll(c *fiber.Ctx) error { Limit: c.QueryInt("limit", 10), ProductId: uint(c.QueryInt("product_id", 0)), WarehouseId: uint(c.QueryInt("warehouse_id", 0)), + LocationId: uint(c.QueryInt("location_id", 0)), Flags: c.Query("flags", ""), KandangId: uint(c.QueryInt("kandang_id", 0)), TransferContext: c.Query(utils.TransferContextKey, ""), diff --git a/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller_test.go b/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller_test.go new file mode 100644 index 00000000..93a015ca --- /dev/null +++ b/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller_test.go @@ -0,0 +1,64 @@ +package controller + +import ( + "errors" + "net/http/httptest" + "testing" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/validations" + "gorm.io/gorm" +) + +type stubProductWarehouseService struct { + lastQuery *validation.Query +} + +func (s *stubProductWarehouseService) GetAll(_ *fiber.Ctx, params *validation.Query) ([]entity.ProductWarehouse, int64, error) { + s.lastQuery = params + return []entity.ProductWarehouse{}, 0, nil +} + +func (s *stubProductWarehouseService) GetOne(_ *fiber.Ctx, _ uint) (*entity.ProductWarehouse, error) { + return nil, gorm.ErrRecordNotFound +} + +var _ service.ProductWarehouseService = (*stubProductWarehouseService)(nil) + +func TestGetAllParsesLocationID(t *testing.T) { + app := fiber.New() + stub := &stubProductWarehouseService{} + ctrl := NewProductWarehouseController(stub) + app.Get("/product-warehouses", ctrl.GetAll) + + req := httptest.NewRequest("GET", "/product-warehouses?location_id=16&kandang_id=59&limit=25", nil) + resp, err := app.Test(req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("expected status 200, got %d", resp.StatusCode) + } + if stub.lastQuery == nil { + t.Fatalf("expected service to receive query") + } + if stub.lastQuery.LocationId != 16 { + t.Fatalf("expected location_id 16, got %d", stub.lastQuery.LocationId) + } + if stub.lastQuery.KandangId != 59 { + t.Fatalf("expected kandang_id 59, got %d", stub.lastQuery.KandangId) + } + if stub.lastQuery.Limit != 25 { + t.Fatalf("expected limit 25, got %d", stub.lastQuery.Limit) + } +} + +func TestStubImplementsServiceContract(t *testing.T) { + validate := validator.New() + if validate == nil { + t.Fatal(errors.New("validator should not be nil")) + } +} 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 87d114c9..2659533d 100644 --- a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go +++ b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go @@ -7,6 +7,7 @@ import ( "gitlab.com/mbugroup/lti-api.git/internal/common/repository" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/utils" "gorm.io/gorm" ) @@ -164,10 +165,42 @@ func (r *ProductWarehouseRepositoryImpl) ApplyFlagsFilter(db *gorm.DB, flags []s return db } - return db. + fallbackCategoryCodes := utils.LegacyProductCategoryCodesForFlags(flags) + + db = db. Joins("JOIN products p_flag ON p_flag.id = product_warehouses.product_id"). - Joins("JOIN flags f_flag ON f_flag.flagable_id = p_flag.id AND f_flag.flagable_type = ?", "products"). - Where("f_flag.name IN ?", flags). + Joins("LEFT JOIN product_categories pc_flag ON pc_flag.id = p_flag.product_category_id") + + actualFlagFilter := ` + EXISTS ( + SELECT 1 + FROM flags f_flag + WHERE f_flag.flagable_id = p_flag.id + AND f_flag.flagable_type = ? + AND f_flag.name IN ? + ) + ` + + if len(fallbackCategoryCodes) == 0 { + return db.Where(actualFlagFilter, entity.FlagableTypeProduct, flags).Distinct() + } + + return db. + Where( + `(`+actualFlagFilter+`) OR ( + NOT EXISTS ( + SELECT 1 + FROM flags f_any + WHERE f_any.flagable_id = p_flag.id + AND f_any.flagable_type = ? + ) + AND pc_flag.code IN ? + )`, + entity.FlagableTypeProduct, + flags, + entity.FlagableTypeProduct, + fallbackCategoryCodes, + ). Distinct() } diff --git a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository_test.go b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository_test.go index a224404b..3f810e53 100644 --- a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository_test.go +++ b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/glebarez/sqlite" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" "gorm.io/gorm" ) @@ -115,3 +116,75 @@ func insertProductWarehouseTestFixtures(t *testing.T, db *gorm.DB) { } } } + +func TestApplyFlagsFilterIncludesLegacyCategoryFallback(t *testing.T) { + db := setupProductWarehouseFlagFilterTestDB(t) + repo := NewProductWarehouseRepository(db) + ctx := context.Background() + + var ids []uint + err := repo.ApplyFlagsFilter( + db.WithContext(ctx).Model(&entity.ProductWarehouse{}), + []string{"PAKAN"}, + ).Order("product_warehouses.id").Pluck("product_warehouses.id", &ids).Error + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(ids) != 2 || ids[0] != 1 || ids[1] != 2 { + t.Fatalf("expected flagged and legacy RAW rows to match, got %v", ids) + } +} + +func TestApplyFlagsFilterDoesNotFallbackWhenProductAlreadyHasDifferentFlags(t *testing.T) { + db := setupProductWarehouseFlagFilterTestDB(t) + repo := NewProductWarehouseRepository(db) + ctx := context.Background() + + var ids []uint + err := repo.ApplyFlagsFilter( + db.WithContext(ctx).Model(&entity.ProductWarehouse{}), + []string{"PAKAN"}, + ).Where("product_warehouses.id = ?", 3).Pluck("product_warehouses.id", &ids).Error + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(ids) != 0 { + t.Fatalf("expected OVK-flagged product not to match PAKAN fallback, got %v", ids) + } +} + +func setupProductWarehouseFlagFilterTestDB(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 product_categories (id INTEGER PRIMARY KEY, code TEXT NOT NULL)`, + `CREATE TABLE products (id INTEGER PRIMARY KEY, product_category_id INTEGER NOT NULL)`, + `CREATE TABLE flags (id INTEGER PRIMARY KEY, flagable_id INTEGER NOT NULL, flagable_type TEXT NOT NULL, name TEXT NOT NULL)`, + `CREATE TABLE product_warehouses (id INTEGER PRIMARY KEY, product_id INTEGER NOT NULL, warehouse_id INTEGER NOT NULL, project_flock_kandang_id INTEGER NULL, qty NUMERIC(15,3) NOT NULL DEFAULT 0)`, + `INSERT INTO product_categories (id, code) VALUES (1, 'STR'), (2, 'RAW'), (3, 'OBT')`, + `INSERT INTO products (id, product_category_id) VALUES (10, 1), (20, 2), (30, 2), (40, 3)`, + `INSERT INTO flags (id, flagable_id, flagable_type, name) VALUES + (1, 10, 'products', 'PAKAN'), + (2, 10, 'products', 'STARTER'), + (3, 40, 'products', 'OVK'), + (4, 40, 'products', 'OBAT')`, + `INSERT INTO product_warehouses (id, product_id, warehouse_id, project_flock_kandang_id, qty) VALUES + (1, 10, 1, NULL, 10), + (2, 20, 1, NULL, 20), + (3, 40, 1, NULL, 30)`, + } + for _, stmt := range statements { + if err := db.Exec(stmt).Error; err != nil { + t.Fatalf("failed preparing schema: %v", err) + } + } + + return db +} diff --git a/internal/modules/purchases/services/fifo_stock_v2_helper.go b/internal/modules/purchases/services/fifo_stock_v2_helper.go index e0b619a9..da7770cc 100644 --- a/internal/modules/purchases/services/fifo_stock_v2_helper.go +++ b/internal/modules/purchases/services/fifo_stock_v2_helper.go @@ -2,12 +2,14 @@ package service import ( "context" + "errors" "fmt" "strings" "time" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/utils" "gorm.io/gorm" ) @@ -76,11 +78,53 @@ func resolvePurchaseFlagGroupByProductWarehouse(ctx context.Context, tx *gorm.DB Order("rr.id ASC"). Limit(1). Take(&selected).Error + if err == nil { + return strings.TrimSpace(selected.FlagGroupCode), nil + } + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return "", err + } + + type categoryRow struct { + CategoryCode string `gorm:"column:category_code"` + } + + var category categoryRow + err = tx.WithContext(ctx). + Table("product_warehouses pw"). + Select("pc.code AS category_code"). + Joins("JOIN products p ON p.id = pw.product_id"). + Joins("JOIN product_categories pc ON pc.id = p.product_category_id"). + Where("pw.id = ?", productWarehouseID). + Limit(1). + Take(&category).Error if err != nil { return "", err } - return strings.TrimSpace(selected.FlagGroupCode), nil + flagGroupCode := utils.LegacyFlagGroupCodeByProductCategoryCode(category.CategoryCode) + if flagGroupCode == "" { + return "", gorm.ErrRecordNotFound + } + + var matched int64 + err = tx.WithContext(ctx). + Table("fifo_stock_v2_route_rules rr"). + Joins("JOIN fifo_stock_v2_flag_groups fg ON fg.code = rr.flag_group_code AND fg.is_active = TRUE"). + Where("rr.is_active = TRUE"). + Where("rr.lane = ?", purchaseStockableLane). + Where("rr.function_code = ?", purchaseInFunctionCode). + Where("rr.source_table = ?", purchaseSourceTable). + Where("rr.flag_group_code = ?", flagGroupCode). + Count(&matched).Error + if err != nil { + return "", err + } + if matched == 0 { + return "", gorm.ErrRecordNotFound + } + + return flagGroupCode, nil } func assignEarliestAsOf(m map[uint]time.Time, productWarehouseID uint, asOf time.Time) { diff --git a/internal/modules/purchases/services/fifo_stock_v2_helper_test.go b/internal/modules/purchases/services/fifo_stock_v2_helper_test.go new file mode 100644 index 00000000..dbc29110 --- /dev/null +++ b/internal/modules/purchases/services/fifo_stock_v2_helper_test.go @@ -0,0 +1,80 @@ +package service + +import ( + "context" + "testing" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" +) + +func TestResolvePurchaseFlagGroupByProductWarehouseFallsBackToProductCategory(t *testing.T) { + db := setupPurchaseFifoHelperTestDB(t) + ctx := context.Background() + + flagGroupCode, err := resolvePurchaseFlagGroupByProductWarehouse(ctx, db, 1115) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if flagGroupCode != "PAKAN" { + t.Fatalf("expected PAKAN, got %s", flagGroupCode) + } +} + +func TestResolvePurchaseFlagGroupByProductWarehouseUsesProductFlagsWhenPresent(t *testing.T) { + db := setupPurchaseFifoHelperTestDB(t) + ctx := context.Background() + + flagGroupCode, err := resolvePurchaseFlagGroupByProductWarehouse(ctx, db, 2222) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if flagGroupCode != "OVK" { + t.Fatalf("expected OVK, got %s", flagGroupCode) + } +} + +func setupPurchaseFifoHelperTestDB(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 fifo_stock_v2_flag_groups (code TEXT PRIMARY KEY, is_active BOOLEAN NOT NULL)`, + `CREATE TABLE fifo_stock_v2_flag_members (flag_name TEXT NOT NULL, flag_group_code TEXT NOT NULL, is_active BOOLEAN NOT NULL)`, + `CREATE TABLE fifo_stock_v2_route_rules ( + id INTEGER PRIMARY KEY, + flag_group_code TEXT NOT NULL, + lane TEXT NOT NULL, + function_code TEXT NOT NULL, + source_table TEXT NOT NULL, + is_active BOOLEAN NOT NULL + )`, + `CREATE TABLE product_categories (id INTEGER PRIMARY KEY, code TEXT NOT NULL)`, + `CREATE TABLE products (id INTEGER PRIMARY KEY, product_category_id INTEGER NOT NULL)`, + `CREATE TABLE flags (id INTEGER PRIMARY KEY, flagable_id INTEGER NOT NULL, flagable_type TEXT NOT NULL, name TEXT NOT NULL)`, + `CREATE TABLE product_warehouses (id INTEGER PRIMARY KEY, product_id INTEGER NOT NULL)`, + `INSERT INTO fifo_stock_v2_flag_groups (code, is_active) VALUES ('PAKAN', TRUE), ('OVK', TRUE)`, + `INSERT INTO fifo_stock_v2_flag_members (flag_name, flag_group_code, is_active) VALUES ('PAKAN', 'PAKAN', TRUE), ('OVK', 'OVK', TRUE), ('OBAT', 'OVK', TRUE)`, + `INSERT INTO fifo_stock_v2_route_rules (id, flag_group_code, lane, function_code, source_table, is_active) VALUES + (1, 'PAKAN', 'STOCKABLE', 'PURCHASE_IN', 'purchase_items', TRUE), + (2, 'OVK', 'STOCKABLE', 'PURCHASE_IN', 'purchase_items', TRUE)`, + `INSERT INTO product_categories (id, code) VALUES (1, 'RAW'), (2, 'OBT')`, + `INSERT INTO products (id, product_category_id) VALUES (37, 1), (112, 2)`, + `INSERT INTO flags (id, flagable_id, flagable_type, name) VALUES + (1, 112, 'products', 'OVK'), + (2, 112, 'products', 'OBAT')`, + `INSERT INTO product_warehouses (id, product_id) VALUES (1115, 37), (2222, 112)`, + } + + for _, stmt := range statements { + if err := db.Exec(stmt).Error; err != nil { + t.Fatalf("failed preparing schema: %v", err) + } + } + + return db +} diff --git a/internal/utils/constant.go b/internal/utils/constant.go index c263180b..dfe4ef6e 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -1,6 +1,7 @@ package utils import ( + "slices" "strings" approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" @@ -129,10 +130,23 @@ var productSubFlagToFlag = func() map[FlagType]FlagType { }() var productAllowWithoutSubFlagByFlag = map[FlagType]bool{ - FlagAyam: true, - FlagPakan: false, - FlagOVK: false, - FlagTelur: false, + FlagAyam: true, + FlagPakan: false, + FlagOVK: false, + FlagTelur: false, +} + +var legacyProductCategoryFlagsByCode = map[string][]FlagType{ + "DOC": {FlagAyam, FlagDOC}, + "PLT": {FlagAyam, FlagPullet}, + "EGG": {FlagTelur}, + "RAW": {FlagPakan}, + "PST": {FlagPakan, FlagPreStarter}, + "STR": {FlagPakan, FlagStarter}, + "FSR": {FlagPakan, FlagFinisher}, + "OBT": {FlagOVK, FlagObat}, + "VTM": {FlagOVK, FlagVitamin}, + "KMA": {FlagOVK, FlagKimia}, } var legacyFlagTypeAliases = map[FlagType]FlagType{ @@ -228,6 +242,52 @@ func ProductFlagAllowWithoutSubFlag(flag FlagType) bool { return allow } +func LegacyProductCategoryCodesForFlags(flags []string) []string { + if len(flags) == 0 { + return nil + } + + requested := make(map[FlagType]struct{}, len(flags)) + for _, flag := range flags { + canonical := CanonicalFlagType(flag) + if canonical == "" { + continue + } + requested[canonical] = struct{}{} + } + if len(requested) == 0 { + return nil + } + + codes := make([]string, 0, len(legacyProductCategoryFlagsByCode)) + for code, supportedFlags := range legacyProductCategoryFlagsByCode { + for _, supportedFlag := range supportedFlags { + if _, ok := requested[canonicalizeFlagType(supportedFlag)]; ok { + codes = append(codes, code) + break + } + } + } + + slices.Sort(codes) + return codes +} + +func LegacyFlagGroupCodeByProductCategoryCode(code string) string { + switch strings.ToUpper(strings.TrimSpace(code)) { + case "DOC", "PLT": + return "AYAM" + case "EGG": + return "TELUR" + case "RAW", "PST", "STR", "FSR": + return "PAKAN" + case "OBT", "VTM", "KMA": + return "OVK" + default: + return "" + } +} + func IsProductMainFlag(flag FlagType) bool { canonical := canonicalizeFlagType(flag) for _, f := range productMainFlags {