From 2d49ffe4cd4459cabdc2dba40527fe1af98bf47e Mon Sep 17 00:00:00 2001 From: "Hafizh A. Y" Date: Fri, 3 Oct 2025 21:04:21 +0700 Subject: [PATCH] Feat(BE-36,37,38,39): finish master data management api --- cmd/api/main.go | 2 - ...250925040409_create_master_tables.down.sql | 4 + ...20250925040409_create_master_tables.up.sql | 63 +- internal/database/seed/seeder.go | 765 +++++++++++++++++- internal/entities/area.go | 2 +- internal/entities/bank.go | 21 + internal/entities/constant.go | 18 + internal/entities/customer.go | 8 +- internal/entities/fcr.go | 19 + internal/entities/fcr_standard.go | 20 + internal/entities/flag.go | 17 + internal/entities/kandang.go | 2 +- internal/entities/location.go | 2 +- internal/entities/nonstock.go | 22 + internal/entities/nonstock_supplier.go | 9 + internal/entities/product-category.go | 19 + internal/entities/product.go | 30 + internal/entities/product_supplier.go | 9 + internal/entities/supplier.go | 30 + internal/entities/uom.go | 2 +- .../controllers/constant.controller.go | 25 + internal/modules/constants/module.go | 20 + .../repositories/constant.repository.go | 46 ++ internal/modules/constants/route.go | 17 + .../constants/services/constant.service.go | 26 + internal/modules/master/areas/dto/area.dto.go | 6 + .../master/areas/services/area.service.go | 2 +- .../areas/validations/area.validation.go | 4 +- .../banks/controllers/bank.controller.go | 140 ++++ internal/modules/master/banks/dto/bank.dto.go | 70 ++ internal/modules/master/banks/module.go | 26 + .../banks/repositories/bank.repository.go | 30 + internal/modules/master/banks/route.go | 28 + .../master/banks/services/bank.service.go | 156 ++++ .../banks/validations/bank.validation.go | 21 + .../master/customers/dto/customer.dto.go | 23 +- .../customers/services/customer.service.go | 11 +- .../validations/customer.validation.go | 2 +- .../master/fcrs/controllers/fcr.controller.go | 140 ++++ internal/modules/master/fcrs/dto/fcr.dto.go | 86 ++ internal/modules/master/fcrs/module.go | 25 + .../fcrs/repositories/fcr.repository.go | 90 +++ internal/modules/master/fcrs/route.go | 28 + .../master/fcrs/services/fcr.service.go | 219 +++++ .../master/fcrs/validations/fcr.validation.go | 23 + .../master/kandangs/dto/kandang.dto.go | 6 + .../kandangs/services/kandang.service.go | 15 +- .../validations/kandang.validation.go | 2 +- .../master/locations/dto/location.dto.go | 6 + .../locations/services/location.service.go | 15 +- .../validations/location.validation.go | 2 +- .../controllers/nonstock.controller.go | 140 ++++ .../master/nonstocks/dto/nonstock.dto.go | 97 +++ internal/modules/master/nonstocks/module.go | 26 + .../repositories/nonstock.repository.go | 172 ++++ internal/modules/master/nonstocks/route.go | 28 + .../nonstocks/services/nonstock.service.go | 308 +++++++ .../validations/nonstock.validation.go | 21 + .../product-category.controller.go | 140 ++++ .../dto/product-category.dto.go | 66 ++ .../master/product-categories/module.go | 25 + .../product-category.repository.go | 44 + .../master/product-categories/route.go | 28 + .../services/product-category.service.go | 175 ++++ .../product-category.validation.go | 17 + .../controllers/product.controller.go | 140 ++++ .../master/products/dto/product.dto.go | 116 +++ internal/modules/master/products/module.go | 26 + .../repositories/product.repository.go | 196 +++++ internal/modules/master/products/route.go | 28 + .../products/services/product.service.go | 384 +++++++++ .../validations/product.validation.go | 35 + internal/modules/master/route.go | 12 + .../controllers/supplier.controller.go | 140 ++++ .../master/suppliers/dto/supplier.dto.go | 88 ++ internal/modules/master/suppliers/module.go | 26 + .../repositories/supplier.repository.go | 30 + internal/modules/master/suppliers/route.go | 28 + .../suppliers/services/supplier.service.go | 221 +++++ .../validations/supplier.validation.go | 37 + internal/modules/master/uoms/dto/uom.dto.go | 6 + .../master/uoms/services/uom.service.go | 2 +- .../master/uoms/validations/uom.validation.go | 4 +- .../master/warehouses/dto/warehouse.dto.go | 6 + .../warehouses/services/warehouse.service.go | 18 +- .../validations/warehouse.validation.go | 2 +- .../users/validations/user.validation.go | 4 +- internal/route/route.go | 4 +- internal/utils/constant.go | 159 +++- internal/utils/slice.go | 34 + test/integration/master_data/bank_test.go | 127 +++ test/integration/master_data/customer_test.go | 14 +- test/integration/master_data/fcr_test.go | 218 +++++ test/integration/master_data/master_data.go | 191 ++++- test/integration/master_data/nonstock_test.go | 309 +++++++ .../master_data/product_category_test.go | 150 ++++ test/integration/master_data/product_test.go | 410 ++++++++++ test/integration/master_data/supplier_test.go | 238 ++++++ tools/gen.go | 15 +- tools/templates/dto.tmpl | 6 + tools/templates/entity.tmpl | 2 +- tools/templates/service.tmpl | 2 +- tools/templates/validation.tmpl | 2 +- 103 files changed, 6974 insertions(+), 117 deletions(-) create mode 100644 internal/entities/bank.go create mode 100644 internal/entities/constant.go create mode 100644 internal/entities/fcr.go create mode 100644 internal/entities/fcr_standard.go create mode 100644 internal/entities/flag.go create mode 100644 internal/entities/nonstock.go create mode 100644 internal/entities/nonstock_supplier.go create mode 100644 internal/entities/product-category.go create mode 100644 internal/entities/product.go create mode 100644 internal/entities/product_supplier.go create mode 100644 internal/entities/supplier.go create mode 100644 internal/modules/constants/controllers/constant.controller.go create mode 100644 internal/modules/constants/module.go create mode 100644 internal/modules/constants/repositories/constant.repository.go create mode 100644 internal/modules/constants/route.go create mode 100644 internal/modules/constants/services/constant.service.go create mode 100644 internal/modules/master/banks/controllers/bank.controller.go create mode 100644 internal/modules/master/banks/dto/bank.dto.go create mode 100644 internal/modules/master/banks/module.go create mode 100644 internal/modules/master/banks/repositories/bank.repository.go create mode 100644 internal/modules/master/banks/route.go create mode 100644 internal/modules/master/banks/services/bank.service.go create mode 100644 internal/modules/master/banks/validations/bank.validation.go create mode 100644 internal/modules/master/fcrs/controllers/fcr.controller.go create mode 100644 internal/modules/master/fcrs/dto/fcr.dto.go create mode 100644 internal/modules/master/fcrs/module.go create mode 100644 internal/modules/master/fcrs/repositories/fcr.repository.go create mode 100644 internal/modules/master/fcrs/route.go create mode 100644 internal/modules/master/fcrs/services/fcr.service.go create mode 100644 internal/modules/master/fcrs/validations/fcr.validation.go create mode 100644 internal/modules/master/nonstocks/controllers/nonstock.controller.go create mode 100644 internal/modules/master/nonstocks/dto/nonstock.dto.go create mode 100644 internal/modules/master/nonstocks/module.go create mode 100644 internal/modules/master/nonstocks/repositories/nonstock.repository.go create mode 100644 internal/modules/master/nonstocks/route.go create mode 100644 internal/modules/master/nonstocks/services/nonstock.service.go create mode 100644 internal/modules/master/nonstocks/validations/nonstock.validation.go create mode 100644 internal/modules/master/product-categories/controllers/product-category.controller.go create mode 100644 internal/modules/master/product-categories/dto/product-category.dto.go create mode 100644 internal/modules/master/product-categories/module.go create mode 100644 internal/modules/master/product-categories/repositories/product-category.repository.go create mode 100644 internal/modules/master/product-categories/route.go create mode 100644 internal/modules/master/product-categories/services/product-category.service.go create mode 100644 internal/modules/master/product-categories/validations/product-category.validation.go create mode 100644 internal/modules/master/products/controllers/product.controller.go create mode 100644 internal/modules/master/products/dto/product.dto.go create mode 100644 internal/modules/master/products/module.go create mode 100644 internal/modules/master/products/repositories/product.repository.go create mode 100644 internal/modules/master/products/route.go create mode 100644 internal/modules/master/products/services/product.service.go create mode 100644 internal/modules/master/products/validations/product.validation.go create mode 100644 internal/modules/master/suppliers/controllers/supplier.controller.go create mode 100644 internal/modules/master/suppliers/dto/supplier.dto.go create mode 100644 internal/modules/master/suppliers/module.go create mode 100644 internal/modules/master/suppliers/repositories/supplier.repository.go create mode 100644 internal/modules/master/suppliers/route.go create mode 100644 internal/modules/master/suppliers/services/supplier.service.go create mode 100644 internal/modules/master/suppliers/validations/supplier.validation.go create mode 100644 internal/utils/slice.go create mode 100644 test/integration/master_data/bank_test.go create mode 100644 test/integration/master_data/fcr_test.go create mode 100644 test/integration/master_data/nonstock_test.go create mode 100644 test/integration/master_data/product_category_test.go create mode 100644 test/integration/master_data/product_test.go create mode 100644 test/integration/master_data/supplier_test.go diff --git a/cmd/api/main.go b/cmd/api/main.go index 52f9ed73..0bcbaa86 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -60,11 +60,9 @@ func setupFiberApp() *fiber.App { app := fiber.New(config.FiberConfig()) // Middleware setup - app.Use("/api/auth", middleware.LimiterConfig()) app.Use(middleware.LoggerConfig()) app.Use(helmet.New()) app.Use(compress.New()) - app.Use(cors.New()) app.Use(middleware.RecoverConfig()) origins := "*" diff --git a/internal/database/migrations/20250925040409_create_master_tables.down.sql b/internal/database/migrations/20250925040409_create_master_tables.down.sql index 729fed68..3f32606a 100644 --- a/internal/database/migrations/20250925040409_create_master_tables.down.sql +++ b/internal/database/migrations/20250925040409_create_master_tables.down.sql @@ -1,9 +1,12 @@ DROP TABLE IF EXISTS fcr_standards; DROP INDEX IF EXISTS suppliers_name_unique; +DROP TABLE IF EXISTS product_suppliers; DROP INDEX IF EXISTS products_sku_unique; DROP INDEX IF EXISTS products_name_unique; DROP TABLE IF EXISTS products; +DROP INDEX IF EXISTS flags_flagable_lookup; +DROP INDEX IF EXISTS flags_unique_flagable; DROP TABLE IF EXISTS flags; DROP INDEX IF EXISTS customers_name_unique; DROP INDEX IF EXISTS customers_email_unique; @@ -13,6 +16,7 @@ DROP INDEX IF EXISTS product_categories_code_unique; DROP INDEX IF EXISTS product_categories_name_unique; DROP TABLE IF EXISTS product_categories; DROP INDEX IF EXISTS nonstocks_name_unique; +DROP TABLE IF EXISTS nonstock_suppliers; DROP TABLE IF EXISTS nonstocks; DROP INDEX IF EXISTS banks_name_unique; DROP TABLE IF EXISTS banks; diff --git a/internal/database/migrations/20250925040409_create_master_tables.up.sql b/internal/database/migrations/20250925040409_create_master_tables.up.sql index a2143806..6dcd914a 100644 --- a/internal/database/migrations/20250925040409_create_master_tables.up.sql +++ b/internal/database/migrations/20250925040409_create_master_tables.up.sql @@ -19,15 +19,17 @@ CREATE TABLE flags ( flagable_id BIGINT NOT NULL, flagable_type VARCHAR(50) NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), - deleted_at TIMESTAMPTZ + updated_at TIMESTAMPTZ DEFAULT NOW() ); +CREATE UNIQUE INDEX flags_unique_flagable ON flags (name, flagable_id, flagable_type); +CREATE INDEX flags_flagable_lookup ON flags (flagable_type, flagable_id); + -- PRODUCT CATEGORIES CREATE TABLE product_categories ( id BIGSERIAL PRIMARY KEY, name VARCHAR NOT NULL, - code VARCHAR(3) NOT NULL, + code VARCHAR(10) NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), deleted_at TIMESTAMPTZ, @@ -47,26 +49,6 @@ CREATE TABLE uoms ( ); CREATE UNIQUE INDEX uoms_name_unique ON uoms (name) WHERE deleted_at IS NULL; --- PRODUCTS -CREATE TABLE products ( - id BIGSERIAL PRIMARY KEY, - name VARCHAR NOT NULL, - brand VARCHAR NOT NULL, - sku VARCHAR(100), - uom_id BIGINT NOT NULL REFERENCES uoms(id) ON DELETE RESTRICT ON UPDATE CASCADE, - product_category_id BIGINT NOT NULL REFERENCES product_categories(id) ON DELETE RESTRICT ON UPDATE CASCADE, - product_price NUMERIC(15,3) NOT NULL, - selling_price NUMERIC(15,3), - tax NUMERIC(15,3), - expiry_period INT, - created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW(), - deleted_at TIMESTAMPTZ, - created_by BIGINT REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE -); -CREATE UNIQUE INDEX products_name_unique ON products (name) WHERE deleted_at IS NULL; -CREATE UNIQUE INDEX products_sku_unique ON products (sku) WHERE deleted_at IS NULL; - -- BANKS CREATE TABLE banks ( id BIGSERIAL PRIMARY KEY, @@ -192,6 +174,7 @@ CREATE TABLE suppliers ( alias VARCHAR(5) NOT NULL, pic VARCHAR NOT NULL, type VARCHAR(50) NOT NULL, + category VARCHAR(20) NOT NULL, hatchery VARCHAR, phone VARCHAR(20) NOT NULL, email VARCHAR NOT NULL, @@ -207,6 +190,40 @@ CREATE TABLE suppliers ( ); CREATE UNIQUE INDEX suppliers_name_unique ON suppliers (name) WHERE deleted_at IS NULL; +CREATE TABLE nonstock_suppliers ( + nonstock_id BIGINT NOT NULL REFERENCES nonstocks(id) ON DELETE CASCADE ON UPDATE CASCADE, + supplier_id BIGINT NOT NULL REFERENCES suppliers(id) ON DELETE CASCADE ON UPDATE CASCADE, + created_at TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (nonstock_id, supplier_id) +); + +-- PRODUCTS +CREATE TABLE products ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR NOT NULL, + brand VARCHAR NOT NULL, + sku VARCHAR(100), + uom_id BIGINT NOT NULL REFERENCES uoms(id) ON DELETE RESTRICT ON UPDATE CASCADE, + product_category_id BIGINT NOT NULL REFERENCES product_categories(id) ON DELETE RESTRICT ON UPDATE CASCADE, + product_price NUMERIC(15,3) NOT NULL, + selling_price NUMERIC(15,3), + tax NUMERIC(15,3), + expiry_period INT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + created_by BIGINT REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE +); +CREATE UNIQUE INDEX products_name_unique ON products (name) WHERE deleted_at IS NULL; +CREATE UNIQUE INDEX products_sku_unique ON products (sku) WHERE deleted_at IS NULL; + +CREATE TABLE product_suppliers ( + product_id BIGINT NOT NULL REFERENCES products(id) ON DELETE CASCADE ON UPDATE CASCADE, + supplier_id BIGINT NOT NULL REFERENCES suppliers(id) ON DELETE CASCADE ON UPDATE CASCADE, + created_at TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (product_id, supplier_id) +); + -- PROJECTS CREATE TABLE projects ( id BIGSERIAL PRIMARY KEY, diff --git a/internal/database/seed/seeder.go b/internal/database/seed/seeder.go index c80fff7a..57c3687b 100644 --- a/internal/database/seed/seeder.go +++ b/internal/database/seed/seeder.go @@ -1,26 +1,777 @@ package seed import ( + "errors" "fmt" + "strings" + "time" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/utils" "gorm.io/gorm" ) func Run(db *gorm.DB) error { return db.Transaction(func(tx *gorm.DB) error { - // ===== Users (user) ===== - user := entity.User{ - Email: "admin@mbugroup.id", - IdUser: 1, - Name: "Super Admin", + users, err := seedUsers(tx) + if err != nil { + return err } - if err := tx.Where("email = ?", user.Email).FirstOrCreate(&user).Error; err != nil { + adminID := users["admin"] + + uoms, err := seedUoms(tx, adminID) + if err != nil { return err } - fmt.Println("✅ Seeder successfully") + areas, err := seedAreas(tx, adminID) + if err != nil { + return err + } + + locations, err := seedLocations(tx, adminID, areas) + if err != nil { + return err + } + + kandangs, err := seedKandangs(tx, adminID, locations, users) + if err != nil { + return err + } + + if err := seedWarehouses(tx, adminID, areas, locations, kandangs); err != nil { + return err + } + + productCategories, err := seedProductCategories(tx, adminID) + if err != nil { + return err + } + + suppliers, err := seedSuppliers(tx, adminID) + if err != nil { + return err + } + + if err := seedCustomers(tx, adminID, users); err != nil { + return err + } + + if err := seedFcr(tx, adminID); err != nil { + return err + } + + if err := seedProducts(tx, adminID, uoms, productCategories, suppliers); err != nil { + return err + } + + if err := seedNonstocks(tx, adminID, uoms, suppliers); err != nil { + return err + } + + if err := seedBanks(tx, adminID); err != nil { + return err + } + + fmt.Println("✅ Master data seeding completed") return nil }) } + +func seedUsers(tx *gorm.DB) (map[string]uint, error) { + seeds := []struct { + Key string + Data entity.User + }{ + { + Key: "admin", + Data: entity.User{Email: "admin@mbugroup.id", IdUser: 1, Name: "Super Admin"}, + }, + } + + result := make(map[string]uint, len(seeds)) + + for _, seed := range seeds { + var user entity.User + err := tx.Where("email = ?", seed.Data.Email).First(&user).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + user = seed.Data + if err := tx.Create(&user).Error; err != nil { + return nil, err + } + } else if err != nil { + return nil, err + } + result[seed.Key] = user.Id + } + + return result, nil +} + +func seedUoms(tx *gorm.DB, createdBy uint) (map[string]uint, error) { + names := []string{"Kilogram", "Gram", "Liter", "Unit", "Ekor"} + result := make(map[string]uint, len(names)) + + for _, name := range names { + var uom entity.Uom + err := tx.Where("name = ?", name).First(&uom).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + uom = entity.Uom{Name: name, CreatedBy: createdBy} + if err := tx.Create(&uom).Error; err != nil { + return nil, err + } + } else if err != nil { + return nil, err + } + result[name] = uom.Id + } + + return result, nil +} + +func seedAreas(tx *gorm.DB, createdBy uint) (map[string]uint, error) { + names := []string{"Priangan", "Banten"} + result := make(map[string]uint, len(names)) + + for _, name := range names { + var area entity.Area + err := tx.Where("name = ?", name).First(&area).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + area = entity.Area{Name: name, CreatedBy: createdBy} + if err := tx.Create(&area).Error; err != nil { + return nil, err + } + } else if err != nil { + return nil, err + } + result[name] = area.Id + } + + return result, nil +} + +func seedLocations(tx *gorm.DB, createdBy uint, areas map[string]uint) (map[string]uint, error) { + seeds := []struct { + Name string + Address string + Area string + }{ + {"Singaparna", "Tasik", "Priangan"}, + {"Cikaum", "Cikaum", "Banten"}, + } + + result := make(map[string]uint, len(seeds)) + + for _, seed := range seeds { + areaID, ok := areas[seed.Area] + if !ok { + return nil, fmt.Errorf("area %s not seeded", seed.Area) + } + + var loc entity.Location + err := tx.Where("name = ?", seed.Name).First(&loc).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + loc = entity.Location{ + Name: seed.Name, + Address: seed.Address, + AreaId: areaID, + CreatedBy: createdBy, + } + if err := tx.Create(&loc).Error; err != nil { + return nil, err + } + } else if err != nil { + return nil, err + } + result[seed.Name] = loc.Id + } + + return result, nil +} + +func seedKandangs(tx *gorm.DB, createdBy uint, locations map[string]uint, users map[string]uint) (map[string]uint, error) { + seeds := []struct { + Name string + Location string + PicKey string + }{ + {"Singaparna 1", "Singaparna", "admin"}, + {"Singaparna 2", "Singaparna", "admin"}, + {"Cikaum 1", "Cikaum", "admin"}, + {"Cikaum 2", "Cikaum", "admin"}, + } + + result := make(map[string]uint, len(seeds)) + + for _, seed := range seeds { + locID, ok := locations[seed.Location] + if !ok { + return nil, fmt.Errorf("location %s not seeded", seed.Location) + } + picID, ok := users[seed.PicKey] + if !ok { + return nil, fmt.Errorf("user %s not seeded", seed.PicKey) + } + + var kandang entity.Kandang + err := tx.Where("name = ?", seed.Name).First(&kandang).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + kandang = entity.Kandang{ + Name: seed.Name, + LocationId: locID, + PicId: picID, + CreatedBy: createdBy, + } + if err := tx.Create(&kandang).Error; err != nil { + return nil, err + } + } else if err != nil { + return nil, err + } + result[seed.Name] = kandang.Id + } + + return result, nil +} + +func seedWarehouses(tx *gorm.DB, createdBy uint, areas map[string]uint, locations map[string]uint, kandangs map[string]uint) error { + seeds := []struct { + Name string + Type string + Area string + Location *string + Kandang *string + }{ + {Name: "Gudang Priangan", Type: string(utils.WarehouseTypeArea), Area: "Priangan"}, + {Name: "Gudang Singaparna", Type: string(utils.WarehouseTypeLokasi), Area: "Priangan", Location: strPtr("Singaparna")}, + {Name: "Gudang Singaparna 1", Type: string(utils.WarehouseTypeKandang), Area: "Priangan", Location: strPtr("Singaparna"), Kandang: strPtr("Singaparna 1")}, + {Name: "Gudang Singaparna 2", Type: string(utils.WarehouseTypeKandang), Area: "Priangan", Location: strPtr("Singaparna"), Kandang: strPtr("Singaparna 2")}, + {Name: "Gudang Banten", Type: string(utils.WarehouseTypeArea), Area: "Banten"}, + {Name: "Gudang Cikaum", Type: string(utils.WarehouseTypeLokasi), Area: "Banten", Location: strPtr("Cikaum")}, + {Name: "Gudang Cikaum 1", Type: string(utils.WarehouseTypeKandang), Area: "Banten", Location: strPtr("Cikaum"), Kandang: strPtr("Cikaum 1")}, + {Name: "Gudang Cikaum 2", Type: string(utils.WarehouseTypeKandang), Area: "Banten", Location: strPtr("Cikaum"), Kandang: strPtr("Cikaum 2")}, + } + + for _, seed := range seeds { + areaID, ok := areas[seed.Area] + if !ok { + return fmt.Errorf("area %s not seeded", seed.Area) + } + + var warehouse entity.Warehouse + err := tx.Where("name = ?", seed.Name).First(&warehouse).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + warehouse = entity.Warehouse{ + Name: seed.Name, + Type: seed.Type, + AreaId: areaID, + CreatedBy: createdBy, + } + } else if err != nil { + return err + } + + if seed.Location != nil { + locID, ok := locations[*seed.Location] + if !ok { + return fmt.Errorf("location %s not seeded", *seed.Location) + } + warehouse.LocationId = uintPtr(locID) + } + if seed.Kandang != nil { + kandangID, ok := kandangs[*seed.Kandang] + if !ok { + return fmt.Errorf("kandang %s not seeded", *seed.Kandang) + } + warehouse.KandangId = uintPtr(kandangID) + } + + if warehouse.Id == 0 { + if err := tx.Create(&warehouse).Error; err != nil { + return err + } + } else { + if err := tx.Model(&entity.Warehouse{}).Where("id = ?", warehouse.Id).Updates(map[string]any{ + "type": warehouse.Type, + "area_id": warehouse.AreaId, + "location_id": warehouse.LocationId, + "kandang_id": warehouse.KandangId, + }).Error; err != nil { + return err + } + } + } + + return nil +} + +func seedProductCategories(tx *gorm.DB, createdBy uint) (map[string]uint, error) { + seeds := []struct { + Name string + Code string + }{ + {"Bahan Baku", "RAW"}, + {"Day Old Chick", "DOC"}, + } + + result := make(map[string]uint, len(seeds)) + + for _, seed := range seeds { + var category entity.ProductCategory + err := tx.Where("name = ?", seed.Name).First(&category).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + category = entity.ProductCategory{Name: seed.Name, Code: seed.Code, CreatedBy: createdBy} + if err := tx.Create(&category).Error; err != nil { + return nil, err + } + } else if err != nil { + return nil, err + } else { + if err := tx.Model(&entity.ProductCategory{}).Where("id = ?", category.Id).Updates(map[string]any{ + "code": seed.Code, + }).Error; err != nil { + return nil, err + } + } + result[seed.Name] = category.Id + } + + return result, nil +} + +func seedSuppliers(tx *gorm.DB, createdBy uint) (map[string]uint, error) { + seeds := []struct { + Name string + Alias string + Category string + Email string + Phone string + Address string + }{ + {"PT CHAROEN POKPHAND INDONESIA Tbk", "CPI", string(utils.SupplierCategorySapronak), "cpi@gmail.com", "081200000001", "Jl. Pakan 1, Bekasi"}, + {"BOP Vendor", "BOP", string(utils.SupplierCategoryBOP), "bop@gmail.com", "081200000002", "Jl. Veteriner 3, Bogor"}, + {"Ekspedisi", "EKS", string(utils.SupplierCategoryBOP), "bop@gmail.com", "081200000002", "Jl. Veteriner 3, Bogor"}, + } + + result := make(map[string]uint, len(seeds)) + + for idx, seed := range seeds { + var supplier entity.Supplier + err := tx.Where("name = ?", seed.Name).First(&supplier).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + supplier = entity.Supplier{ + Name: seed.Name, + Alias: seed.Alias, + Pic: "John Doe", + Type: string(utils.CustomerSupplierTypeBisnis), + Category: seed.Category, + Phone: seed.Phone, + Email: seed.Email, + Address: seed.Address, + DueDate: 30, + CreatedBy: createdBy, + AccountNumber: strPtr(fmt.Sprintf("%03d", idx+1)), + } + if err := tx.Create(&supplier).Error; err != nil { + return nil, err + } + } else if err != nil { + return nil, err + } + result[seed.Name] = supplier.Id + } + + return result, nil +} + +func seedCustomers(tx *gorm.DB, createdBy uint, users map[string]uint) error { + seeds := []struct { + Name string + PicKey string + Address string + Phone string + Email string + }{ + {"Abdul Azis", "admin", "Jl. Raya Utama 1, Bekasi", "082100000001", "abdul.azis@gmail.com"}, + } + + for idx, seed := range seeds { + picID, ok := users[seed.PicKey] + if !ok { + return fmt.Errorf("user %s not seeded", seed.PicKey) + } + + var customer entity.Customer + err := tx.Where("name = ?", seed.Name).First(&customer).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + customer = entity.Customer{ + Name: seed.Name, + PicId: picID, + Type: string(utils.CustomerSupplierTypeBisnis), + Address: seed.Address, + Phone: seed.Phone, + Email: seed.Email, + AccountNumber: *strPtr(fmt.Sprintf("%03d", idx+1)), + CreatedBy: createdBy, + } + if err := tx.Create(&customer).Error; err != nil { + return err + } + } else if err != nil { + return err + } + } + + return nil +} + +func seedFcr(tx *gorm.DB, createdBy uint) error { + seeds := []struct { + Name string + Standards []struct { + Weight float64 + FcrNumber float64 + Mortality float64 + } + }{ + { + Name: "FCR Layer", + Standards: []struct { + Weight float64 + FcrNumber float64 + Mortality float64 + }{ + {Weight: 0.8, FcrNumber: 1.60, Mortality: 2.0}, + {Weight: 1.5, FcrNumber: 1.75, Mortality: 3.5}, + }, + }, + } + + for _, seed := range seeds { + var fcr entity.Fcr + err := tx.Where("name = ?", seed.Name).First(&fcr).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + fcr = entity.Fcr{Name: seed.Name, CreatedBy: createdBy} + if err := tx.Create(&fcr).Error; err != nil { + return err + } + } else if err != nil { + return err + } + + for _, std := range seed.Standards { + var standard entity.FcrStandard + err := tx.Where("fcr_id = ? AND weight = ?", fcr.Id, std.Weight).First(&standard).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + standard = entity.FcrStandard{ + FcrID: fcr.Id, + Weight: std.Weight, + FcrNumber: std.FcrNumber, + Mortality: std.Mortality, + } + if err := tx.Create(&standard).Error; err != nil { + return err + } + } else if err != nil { + return err + } else { + if err := tx.Model(&entity.FcrStandard{}).Where("id = ?", standard.Id).Updates(map[string]any{ + "fcr_number": std.FcrNumber, + "mortality": std.Mortality, + }).Error; err != nil { + return err + } + } + } + } + + return nil +} + +func seedProducts(tx *gorm.DB, createdBy uint, uoms map[string]uint, categories map[string]uint, suppliers map[string]uint) error { + seeds := []struct { + Name string + Brand string + Sku string + Uom string + Category string + Price float64 + Selling *float64 + Tax *float64 + Expiry *int + Suppliers []string + Flags []utils.FlagType + }{ + { + Name: "DOC Broiler", + Brand: "MBU Broiler", + Sku: "BRO0001", + Uom: "Ekor", + Category: "Day Old Chick", + Price: 7500, + Suppliers: []string{"PT CHAROEN POKPHAND INDONESIA Tbk"}, + Flags: []utils.FlagType{utils.FlagDOC}, + }, + { + Name: "281 SPECIAL STARTER", + Brand: "281 STARTER", + Sku: "281", + Uom: "Kilogram", + Category: "Bahan Baku", + Price: 7850, + Expiry: intPtr(60), + Suppliers: []string{"PT CHAROEN POKPHAND INDONESIA Tbk"}, + Flags: []utils.FlagType{utils.FlagPakan, utils.FlagStarter}, + }, + } + + for _, seed := range seeds { + uomID, ok := uoms[seed.Uom] + if !ok { + return fmt.Errorf("uom %s not seeded", seed.Uom) + } + categoryID, ok := categories[seed.Category] + if !ok { + return fmt.Errorf("product category %s not seeded", seed.Category) + } + + var product entity.Product + err := tx.Where("name = ?", seed.Name).First(&product).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + selling := seed.Selling + tax := seed.Tax + product = entity.Product{ + Name: seed.Name, + Brand: seed.Brand, + Sku: &seed.Sku, + UomId: uomID, + ProductCategoryId: categoryID, + ProductPrice: seed.Price, + SellingPrice: selling, + Tax: tax, + ExpiryPeriod: seed.Expiry, + CreatedBy: createdBy, + } + if err := tx.Create(&product).Error; err != nil { + return err + } + } else if err != nil { + return err + } else { + updates := map[string]any{ + "brand": seed.Brand, + "uom_id": uomID, + "product_category_id": categoryID, + "product_price": seed.Price, + "selling_price": seed.Selling, + "tax": seed.Tax, + "expiry_period": seed.Expiry, + } + if seed.Sku != "" { + updates["sku"] = seed.Sku + } + if err := tx.Model(&entity.Product{}).Where("id = ?", product.Id).Updates(updates).Error; err != nil { + return err + } + } + + for _, supplierName := range seed.Suppliers { + supplierID, ok := suppliers[supplierName] + if !ok { + return fmt.Errorf("supplier %s not seeded", supplierName) + } + var existing entity.ProductSupplier + err := tx.Where("product_id = ? AND supplier_id = ?", product.Id, supplierID).First(&existing).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + link := entity.ProductSupplier{ProductID: product.Id, SupplierID: supplierID} + if err := tx.Create(&link).Error; err != nil { + return err + } + } else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + } + + if err := seedFlags(tx, product.Id, entity.FlagableTypeProduct, seed.Flags); err != nil { + return err + } + } + + return nil +} + +func seedNonstocks(tx *gorm.DB, createdBy uint, uoms map[string]uint, suppliers map[string]uint) error { + seeds := []struct { + Name string + Uom string + Suppliers []string + Flags []utils.FlagType + }{ + { + Name: "Expedisi DOC", + Uom: "Ekor", + Suppliers: []string{"Ekspedisi"}, + Flags: []utils.FlagType{utils.FlagEkspedisi}, + }, + { + Name: "Solar", + Uom: "Liter", + Suppliers: []string{"BOP Vendor"}, + Flags: []utils.FlagType{}, + }, + } + + for _, seed := range seeds { + uomID, ok := uoms[seed.Uom] + if !ok { + return fmt.Errorf("uom %s not seeded", seed.Uom) + } + + var nonstock entity.Nonstock + err := tx.Where("name = ?", seed.Name).First(&nonstock).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + nonstock = entity.Nonstock{ + Name: seed.Name, + UomId: uomID, + CreatedBy: createdBy, + } + if err := tx.Create(&nonstock).Error; err != nil { + return err + } + } else if err != nil { + return err + } else { + if err := tx.Model(&entity.Nonstock{}).Where("id = ?", nonstock.Id).Updates(map[string]any{ + "uom_id": uomID, + }).Error; err != nil { + return err + } + } + + for _, supplierName := range seed.Suppliers { + supplierID, ok := suppliers[supplierName] + if !ok { + return fmt.Errorf("supplier %s not seeded", supplierName) + } + var existing entity.NonstockSupplier + err := tx.Where("nonstock_id = ? AND supplier_id = ?", nonstock.Id, supplierID).First(&existing).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + link := entity.NonstockSupplier{NonstockID: nonstock.Id, SupplierID: supplierID} + if err := tx.Create(&link).Error; err != nil { + return err + } + } else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + } + + if err := seedFlags(tx, nonstock.Id, entity.FlagableTypeNonstock, seed.Flags); err != nil { + return err + } + } + + return nil +} + +func seedFlags(tx *gorm.DB, flagableID uint, flagableType string, flags []utils.FlagType) error { + if len(flags) == 0 { + return nil + } + for _, flag := range flags { + name := strings.ToUpper(string(flag)) + var existing entity.Flag + err := tx.Where("name = ? AND flagable_id = ? AND flagable_type = ?", name, flagableID, flagableType).First(&existing).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + record := entity.Flag{ + Name: name, + FlagableID: flagableID, + FlagableType: flagableType, + } + if err := tx.Create(&record).Error; err != nil { + return err + } + } else if err != nil { + return err + } + } + return nil +} + +func seedBanks(tx *gorm.DB, createdBy uint) error { + seeds := []struct { + Name string + Alias string + Owner *string + AccountNumber string + }{ + { + Name: "Bank Central Asia", + Alias: "BCA", + AccountNumber: "1234567890", + Owner: ptr("PT MBU Group"), + }, + { + Name: "Bank Rakyat Indonesia", + Alias: "BRI", + AccountNumber: "9876543210", + Owner: ptr("PT MBU Group"), + }, + { + Name: "Bank Mandiri", + Alias: "MAND", + AccountNumber: "1122334455", + Owner: ptr("PT MBU Group"), + }, + } + + for _, seed := range seeds { + var bank entity.Bank + err := tx.Where("name = ?", seed.Name).First(&bank).Error + + if errors.Is(err, gorm.ErrRecordNotFound) { + bank = entity.Bank{ + Name: seed.Name, + Alias: seed.Alias, + Owner: seed.Owner, + AccountNumber: seed.AccountNumber, + CreatedBy: createdBy, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + if err := tx.Create(&bank).Error; err != nil { + return err + } + } else if err != nil { + return err + } else { + // update data jika sudah ada + if err := tx.Model(&entity.Bank{}).Where("id = ?", bank.Id).Updates(map[string]any{ + "alias": seed.Alias, + "owner": seed.Owner, + "account_number": seed.AccountNumber, + "updated_at": time.Now(), + }).Error; err != nil { + return err + } + } + } + + return nil +} + +func ptr[T any](v T) *T { + return &v +} + +func strPtr(s string) *string { + return &s +} + +func intPtr(v int) *int { + return &v +} + +func uintPtr(v uint) *uint { + return &v +} diff --git a/internal/entities/area.go b/internal/entities/area.go index dc1a2af7..0af4d1f0 100644 --- a/internal/entities/area.go +++ b/internal/entities/area.go @@ -8,7 +8,7 @@ import ( type Area struct { Id uint `gorm:"primaryKey"` - Name string `gorm:"not null"` + Name string `gorm:"not null;uniqueIndex:areas_name_unique,where:deleted_at IS NULL"` CreatedBy uint `gorm:"not null"` CreatedAt time.Time `gorm:"autoCreateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"` diff --git a/internal/entities/bank.go b/internal/entities/bank.go new file mode 100644 index 00000000..3c2a93da --- /dev/null +++ b/internal/entities/bank.go @@ -0,0 +1,21 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type Bank struct { + Id uint `gorm:"primaryKey"` + Name string `gorm:"not null;uniqueIndex:banks_name_unique,where:deleted_at IS NULL"` + Alias string `gorm:"not null;size:5"` + Owner *string `gorm:""` + AccountNumber string `gorm:"not null;size:50"` + CreatedBy uint `gorm:"not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` +} diff --git a/internal/entities/constant.go b/internal/entities/constant.go new file mode 100644 index 00000000..4f1fa56f --- /dev/null +++ b/internal/entities/constant.go @@ -0,0 +1,18 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type Constant struct { + Id uint `gorm:"primaryKey"` + Name string `gorm:"not null;uniqueIndex:idx_suppliers_name,where:deleted_at IS NULL"` + CreatedBy uint `gorm:"not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` +} diff --git a/internal/entities/customer.go b/internal/entities/customer.go index 66afeb93..98d0c861 100644 --- a/internal/entities/customer.go +++ b/internal/entities/customer.go @@ -8,13 +8,13 @@ import ( type Customer struct { Id uint `gorm:"primaryKey"` - Name string `gorm:"not null"` + Name string `gorm:"not null;uniqueIndex:customers_name_unique,where:deleted_at IS NULL"` PicId uint `gorm:"not null"` - Type string `gorm:"not null"` + Type string `gorm:"not null;size:50"` Address string `gorm:"not null"` - Phone string `gorm:"not null"` + Phone string `gorm:"not null;size:20"` Email string `gorm:"not null"` - AccountNumber string `gorm:"not null"` + AccountNumber string `gorm:"not null;size:50"` Balance float64 `gorm:"default:0"` CreatedBy uint `gorm:"not null"` CreatedAt time.Time `gorm:"autoCreateTime"` diff --git a/internal/entities/fcr.go b/internal/entities/fcr.go new file mode 100644 index 00000000..4bf96eaf --- /dev/null +++ b/internal/entities/fcr.go @@ -0,0 +1,19 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type Fcr struct { + Id uint `gorm:"primaryKey"` + Name string `gorm:"not null;uniqueIndex:idx_suppliers_name,where:deleted_at IS NULL"` + CreatedBy uint `gorm:"not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` + Standards []FcrStandard `gorm:"foreignKey:FcrID;references:Id"` +} diff --git a/internal/entities/fcr_standard.go b/internal/entities/fcr_standard.go new file mode 100644 index 00000000..38d2e62c --- /dev/null +++ b/internal/entities/fcr_standard.go @@ -0,0 +1,20 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type FcrStandard struct { + Id uint `gorm:"primaryKey"` + FcrID uint `gorm:"not null;index"` + Weight float64 `gorm:"type:numeric(15,3);not null"` + FcrNumber float64 `gorm:"type:numeric(15,3);not null"` + Mortality float64 `gorm:"type:numeric(15,3);not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + Fcr Fcr `gorm:"foreignKey:FcrID;references:Id"` +} diff --git a/internal/entities/flag.go b/internal/entities/flag.go new file mode 100644 index 00000000..aba2c8d5 --- /dev/null +++ b/internal/entities/flag.go @@ -0,0 +1,17 @@ +package entities + +import "time" + +const ( + FlagableTypeProduct = "products" + FlagableTypeNonstock = "nonstocks" +) + +type Flag struct { + Id uint `gorm:"primaryKey"` + Name string `gorm:"not null;uniqueIndex:flags_unique_flagable"` + FlagableID uint `gorm:"not null;uniqueIndex:flags_unique_flagable;index:flags_flagable_lookup,priority:2"` + FlagableType string `gorm:"size:50;not null;uniqueIndex:flags_unique_flagable;index:flags_flagable_lookup,priority:1"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` +} diff --git a/internal/entities/kandang.go b/internal/entities/kandang.go index 9c65aaa1..862f40fc 100644 --- a/internal/entities/kandang.go +++ b/internal/entities/kandang.go @@ -8,7 +8,7 @@ import ( type Kandang struct { Id uint `gorm:"primaryKey"` - Name string `gorm:"not null"` + Name string `gorm:"not null;uniqueIndex:kandangs_name_unique,where:deleted_at IS NULL"` LocationId uint `gorm:"not null"` PicId uint `gorm:"not null"` CreatedBy uint `gorm:"not null"` diff --git a/internal/entities/location.go b/internal/entities/location.go index 4216f2ad..9f87e97b 100644 --- a/internal/entities/location.go +++ b/internal/entities/location.go @@ -8,7 +8,7 @@ import ( type Location struct { Id uint `gorm:"primaryKey"` - Name string `gorm:"not null"` + Name string `gorm:"not null;uniqueIndex:locations_name_unique,where:deleted_at IS NULL"` Address string `gorm:"not null"` AreaId uint `gorm:"not null"` CreatedBy uint `gorm:"not null"` diff --git a/internal/entities/nonstock.go b/internal/entities/nonstock.go new file mode 100644 index 00000000..3f291cc5 --- /dev/null +++ b/internal/entities/nonstock.go @@ -0,0 +1,22 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type Nonstock struct { + Id uint `gorm:"primaryKey"` + Name string `gorm:"not null;uniqueIndex:nonstocks_name_unique,where:deleted_at IS NULL"` + UomId uint `gorm:"not null"` + CreatedBy uint `gorm:"not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` + Uom Uom `gorm:"foreignKey:UomId;references:Id"` + Suppliers []Supplier `gorm:"many2many:nonstock_suppliers;joinForeignKey:NonstockID;joinReferences:SupplierID"` + Flags []Flag `gorm:"polymorphic:Flagable;polymorphicValue:nonstocks"` +} diff --git a/internal/entities/nonstock_supplier.go b/internal/entities/nonstock_supplier.go new file mode 100644 index 00000000..2d56a680 --- /dev/null +++ b/internal/entities/nonstock_supplier.go @@ -0,0 +1,9 @@ +package entities + +import "time" + +type NonstockSupplier struct { + NonstockID uint `gorm:"primaryKey"` + SupplierID uint `gorm:"primaryKey"` + CreatedAt time.Time `gorm:"autoCreateTime"` +} diff --git a/internal/entities/product-category.go b/internal/entities/product-category.go new file mode 100644 index 00000000..45acc299 --- /dev/null +++ b/internal/entities/product-category.go @@ -0,0 +1,19 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type ProductCategory struct { + Id uint `gorm:"primaryKey"` + Name string `gorm:"not null;uniqueIndex:product_categories_name_unique,where:deleted_at IS NULL"` + Code string `gorm:"not null;size:10;uniqueIndex:product_categories_code_unique,where:deleted_at IS NULL"` + CreatedBy uint `gorm:"not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` +} diff --git a/internal/entities/product.go b/internal/entities/product.go new file mode 100644 index 00000000..120a018c --- /dev/null +++ b/internal/entities/product.go @@ -0,0 +1,30 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type Product struct { + Id uint `gorm:"primaryKey"` + Name string `gorm:"not null;uniqueIndex:products_name_unique,where:deleted_at IS NULL"` + Brand string `gorm:"not null"` + Sku *string `gorm:"size:100;uniqueIndex:products_sku_unique,where:deleted_at IS NULL"` + UomId uint `gorm:"not null"` + ProductCategoryId uint `gorm:"not null"` + ProductPrice float64 `gorm:"type:numeric(15,3);not null"` + SellingPrice *float64 `gorm:"type:numeric(15,3)"` + Tax *float64 `gorm:"type:numeric(15,3)"` + ExpiryPeriod *int `gorm:""` + CreatedBy uint `gorm:"not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` + Uom Uom `gorm:"foreignKey:UomId;references:Id"` + ProductCategory ProductCategory `gorm:"foreignKey:ProductCategoryId;references:Id"` + Suppliers []Supplier `gorm:"many2many:product_suppliers;joinForeignKey:ProductID;joinReferences:SupplierID"` + Flags []Flag `gorm:"polymorphic:Flagable;polymorphicValue:products"` +} diff --git a/internal/entities/product_supplier.go b/internal/entities/product_supplier.go new file mode 100644 index 00000000..9aa2deec --- /dev/null +++ b/internal/entities/product_supplier.go @@ -0,0 +1,9 @@ +package entities + +import "time" + +type ProductSupplier struct { + ProductID uint `gorm:"primaryKey"` + SupplierID uint `gorm:"primaryKey"` + CreatedAt time.Time `gorm:"autoCreateTime"` +} diff --git a/internal/entities/supplier.go b/internal/entities/supplier.go new file mode 100644 index 00000000..141c4a58 --- /dev/null +++ b/internal/entities/supplier.go @@ -0,0 +1,30 @@ +package entities + +import ( + "time" + + "gorm.io/gorm" +) + +type Supplier struct { + Id uint `gorm:"primaryKey"` + Name string `gorm:"not null;uniqueIndex:suppliers_name_unique,where:deleted_at IS NULL"` + Alias string `gorm:"not null;size:5"` + Pic string `gorm:"not null"` + Type string `gorm:"not null;size:50"` + Category string `gorm:"not null;size:20"` + Hatchery *string `gorm:"size:255"` + Phone string `gorm:"not null;size:20"` + Email string `gorm:"not null"` + Address string `gorm:"not null"` + Npwp *string `gorm:"size:50"` + AccountNumber *string `gorm:"size:50"` + Balance float64 `gorm:"type:numeric(15,3);default:0"` + DueDate int `gorm:"not null"` + CreatedBy uint `gorm:"not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` +} diff --git a/internal/entities/uom.go b/internal/entities/uom.go index 1db4458c..a3335428 100644 --- a/internal/entities/uom.go +++ b/internal/entities/uom.go @@ -8,7 +8,7 @@ import ( type Uom struct { Id uint `gorm:"primaryKey"` - Name string `gorm:"not null"` + Name string `gorm:"not null;uniqueIndex:uoms_name_unique,where:deleted_at IS NULL"` CreatedBy uint `gorm:"not null"` CreatedAt time.Time `gorm:"autoCreateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"` diff --git a/internal/modules/constants/controllers/constant.controller.go b/internal/modules/constants/controllers/constant.controller.go new file mode 100644 index 00000000..68fc951c --- /dev/null +++ b/internal/modules/constants/controllers/constant.controller.go @@ -0,0 +1,25 @@ +package controller + +import ( + service "gitlab.com/mbugroup/lti-api.git/internal/modules/constants/services" + + "github.com/gofiber/fiber/v2" +) + +type ConstantController struct { + ConstantService service.ConstantService +} + +func NewConstantController(constantService service.ConstantService) *ConstantController { + return &ConstantController{ + ConstantService: constantService, + } +} + +func (ctrl *ConstantController) GetAll(c *fiber.Ctx) error { + data, err := ctrl.ConstantService.GetAll(c) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + return c.Status(fiber.StatusOK).JSON(data) +} diff --git a/internal/modules/constants/module.go b/internal/modules/constants/module.go new file mode 100644 index 00000000..d59cbedf --- /dev/null +++ b/internal/modules/constants/module.go @@ -0,0 +1,20 @@ +package constants + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + rConstant "gitlab.com/mbugroup/lti-api.git/internal/modules/constants/repositories" + sConstant "gitlab.com/mbugroup/lti-api.git/internal/modules/constants/services" +) + +type ConstantModule struct{} + +func (ConstantModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + constantRepo := rConstant.NewConstantRepository(db) + + constantService := sConstant.NewConstantService(constantRepo, validate) + + ConstantRoutes(router, constantService) +} diff --git a/internal/modules/constants/repositories/constant.repository.go b/internal/modules/constants/repositories/constant.repository.go new file mode 100644 index 00000000..7b85ce20 --- /dev/null +++ b/internal/modules/constants/repositories/constant.repository.go @@ -0,0 +1,46 @@ +package repository + +import ( + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + utils "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gorm.io/gorm" +) + +type ConstantRepository interface { + GetConstants() map[string]interface{} +} + +type ConstantRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.Constant] +} + +func NewConstantRepository(db *gorm.DB) ConstantRepository { + return &ConstantRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.Constant](db), + } +} + +func (r *ConstantRepositoryImpl) GetConstants() map[string]interface{} { + flagList := make([]string, 0) + for f := range utils.AllFlagTypes() { + flagList = append(flagList, string(f)) + } + + return map[string]interface{}{ + "flags": flagList, + "warehouse_types": []string{ + "AREA", + "LOKASI", + "KANDANG", + }, + "supplier_categories": []string{ + "BOP", + "SAPRONAK", + }, + "customer_supplier_types": []string{ + "BISNIS", + "INDIVIDUAL", + }, + } +} diff --git a/internal/modules/constants/route.go b/internal/modules/constants/route.go new file mode 100644 index 00000000..1da14371 --- /dev/null +++ b/internal/modules/constants/route.go @@ -0,0 +1,17 @@ +package constants + +import ( + // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/constants/controllers" + constant "gitlab.com/mbugroup/lti-api.git/internal/modules/constants/services" + + "github.com/gofiber/fiber/v2" +) + +func ConstantRoutes(v1 fiber.Router, s constant.ConstantService) { + ctrl := controller.NewConstantController(s) + + route := v1.Group("/constants") + + route.Get("/", ctrl.GetAll) +} diff --git a/internal/modules/constants/services/constant.service.go b/internal/modules/constants/services/constant.service.go new file mode 100644 index 00000000..e32fc7d4 --- /dev/null +++ b/internal/modules/constants/services/constant.service.go @@ -0,0 +1,26 @@ +package service + +import ( + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/constants/repositories" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" +) + +type ConstantService interface { + GetAll(ctx *fiber.Ctx) (map[string]interface{}, error) +} + +type constantService struct { + Repository repository.ConstantRepository +} + +func NewConstantService(repo repository.ConstantRepository, validate *validator.Validate) ConstantService { + return &constantService{ + Repository: repo, + } +} + +func (s constantService) GetAll(c *fiber.Ctx) (map[string]interface{}, error) { + return s.Repository.GetConstants(), nil +} diff --git a/internal/modules/master/areas/dto/area.dto.go b/internal/modules/master/areas/dto/area.dto.go index d203d570..eceff7a9 100644 --- a/internal/modules/master/areas/dto/area.dto.go +++ b/internal/modules/master/areas/dto/area.dto.go @@ -56,3 +56,9 @@ func ToAreaListDTOs(e []entity.Area) []AreaListDTO { } return result } + +func ToAreaDetailDTO(e entity.Area) AreaDetailDTO { + return AreaDetailDTO{ + AreaListDTO: ToAreaListDTO(e), + } +} diff --git a/internal/modules/master/areas/services/area.service.go b/internal/modules/master/areas/services/area.service.go index a81fe4ff..1925a592 100644 --- a/internal/modules/master/areas/services/area.service.go +++ b/internal/modules/master/areas/services/area.service.go @@ -98,7 +98,7 @@ func (s *areaService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.A return nil, err } - return s.Repository.GetByID(c.Context(), createBody.Id, s.withRelations) + return s.GetOne(c, createBody.Id) } func (s areaService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Area, error) { diff --git a/internal/modules/master/areas/validations/area.validation.go b/internal/modules/master/areas/validations/area.validation.go index e423aa8c..56bbd601 100644 --- a/internal/modules/master/areas/validations/area.validation.go +++ b/internal/modules/master/areas/validations/area.validation.go @@ -1,11 +1,11 @@ package validation type Create struct { - Name string `json:"name" validate:"required_strict,min=3"` + Name string `json:"name" validate:"required_strict,min=3"` } type Update struct { - Name *string `json:"name,omitempty" validate:"omitempty,max=50"` + Name *string `json:"name,omitempty" validate:"omitempty"` } type Query struct { diff --git a/internal/modules/master/banks/controllers/bank.controller.go b/internal/modules/master/banks/controllers/bank.controller.go new file mode 100644 index 00000000..7625d078 --- /dev/null +++ b/internal/modules/master/banks/controllers/bank.controller.go @@ -0,0 +1,140 @@ +package controller + +import ( + "math" + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/master/banks/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/master/banks/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/banks/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type BankController struct { + BankService service.BankService +} + +func NewBankController(bankService service.BankService) *BankController { + return &BankController{ + BankService: bankService, + } +} + +func (u *BankController) 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.BankService.GetAll(c, query) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.BankListDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get all banks successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: dto.ToBankListDTOs(result), + }) +} + +func (u *BankController) 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.BankService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get bank successfully", + Data: dto.ToBankListDTO(*result), + }) +} + +func (u *BankController) 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.BankService.CreateOne(c, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Create bank successfully", + Data: dto.ToBankListDTO(*result), + }) +} + +func (u *BankController) 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.BankService.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 bank successfully", + Data: dto.ToBankListDTO(*result), + }) +} + +func (u *BankController) 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.BankService.DeleteOne(c, uint(id)); err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Common{ + Code: fiber.StatusOK, + Status: "success", + Message: "Delete bank successfully", + }) +} diff --git a/internal/modules/master/banks/dto/bank.dto.go b/internal/modules/master/banks/dto/bank.dto.go new file mode 100644 index 00000000..415c5f6b --- /dev/null +++ b/internal/modules/master/banks/dto/bank.dto.go @@ -0,0 +1,70 @@ +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 BankBaseDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + Alias string `json:"alias"` + Owner *string `json:"owner"` + AccountNumber string `json:"account_number"` +} + +type BankListDTO struct { + BankBaseDTO + CreatedUser *userDTO.UserBaseDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type BankDetailDTO struct { + BankListDTO +} + +// === Mapper Functions === + +func ToBankBaseDTO(e entity.Bank) BankBaseDTO { + return BankBaseDTO{ + Id: e.Id, + Name: e.Name, + Alias: e.Alias, + Owner: e.Owner, + AccountNumber: e.AccountNumber, + } +} + +func ToBankListDTO(e entity.Bank) BankListDTO { + var createdUser *userDTO.UserBaseDTO + if e.CreatedUser.Id != 0 { + mapped := userDTO.ToUserBaseDTO(e.CreatedUser) + createdUser = &mapped + } + + return BankListDTO{ + BankBaseDTO: ToBankBaseDTO(e), + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + CreatedUser: createdUser, + } +} + +func ToBankListDTOs(e []entity.Bank) []BankListDTO { + result := make([]BankListDTO, len(e)) + for i, r := range e { + result[i] = ToBankListDTO(r) + } + return result +} + +func ToBankDetailDTO(e entity.Bank) BankDetailDTO { + return BankDetailDTO{ + BankListDTO: ToBankListDTO(e), + } +} diff --git a/internal/modules/master/banks/module.go b/internal/modules/master/banks/module.go new file mode 100644 index 00000000..cb2f4540 --- /dev/null +++ b/internal/modules/master/banks/module.go @@ -0,0 +1,26 @@ +package banks + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + rBank "gitlab.com/mbugroup/lti-api.git/internal/modules/master/banks/repositories" + sBank "gitlab.com/mbugroup/lti-api.git/internal/modules/master/banks/services" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type BankModule struct{} + +func (BankModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + bankRepo := rBank.NewBankRepository(db) + userRepo := rUser.NewUserRepository(db) + + bankService := sBank.NewBankService(bankRepo, validate) + userService := sUser.NewUserService(userRepo, validate) + + BankRoutes(router, userService, bankService) +} + diff --git a/internal/modules/master/banks/repositories/bank.repository.go b/internal/modules/master/banks/repositories/bank.repository.go new file mode 100644 index 00000000..53d27713 --- /dev/null +++ b/internal/modules/master/banks/repositories/bank.repository.go @@ -0,0 +1,30 @@ +package repository + +import ( + "context" + + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type BankRepository interface { + repository.BaseRepository[entity.Bank] + NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) +} + +type BankRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.Bank] + db *gorm.DB +} + +func NewBankRepository(db *gorm.DB) BankRepository { + return &BankRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.Bank](db), + db: db, + } +} + +func (r *BankRepositoryImpl) NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) { + return repository.ExistsByName[entity.Bank](ctx, r.db, name, excludeID) +} diff --git a/internal/modules/master/banks/route.go b/internal/modules/master/banks/route.go new file mode 100644 index 00000000..00b7694d --- /dev/null +++ b/internal/modules/master/banks/route.go @@ -0,0 +1,28 @@ +package banks + +import ( + // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/master/banks/controllers" + bank "gitlab.com/mbugroup/lti-api.git/internal/modules/master/banks/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func BankRoutes(v1 fiber.Router, u user.UserService, s bank.BankService) { + ctrl := controller.NewBankController(s) + + route := v1.Group("/banks") + + // 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) + route.Patch("/:id", ctrl.UpdateOne) + route.Delete("/:id", ctrl.DeleteOne) +} diff --git a/internal/modules/master/banks/services/bank.service.go b/internal/modules/master/banks/services/bank.service.go new file mode 100644 index 00000000..b62bf864 --- /dev/null +++ b/internal/modules/master/banks/services/bank.service.go @@ -0,0 +1,156 @@ +package service + +import ( + "errors" + "fmt" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/banks/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/banks/validations" + "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 BankService interface { + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.Bank, int64, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.Bank, error) + CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.Bank, error) + UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Bank, error) + DeleteOne(ctx *fiber.Ctx, id uint) error +} + +type bankService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.BankRepository +} + +func NewBankService(repo repository.BankRepository, validate *validator.Validate) BankService { + return &bankService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + } +} + +func (s bankService) withRelations(db *gorm.DB) *gorm.DB { + return db.Preload("CreatedUser") +} + +func (s bankService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Bank, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + offset := (params.Page - 1) * params.Limit + + banks, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { + db = s.withRelations(db) + if params.Search != "" { + return db.Where("name LIKE ?", "%"+params.Search+"%") + } + return db.Order("created_at DESC").Order("updated_at DESC") + }) + + if err != nil { + s.Log.Errorf("Failed to get banks: %+v", err) + return nil, 0, err + } + return banks, total, nil +} + +func (s bankService) GetOne(c *fiber.Ctx, id uint) (*entity.Bank, error) { + bank, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Bank not found") + } + if err != nil { + s.Log.Errorf("Failed get bank by id: %+v", err) + return nil, err + } + return bank, nil +} + +func (s *bankService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Bank, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + if exists, err := s.Repository.NameExists(c.Context(), req.Name, nil); err != nil { + s.Log.Errorf("Failed to check bank name: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check bank name") + } else if exists { + return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Bank with name %s already exists", req.Name)) + } + + createBody := &entity.Bank{ + Name: req.Name, + Alias: req.Alias, + Owner: req.Owner, + AccountNumber: req.AccountNumber, + CreatedBy: 1, + } + + if err := s.Repository.CreateOne(c.Context(), createBody, nil); err != nil { + s.Log.Errorf("Failed to create bank: %+v", err) + return nil, err + } + + return s.GetOne(c, createBody.Id) +} + +func (s bankService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Bank, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + updateBody := make(map[string]any) + + if req.Name != nil { + if exists, err := s.Repository.NameExists(c.Context(), *req.Name, &id); err != nil { + s.Log.Errorf("Failed to check bank name: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check bank name") + } else if exists { + return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Bank with name %s already exists", *req.Name)) + } + updateBody["name"] = *req.Name + } + if req.Alias != nil { + updateBody["alias"] = *req.Alias + } + if req.Owner != nil { + updateBody["owner"] = *req.Owner + } + if req.AccountNumber != nil { + updateBody["account_number"] = *req.AccountNumber + } + + 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, "Bank not found") + } + s.Log.Errorf("Failed to update bank: %+v", err) + return nil, err + } + + return s.GetOne(c, id) +} + +func (s bankService) 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, "Bank not found") + } + s.Log.Errorf("Failed to delete bank: %+v", err) + return err + } + return nil +} diff --git a/internal/modules/master/banks/validations/bank.validation.go b/internal/modules/master/banks/validations/bank.validation.go new file mode 100644 index 00000000..9d2bd897 --- /dev/null +++ b/internal/modules/master/banks/validations/bank.validation.go @@ -0,0 +1,21 @@ +package validation + +type Create struct { + Name string `json:"name" validate:"required_strict,min=3"` + Alias string `json:"alias" validate:"required_strict"` + Owner *string `json:"owner,omitempty" validate:"omitempty"` + AccountNumber string `json:"account_number" validate:"required_strict,max=50"` +} + +type Update struct { + Name *string `json:"name,omitempty" validate:"omitempty"` + Alias *string `json:"alias,omitempty" validate:"omitempty"` + Owner *string `json:"owner,omitempty" validate:"omitempty"` + AccountNumber *string `json:"account_number,omitempty" validate:"omitempty,max=50"` +} + +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"` +} diff --git a/internal/modules/master/customers/dto/customer.dto.go b/internal/modules/master/customers/dto/customer.dto.go index d73bbece..bd101be5 100644 --- a/internal/modules/master/customers/dto/customer.dto.go +++ b/internal/modules/master/customers/dto/customer.dto.go @@ -10,14 +10,15 @@ import ( // === DTO Structs === type CustomerBaseDTO struct { - Id uint `json:"id"` - Name string `json:"name"` - PicId uint `json:"pic_id"` - Type string `json:"type"` - Address string `json:"address"` - Phone string `json:"phone"` - Email string `json:"email"` - AccountNumber string `json:"account_number"` + Id uint `json:"id"` + Name string `json:"name"` + PicId uint `json:"pic_id"` + Type string `json:"type"` + Address string `json:"address"` + Phone string `json:"phone"` + Email string `json:"email"` + AccountNumber string `json:"account_number"` + Balance float64 `json:"balance"` Pic *userDTO.UserBaseDTO `json:"pic"` } @@ -77,3 +78,9 @@ func ToCustomerListDTOs(e []entity.Customer) []CustomerListDTO { } return result } + +func ToCustomerDetailDTO(e entity.Customer) CustomerDetailDTO { + return CustomerDetailDTO{ + CustomerListDTO: ToCustomerListDTO(e), + } +} diff --git a/internal/modules/master/customers/services/customer.service.go b/internal/modules/master/customers/services/customer.service.go index 83aff326..b2cc1e85 100644 --- a/internal/modules/master/customers/services/customer.service.go +++ b/internal/modules/master/customers/services/customer.service.go @@ -117,7 +117,7 @@ func (s *customerService) CreateOne(c *fiber.Ctx, req *validation.Create) (*enti return nil, err } - return createBody, nil + return s.GetOne(c, createBody.Id) } func (s customerService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Customer, error) { @@ -137,17 +137,18 @@ func (s customerService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint updateBody["name"] = *req.Name } + if err := common.EnsureRelations(c.Context(), common.RelationCheck{Name: "Pic", ID: req.PicId, Exists: s.Repository.PicExists}); err != nil { + return nil, err + } + if req.PicId != nil { - if err := common.EnsureRelations(c.Context(), common.RelationCheck{Name: "Pic", ID: req.PicId, Exists: s.Repository.PicExists}); err != nil { - return nil, err - } updateBody["pic_id"] = *req.PicId } if req.Type != nil { typ := strings.ToUpper(*req.Type) if !utils.IsValidCustomerSupplierType(typ) { - return nil, fiber.NewError(fiber.StatusBadRequest, "invalid customer type") + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid customer type") } updateBody["type"] = typ } diff --git a/internal/modules/master/customers/validations/customer.validation.go b/internal/modules/master/customers/validations/customer.validation.go index 43044d49..a7a666ec 100644 --- a/internal/modules/master/customers/validations/customer.validation.go +++ b/internal/modules/master/customers/validations/customer.validation.go @@ -11,7 +11,7 @@ type Create struct { } type Update struct { - Name *string `json:"name,omitempty" validate:"omitempty,max=50"` + Name *string `json:"name,omitempty" validate:"omitempty"` PicId *uint `json:"pic_id,omitempty" validate:"omitempty,number,gt=0"` Type *string `json:"type,omitempty" validate:"omitempty"` Address *string `json:"address,omitempty" validate:"omitempty"` diff --git a/internal/modules/master/fcrs/controllers/fcr.controller.go b/internal/modules/master/fcrs/controllers/fcr.controller.go new file mode 100644 index 00000000..33353ffa --- /dev/null +++ b/internal/modules/master/fcrs/controllers/fcr.controller.go @@ -0,0 +1,140 @@ +package controller + +import ( + "math" + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/master/fcrs/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/master/fcrs/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/fcrs/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type FcrController struct { + FcrService service.FcrService +} + +func NewFcrController(fcrService service.FcrService) *FcrController { + return &FcrController{ + FcrService: fcrService, + } +} + +func (u *FcrController) 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.FcrService.GetAll(c, query) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.FcrListDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get all fcrs successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: dto.ToFcrListDTOs(result), + }) +} + +func (u *FcrController) 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.FcrService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get fcr successfully", + Data: dto.ToFcrDetailDTO(*result), + }) +} + +func (u *FcrController) 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.FcrService.CreateOne(c, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Create fcr successfully", + Data: dto.ToFcrDetailDTO(*result), + }) +} + +func (u *FcrController) 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.FcrService.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 fcr successfully", + Data: dto.ToFcrDetailDTO(*result), + }) +} + +func (u *FcrController) 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.FcrService.DeleteOne(c, uint(id)); err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Common{ + Code: fiber.StatusOK, + Status: "success", + Message: "Delete fcr successfully", + }) +} diff --git a/internal/modules/master/fcrs/dto/fcr.dto.go b/internal/modules/master/fcrs/dto/fcr.dto.go new file mode 100644 index 00000000..c88bb1a5 --- /dev/null +++ b/internal/modules/master/fcrs/dto/fcr.dto.go @@ -0,0 +1,86 @@ +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 FcrBaseDTO struct { + Id uint `json:"id"` + Name string `json:"name"` +} + +type FcrStandardDTO struct { + Id uint `json:"id"` + Weight float64 `json:"weight"` + FcrNumber float64 `json:"fcr_number"` + Mortality float64 `json:"mortality"` +} + +type FcrListDTO struct { + FcrBaseDTO + CreatedUser *userDTO.UserBaseDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type FcrDetailDTO struct { + FcrListDTO + Standards []FcrStandardDTO `json:"fcr_standards"` +} + +// === Mapper Functions === + +func ToFcrBaseDTO(e entity.Fcr) FcrBaseDTO { + return FcrBaseDTO{ + Id: e.Id, + Name: e.Name, + } +} + +func ToFcrListDTO(e entity.Fcr) FcrListDTO { + var createdUser *userDTO.UserBaseDTO + if e.CreatedUser.Id != 0 { + mapped := userDTO.ToUserBaseDTO(e.CreatedUser) + createdUser = &mapped + } + + return FcrListDTO{ + FcrBaseDTO: ToFcrBaseDTO(e), + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + CreatedUser: createdUser, + } +} + +func ToFcrListDTOs(e []entity.Fcr) []FcrListDTO { + result := make([]FcrListDTO, len(e)) + for i, r := range e { + result[i] = ToFcrListDTO(r) + } + return result +} + +func ToFcrDetailDTO(e entity.Fcr) FcrDetailDTO { + return FcrDetailDTO{ + FcrListDTO: ToFcrListDTO(e), + Standards: ToFcrStandardDTOs(e.Standards), + } +} + +func ToFcrStandardDTOs(standards []entity.FcrStandard) []FcrStandardDTO { + result := make([]FcrStandardDTO, len(standards)) + for i, s := range standards { + result[i] = FcrStandardDTO{ + Id: s.Id, + Weight: s.Weight, + FcrNumber: s.FcrNumber, + Mortality: s.Mortality, + } + } + return result +} diff --git a/internal/modules/master/fcrs/module.go b/internal/modules/master/fcrs/module.go new file mode 100644 index 00000000..0102dae4 --- /dev/null +++ b/internal/modules/master/fcrs/module.go @@ -0,0 +1,25 @@ +package fcrs + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + rFcr "gitlab.com/mbugroup/lti-api.git/internal/modules/master/fcrs/repositories" + sFcr "gitlab.com/mbugroup/lti-api.git/internal/modules/master/fcrs/services" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type FcrModule struct{} + +func (FcrModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + fcrRepo := rFcr.NewFcrRepository(db) + userRepo := rUser.NewUserRepository(db) + + fcrService := sFcr.NewFcrService(fcrRepo, validate) + userService := sUser.NewUserService(userRepo, validate) + + FcrRoutes(router, userService, fcrService) +} diff --git a/internal/modules/master/fcrs/repositories/fcr.repository.go b/internal/modules/master/fcrs/repositories/fcr.repository.go new file mode 100644 index 00000000..de21a87d --- /dev/null +++ b/internal/modules/master/fcrs/repositories/fcr.repository.go @@ -0,0 +1,90 @@ +package repository + +import ( + "context" + "errors" + + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type FcrRepository interface { + repository.BaseRepository[entity.Fcr] + NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) + SyncStandardsDiff(ctx context.Context, tx *gorm.DB, fcrID uint, standards []entity.FcrStandard) error +} + +type FcrRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.Fcr] +} + +func NewFcrRepository(db *gorm.DB) FcrRepository { + return &FcrRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.Fcr](db), + } +} + +func (r *FcrRepositoryImpl) NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) { + return repository.ExistsByName[entity.Fcr](ctx, r.DB(), name, excludeID) +} + +func (r *FcrRepositoryImpl) SyncStandardsDiff(ctx context.Context, tx *gorm.DB, fcrID uint, standards []entity.FcrStandard) error { + db := tx + if db == nil { + db = r.DB() + } + + var existing []entity.FcrStandard + if err := db.WithContext(ctx). + Where("fcr_id = ?", fcrID). + Find(&existing). + Error; err != nil { + return err + } + + existingMap := make(map[float64]entity.FcrStandard) + for _, st := range existing { + existingMap[st.Weight] = st + } + + newMap := make(map[float64]entity.FcrStandard) + for _, st := range standards { + st.FcrID = fcrID + newMap[st.Weight] = st + } + + baseRepo := repository.NewBaseRepository[entity.FcrStandard](db) + + for weight, newStd := range newMap { + if current, ok := existingMap[weight]; ok { + if current.FcrNumber != newStd.FcrNumber || current.Mortality != newStd.Mortality { + update := map[string]any{ + "fcr_number": newStd.FcrNumber, + "mortality": newStd.Mortality, + } + if err := baseRepo.PatchOne(ctx, current.Id, update, nil); err != nil { + return err + } + } + } else { + entry := newStd + if err := baseRepo.CreateOne(ctx, &entry, nil); err != nil { + return err + } + } + } + + for weight, current := range existingMap { + if _, keep := newMap[weight]; !keep { + if err := baseRepo.DeleteOne(ctx, current.Id); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + continue + } + return err + } + } + } + + return nil +} diff --git a/internal/modules/master/fcrs/route.go b/internal/modules/master/fcrs/route.go new file mode 100644 index 00000000..27863784 --- /dev/null +++ b/internal/modules/master/fcrs/route.go @@ -0,0 +1,28 @@ +package fcrs + +import ( + // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/master/fcrs/controllers" + fcr "gitlab.com/mbugroup/lti-api.git/internal/modules/master/fcrs/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func FcrRoutes(v1 fiber.Router, u user.UserService, s fcr.FcrService) { + ctrl := controller.NewFcrController(s) + + route := v1.Group("/fcrs") + + // 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) + route.Patch("/:id", ctrl.UpdateOne) + route.Delete("/:id", ctrl.DeleteOne) +} diff --git a/internal/modules/master/fcrs/services/fcr.service.go b/internal/modules/master/fcrs/services/fcr.service.go new file mode 100644 index 00000000..f4125374 --- /dev/null +++ b/internal/modules/master/fcrs/services/fcr.service.go @@ -0,0 +1,219 @@ +package service + +import ( + "errors" + "fmt" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/fcrs/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/fcrs/validations" + "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 FcrService interface { + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.Fcr, int64, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.Fcr, error) + CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.Fcr, error) + UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Fcr, error) + DeleteOne(ctx *fiber.Ctx, id uint) error +} + +type fcrService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.FcrRepository +} + +func NewFcrService(repo repository.FcrRepository, validate *validator.Validate) FcrService { + return &fcrService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + } +} + +func (s fcrService) withRelations(db *gorm.DB) *gorm.DB { + return db. + Preload("CreatedUser"). + Preload("Standards", func(db *gorm.DB) *gorm.DB { + return db.Order("weight ASC") + }) +} + +func (s fcrService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Fcr, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + offset := (params.Page - 1) * params.Limit + + fcrs, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { + db = s.withRelations(db) + if params.Search != "" { + return db.Where("name LIKE ?", "%"+params.Search+"%") + } + return db.Order("created_at DESC").Order("updated_at DESC") + }) + + if err != nil { + s.Log.Errorf("Failed to get fcrs: %+v", err) + return nil, 0, err + } + return fcrs, total, nil +} + +func (s fcrService) GetOne(c *fiber.Ctx, id uint) (*entity.Fcr, error) { + fcr, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Fcr not found") + } + if err != nil { + s.Log.Errorf("Failed get fcr by id: %+v", err) + return nil, err + } + return fcr, nil +} + +func (s *fcrService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Fcr, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + if exists, err := s.Repository.NameExists(c.Context(), req.Name, nil); err != nil { + s.Log.Errorf("Failed to check fcr name: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check fcr name") + } else if exists { + return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Fcr with name %s already exists", req.Name)) + } + + createBody := &entity.Fcr{ + Name: req.Name, + CreatedBy: 1, + } + + err := s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { + repoTx := s.Repository.WithTx(tx) + + if err := repoTx.CreateOne(c.Context(), createBody, nil); err != nil { + return err + } + + if len(req.FcrStandards) == 0 { + return nil + } + + standards := make([]entity.FcrStandard, len(req.FcrStandards)) + for i, std := range req.FcrStandards { + standards[i] = entity.FcrStandard{ + FcrID: createBody.Id, + Weight: std.Weight, + FcrNumber: std.FcrNumber, + Mortality: std.Mortality, + } + } + + if err := s.Repository.SyncStandardsDiff(c.Context(), tx, createBody.Id, standards); err != nil { + return err + } + + return nil + }) + + if err != nil { + s.Log.Errorf("Failed to create fcr: %+v", err) + return nil, err + } + + return s.GetOne(c, createBody.Id) +} + +func (s fcrService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Fcr, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + updateBody := make(map[string]any) + + if req.Name != nil { + if exists, err := s.Repository.NameExists(c.Context(), *req.Name, &id); err != nil { + s.Log.Errorf("Failed to check fcr name: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check fcr name") + } else if exists { + return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Fcr with name %s already exists", *req.Name)) + } + updateBody["name"] = *req.Name + } + + if len(updateBody) == 0 && req.FcrStandards == nil { + return s.GetOne(c, id) + } + + err := s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { + repoTx := s.Repository.WithTx(tx) + + if len(updateBody) > 0 { + if err := repoTx.PatchOne(c.Context(), id, updateBody, nil); err != nil { + return err + } + } else { + if _, err := repoTx.GetByID(c.Context(), id, nil); err != nil { + return err + } + } + + if req.FcrStandards != nil { + standards := make([]entity.FcrStandard, len(req.FcrStandards)) + for i, std := range req.FcrStandards { + standards[i] = entity.FcrStandard{ + FcrID: id, + Weight: std.Weight, + FcrNumber: std.FcrNumber, + Mortality: std.Mortality, + } + } + + if err := s.Repository.SyncStandardsDiff(c.Context(), tx, id, standards); err != nil { + return err + } + } + + return nil + }) + + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Fcr not found") + } + s.Log.Errorf("Failed to update fcr: %+v", err) + return nil, err + } + + return s.GetOne(c, id) +} + +func (s fcrService) DeleteOne(c *fiber.Ctx, id uint) error { + err := s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { + repoTx := s.Repository.WithTx(tx) + + if err := s.Repository.SyncStandardsDiff(c.Context(), tx, id, nil); err != nil { + return err + } + + return repoTx.DeleteOne(c.Context(), id) + }) + + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Fcr not found") + } + s.Log.Errorf("Failed to delete fcr: %+v", err) + return err + } + + return nil +} diff --git a/internal/modules/master/fcrs/validations/fcr.validation.go b/internal/modules/master/fcrs/validations/fcr.validation.go new file mode 100644 index 00000000..a5e070cc --- /dev/null +++ b/internal/modules/master/fcrs/validations/fcr.validation.go @@ -0,0 +1,23 @@ +package validation + +type FcrStandard struct { + Weight float64 `json:"weight" validate:"required,gte=0"` + FcrNumber float64 `json:"fcr_number" validate:"required,gte=0"` + Mortality float64 `json:"mortality" validate:"required,gte=0"` +} + +type Create struct { + Name string `json:"name" validate:"required_strict,min=3,max=50"` + FcrStandards []FcrStandard `json:"fcr_standards" validate:"required,min=1,dive"` +} + +type Update struct { + Name *string `json:"name,omitempty" validate:"omitempty_strict,min=3,max=50"` + FcrStandards []FcrStandard `json:"fcr_standards,omitempty" validate:"omitempty,min=1,dive"` +} + +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"` +} diff --git a/internal/modules/master/kandangs/dto/kandang.dto.go b/internal/modules/master/kandangs/dto/kandang.dto.go index 5f5786a3..d40498af 100644 --- a/internal/modules/master/kandangs/dto/kandang.dto.go +++ b/internal/modules/master/kandangs/dto/kandang.dto.go @@ -73,3 +73,9 @@ func ToKandangListDTOs(e []entity.Kandang) []KandangListDTO { } return result } + +func ToKandangDetailDTO(e entity.Kandang) KandangDetailDTO { + return KandangDetailDTO{ + KandangListDTO: ToKandangListDTO(e), + } +} diff --git a/internal/modules/master/kandangs/services/kandang.service.go b/internal/modules/master/kandangs/services/kandang.service.go index f7dea21f..59162860 100644 --- a/internal/modules/master/kandangs/services/kandang.service.go +++ b/internal/modules/master/kandangs/services/kandang.service.go @@ -108,7 +108,7 @@ func (s *kandangService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit return nil, err } - return s.Repository.GetByID(c.Context(), createBody.Id, s.withRelations) + return s.GetOne(c, createBody.Id) } func (s kandangService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Kandang, error) { @@ -128,17 +128,18 @@ func (s kandangService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) updateBody["name"] = *req.Name } + if err := common.EnsureRelations(c.Context(), + common.RelationCheck{Name: "Location", ID: req.LocationId, Exists: s.Repository.LocationExists}, + common.RelationCheck{Name: "Pic", ID: req.PicId, Exists: s.Repository.PicExists}, + ); err != nil { + return nil, err + } + if req.LocationId != nil { - if err := common.EnsureRelations(c.Context(), common.RelationCheck{Name: "Location", ID: req.LocationId, Exists: s.Repository.LocationExists}); err != nil { - return nil, err - } updateBody["location_id"] = *req.LocationId } if req.PicId != nil { - if err := common.EnsureRelations(c.Context(), common.RelationCheck{Name: "Pic", ID: req.PicId, Exists: s.Repository.PicExists}); err != nil { - return nil, err - } updateBody["pic_id"] = *req.PicId } diff --git a/internal/modules/master/kandangs/validations/kandang.validation.go b/internal/modules/master/kandangs/validations/kandang.validation.go index 5b96a4b3..d76a6982 100644 --- a/internal/modules/master/kandangs/validations/kandang.validation.go +++ b/internal/modules/master/kandangs/validations/kandang.validation.go @@ -7,7 +7,7 @@ type Create struct { } type Update struct { - Name *string `json:"name,omitempty" validate:"omitempty,max=50"` + Name *string `json:"name,omitempty" validate:"omitempty"` LocationId *uint `json:"location_id,omitempty" validate:"omitempty,number,gt=0"` PicId *uint `json:"pic_id,omitempty" validate:"omitempty,number,gt=0"` } diff --git a/internal/modules/master/locations/dto/location.dto.go b/internal/modules/master/locations/dto/location.dto.go index 6da3694b..6e9ec68b 100644 --- a/internal/modules/master/locations/dto/location.dto.go +++ b/internal/modules/master/locations/dto/location.dto.go @@ -67,3 +67,9 @@ func ToLocationListDTOs(e []entity.Location) []LocationListDTO { } return result } + +func ToLocationDetailDTO(e entity.Location) LocationDetailDTO { + return LocationDetailDTO{ + LocationListDTO: ToLocationListDTO(e), + } +} diff --git a/internal/modules/master/locations/services/location.service.go b/internal/modules/master/locations/services/location.service.go index e7498377..1aae174c 100644 --- a/internal/modules/master/locations/services/location.service.go +++ b/internal/modules/master/locations/services/location.service.go @@ -107,13 +107,7 @@ func (s *locationService) CreateOne(c *fiber.Ctx, req *validation.Create) (*enti return nil, err } - created, err := s.Repository.GetByID(c.Context(), createBody.Id, s.withRelations) - if err != nil { - s.Log.Errorf("Failed to reload created location: %+v", err) - return nil, err - } - - return created, nil + return s.GetOne(c, createBody.Id) } func (s locationService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Location, error) { @@ -137,10 +131,11 @@ func (s locationService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint updateBody["address"] = *req.Address } + if err := common.EnsureRelations(c.Context(), common.RelationCheck{Name: "Area", ID: req.AreaId, Exists: s.Repository.AreaExists}); err != nil { + return nil, err + } + if req.AreaId != nil { - if err := common.EnsureRelations(c.Context(), common.RelationCheck{Name: "Area", ID: req.AreaId, Exists: s.Repository.AreaExists}); err != nil { - return nil, err - } updateBody["area_id"] = *req.AreaId } diff --git a/internal/modules/master/locations/validations/location.validation.go b/internal/modules/master/locations/validations/location.validation.go index 7528d255..a9af1eb8 100644 --- a/internal/modules/master/locations/validations/location.validation.go +++ b/internal/modules/master/locations/validations/location.validation.go @@ -7,7 +7,7 @@ type Create struct { } type Update struct { - Name *string `json:"name,omitempty" validate:"omitempty,max=50"` + Name *string `json:"name,omitempty" validate:"omitempty"` Address *string `json:"address,omitempty" validate:"omitempty"` AreaId *uint `json:"area_id,omitempty" validate:"omitempty,number,gt=0"` } diff --git a/internal/modules/master/nonstocks/controllers/nonstock.controller.go b/internal/modules/master/nonstocks/controllers/nonstock.controller.go new file mode 100644 index 00000000..d8b688b7 --- /dev/null +++ b/internal/modules/master/nonstocks/controllers/nonstock.controller.go @@ -0,0 +1,140 @@ +package controller + +import ( + "math" + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type NonstockController struct { + NonstockService service.NonstockService +} + +func NewNonstockController(nonstockService service.NonstockService) *NonstockController { + return &NonstockController{ + NonstockService: nonstockService, + } +} + +func (u *NonstockController) 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.NonstockService.GetAll(c, query) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.NonstockListDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get all nonstocks successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: dto.ToNonstockListDTOs(result), + }) +} + +func (u *NonstockController) 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.NonstockService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get nonstock successfully", + Data: dto.ToNonstockDetailDTO(*result), + }) +} + +func (u *NonstockController) 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.NonstockService.CreateOne(c, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Create nonstock successfully", + Data: dto.ToNonstockDetailDTO(*result), + }) +} + +func (u *NonstockController) 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.NonstockService.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 nonstock successfully", + Data: dto.ToNonstockDetailDTO(*result), + }) +} + +func (u *NonstockController) 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.NonstockService.DeleteOne(c, uint(id)); err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Common{ + Code: fiber.StatusOK, + Status: "success", + Message: "Delete nonstock successfully", + }) +} diff --git a/internal/modules/master/nonstocks/dto/nonstock.dto.go b/internal/modules/master/nonstocks/dto/nonstock.dto.go new file mode 100644 index 00000000..9ab2270b --- /dev/null +++ b/internal/modules/master/nonstocks/dto/nonstock.dto.go @@ -0,0 +1,97 @@ +package dto + +import ( + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + supplierDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/dto" + uomDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/uoms/dto" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" +) + +// === DTO Structs === + +type NonstockBaseDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + UomID uint `json:"uom_id"` +} + +type NonstockListDTO struct { + NonstockBaseDTO + Uom *uomDTO.UomBaseDTO `json:"uom,omitempty"` + Suppliers []supplierDTO.SupplierBaseDTO `json:"suppliers"` + Flags []string `json:"flags"` + CreatedUser *userDTO.UserBaseDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type NonstockDetailDTO struct { + NonstockListDTO + Flags []string `json:"flags"` +} + +// === Mapper Functions === + +func ToNonstockBaseDTO(e entity.Nonstock) NonstockBaseDTO { + return NonstockBaseDTO{ + Id: e.Id, + Name: e.Name, + UomID: e.UomId, + } +} + +func ToNonstockListDTO(e entity.Nonstock) NonstockListDTO { + var createdUser *userDTO.UserBaseDTO + if e.CreatedUser.Id != 0 { + mapped := userDTO.ToUserBaseDTO(e.CreatedUser) + createdUser = &mapped + } + + var uomRef *uomDTO.UomBaseDTO + if e.Uom.Id != 0 { + mapped := uomDTO.ToUomBaseDTO(e.Uom) + uomRef = &mapped + } + + suppliers := make([]supplierDTO.SupplierBaseDTO, len(e.Suppliers)) + for i, s := range e.Suppliers { + suppliers[i] = supplierDTO.ToSupplierBaseDTO(s) + } + + flags := make([]string, len(e.Flags)) + for i, f := range e.Flags { + flags[i] = f.Name + } + + return NonstockListDTO{ + NonstockBaseDTO: ToNonstockBaseDTO(e), + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + CreatedUser: createdUser, + Uom: uomRef, + Suppliers: suppliers, + Flags: flags, + } +} + +func ToNonstockListDTOs(e []entity.Nonstock) []NonstockListDTO { + result := make([]NonstockListDTO, len(e)) + for i, r := range e { + result[i] = ToNonstockListDTO(r) + } + return result +} + +func ToNonstockDetailDTO(e entity.Nonstock) NonstockDetailDTO { + flags := make([]string, len(e.Flags)) + for i, f := range e.Flags { + flags[i] = f.Name + } + + return NonstockDetailDTO{ + NonstockListDTO: ToNonstockListDTO(e), + Flags: flags, + } +} diff --git a/internal/modules/master/nonstocks/module.go b/internal/modules/master/nonstocks/module.go new file mode 100644 index 00000000..167d432b --- /dev/null +++ b/internal/modules/master/nonstocks/module.go @@ -0,0 +1,26 @@ +package nonstocks + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + rNonstock "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/repositories" + sNonstock "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/services" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type NonstockModule struct{} + +func (NonstockModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + nonstockRepo := rNonstock.NewNonstockRepository(db) + userRepo := rUser.NewUserRepository(db) + + nonstockService := sNonstock.NewNonstockService(nonstockRepo, validate) + userService := sUser.NewUserService(userRepo, validate) + + NonstockRoutes(router, userService, nonstockService) +} + diff --git a/internal/modules/master/nonstocks/repositories/nonstock.repository.go b/internal/modules/master/nonstocks/repositories/nonstock.repository.go new file mode 100644 index 00000000..affcbc4b --- /dev/null +++ b/internal/modules/master/nonstocks/repositories/nonstock.repository.go @@ -0,0 +1,172 @@ +package repository + +import ( + "context" + "errors" + + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type NonstockRepository interface { + repository.BaseRepository[entity.Nonstock] + NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) + SyncSuppliersDiff(ctx context.Context, tx *gorm.DB, nonstockID uint, supplierIDs []uint) error + UomExists(ctx context.Context, uomID uint) (bool, error) + GetSuppliersByIDs(ctx context.Context, supplierIDs []uint) ([]entity.Supplier, error) + SyncFlags(ctx context.Context, tx *gorm.DB, nonstockID uint, flags []string) error + DeleteFlags(ctx context.Context, tx *gorm.DB, nonstockID uint) error + GetFlags(ctx context.Context, nonstockID uint) ([]entity.Flag, error) +} + +type NonstockRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.Nonstock] +} + +func NewNonstockRepository(db *gorm.DB) NonstockRepository { + return &NonstockRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.Nonstock](db), + } +} + +func (r *NonstockRepositoryImpl) NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) { + return repository.ExistsByName[entity.Nonstock](ctx, r.DB(), name, excludeID) +} + +func (r *NonstockRepositoryImpl) SyncSuppliersDiff(ctx context.Context, tx *gorm.DB, nonstockID uint, supplierIDs []uint) error { + db := tx + if db == nil { + db = r.DB() + } + + if supplierIDs == nil { + return db.WithContext(ctx). + Where("nonstock_id = ?", nonstockID). + Delete(&entity.NonstockSupplier{}). + Error + } + + var existing []entity.NonstockSupplier + if err := db.WithContext(ctx). + Where("nonstock_id = ?", nonstockID). + Find(&existing). + Error; err != nil { + return err + } + + existingMap := make(map[uint]struct{}, len(existing)) + for _, rel := range existing { + existingMap[rel.SupplierID] = struct{}{} + } + + incomingMap := make(map[uint]struct{}, len(supplierIDs)) + for _, id := range supplierIDs { + incomingMap[id] = struct{}{} + if _, exists := existingMap[id]; exists { + continue + } + record := entity.NonstockSupplier{NonstockID: nonstockID, SupplierID: id} + if err := db.WithContext(ctx).Create(&record).Error; err != nil { + return err + } + } + + for _, rel := range existing { + if _, keep := incomingMap[rel.SupplierID]; !keep { + if err := db.WithContext(ctx). + Where("nonstock_id = ? AND supplier_id = ?", nonstockID, rel.SupplierID). + Delete(&entity.NonstockSupplier{}). + Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + continue + } + return err + } + } + } + + return nil +} + +func (r *NonstockRepositoryImpl) UomExists(ctx context.Context, uomID uint) (bool, error) { + var count int64 + if err := r.DB().WithContext(ctx). + Model(&entity.Uom{}). + Where("id = ?", uomID). + Count(&count). + Error; err != nil { + return false, err + } + return count > 0, nil +} + +func (r *NonstockRepositoryImpl) GetSuppliersByIDs(ctx context.Context, supplierIDs []uint) ([]entity.Supplier, error) { + if len(supplierIDs) == 0 { + return nil, nil + } + + var suppliers []entity.Supplier + if err := r.DB().WithContext(ctx). + Select("id", "category"). + Where("id IN ?", supplierIDs). + Find(&suppliers). + Error; err != nil { + return nil, err + } + return suppliers, nil +} + +func (r *NonstockRepositoryImpl) SyncFlags(ctx context.Context, tx *gorm.DB, nonstockID uint, flags []string) error { + db := tx + if db == nil { + db = r.DB() + } + + // Hapus flags lama terlebih dahulu + if err := db.WithContext(ctx). + Where("flagable_id = ? AND flagable_type = ?", nonstockID, entity.FlagableTypeNonstock). + Delete(&entity.Flag{}). + Error; err != nil { + return err + } + + // Insert flags baru jika ada + if len(flags) > 0 { + newFlags := make([]entity.Flag, len(flags)) + for i, f := range flags { + newFlags[i] = entity.Flag{ + Name: f, + FlagableID: nonstockID, + FlagableType: entity.FlagableTypeNonstock, + } + } + if err := db.WithContext(ctx).Create(&newFlags).Error; err != nil { + return err + } + } + + return nil +} + +func (r *NonstockRepositoryImpl) DeleteFlags(ctx context.Context, tx *gorm.DB, nonstockID uint) error { + db := tx + if db == nil { + db = r.DB() + } + return db.WithContext(ctx). + Where("flagable_id = ? AND flagable_type = ?", nonstockID, entity.FlagableTypeNonstock). + Delete(&entity.Flag{}). + Error +} + +func (r *NonstockRepositoryImpl) GetFlags(ctx context.Context, nonstockID uint) ([]entity.Flag, error) { + var flags []entity.Flag + if err := r.DB().WithContext(ctx). + Where("flagable_id = ? AND flagable_type = ?", nonstockID, entity.FlagableTypeNonstock). + Find(&flags). + Error; err != nil { + return nil, err + } + return flags, nil +} diff --git a/internal/modules/master/nonstocks/route.go b/internal/modules/master/nonstocks/route.go new file mode 100644 index 00000000..155096f0 --- /dev/null +++ b/internal/modules/master/nonstocks/route.go @@ -0,0 +1,28 @@ +package nonstocks + +import ( + // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/controllers" + nonstock "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func NonstockRoutes(v1 fiber.Router, u user.UserService, s nonstock.NonstockService) { + ctrl := controller.NewNonstockController(s) + + route := v1.Group("/nonstocks") + + // 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) + route.Patch("/:id", ctrl.UpdateOne) + route.Delete("/:id", ctrl.DeleteOne) +} diff --git a/internal/modules/master/nonstocks/services/nonstock.service.go b/internal/modules/master/nonstocks/services/nonstock.service.go new file mode 100644 index 00000000..3833432e --- /dev/null +++ b/internal/modules/master/nonstocks/services/nonstock.service.go @@ -0,0 +1,308 @@ +package service + +import ( + "errors" + "fmt" + "strings" + + common "gitlab.com/mbugroup/lti-api.git/internal/common/service" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/validations" + "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 NonstockService interface { + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.Nonstock, int64, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.Nonstock, error) + CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.Nonstock, error) + UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Nonstock, error) + DeleteOne(ctx *fiber.Ctx, id uint) error +} + +type nonstockService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.NonstockRepository +} + +func NewNonstockService(repo repository.NonstockRepository, validate *validator.Validate) NonstockService { + return &nonstockService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + } +} + +func (s nonstockService) withRelations(db *gorm.DB) *gorm.DB { + return db. + Preload("CreatedUser"). + Preload("Uom"). + Preload("Flags"). + Preload("Suppliers", func(db *gorm.DB) *gorm.DB { + return db.Order("suppliers.name ASC") + }) +} + +func (s nonstockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Nonstock, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + offset := (params.Page - 1) * params.Limit + + nonstocks, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { + db = s.withRelations(db) + if params.Search != "" { + return db.Where("name LIKE ?", "%"+params.Search+"%") + } + return db.Order("created_at DESC").Order("updated_at DESC") + }) + + if err != nil { + s.Log.Errorf("Failed to get nonstocks: %+v", err) + return nil, 0, err + } + return nonstocks, total, nil +} + +func (s nonstockService) GetOne(c *fiber.Ctx, id uint) (*entity.Nonstock, error) { + nonstock, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Nonstock not found") + } + if err != nil { + s.Log.Errorf("Failed get nonstock by id: %+v", err) + return nil, err + } + return nonstock, nil +} + +func (s *nonstockService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Nonstock, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + ctx := c.Context() + name := strings.TrimSpace(req.Name) + + if exists, err := s.Repository.NameExists(ctx, name, nil); err != nil { + s.Log.Errorf("Failed to check nonstock name: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check nonstock name") + } else if exists { + return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Nonstock with name %s already exists", name)) + } + + if err := common.EnsureRelations(ctx, common.RelationCheck{Name: "Uom", ID: &req.UomID, Exists: s.Repository.UomExists}); err != nil { + return nil, err + } + + supplierIDs := utils.UniqueUintSlice(req.SupplierIDs) + if len(supplierIDs) > 0 { + supplierList, supplierErr := s.Repository.GetSuppliersByIDs(ctx, supplierIDs) + if supplierErr != nil { + s.Log.Errorf("Failed to validate suppliers: %+v", supplierErr) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate suppliers") + } + if len(supplierList) != len(supplierIDs) { + actualIDs := make([]uint, len(supplierList)) + for i, supplier := range supplierList { + actualIDs[i] = supplier.Id + } + missing := utils.MissingUintIDs(supplierIDs, actualIDs) + return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Suppliers with ids %v not found", missing)) + } + for _, sup := range supplierList { + if strings.ToUpper(sup.Category) != string(utils.SupplierCategoryBOP) { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Supplier with id %d is not category BOP", sup.Id)) + } + } + } + + nonstockFlags, flagErr := normalizeNonstockFlags(req.Flags) + if flagErr != nil { + return nil, flagErr + } + + createBody := &entity.Nonstock{ + Name: req.Name, + UomId: req.UomID, + CreatedBy: 1, + } + + err := s.Repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + repoTx := s.Repository.WithTx(tx) + + if err := repoTx.CreateOne(ctx, createBody, nil); err != nil { + return err + } + + if err := s.Repository.SyncFlags(ctx, tx, createBody.Id, nonstockFlags); err != nil { + return err + } + + return s.Repository.SyncSuppliersDiff(ctx, tx, createBody.Id, supplierIDs) + }) + + if err != nil { + s.Log.Errorf("Failed to create nonstock: %+v", err) + return nil, err + } + + return s.GetOne(c, createBody.Id) +} + +func (s nonstockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Nonstock, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + updateBody := make(map[string]any) + + ctx := c.Context() + + if err := common.EnsureRelations(ctx, common.RelationCheck{Name: "Uom", ID: req.UomID, Exists: s.Repository.UomExists}); err != nil { + return nil, err + } + + if req.Name != nil { + if exists, err := s.Repository.NameExists(ctx, *req.Name, &id); err != nil { + s.Log.Errorf("Failed to check nonstock name: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check nonstock name") + } else if exists { + return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Nonstock with name %s already exists", *req.Name)) + } + + updateBody["name"] = *req.Name + } + if req.UomID != nil { + updateBody["uom_id"] = *req.UomID + } + + var supplierIDs []uint + var supplierUpdate bool + if req.SupplierIDs != nil { + supplierUpdate = true + supplierIDs = utils.UniqueUintSlice(*req.SupplierIDs) + if len(supplierIDs) > 0 { + var supplierList []entity.Supplier + var supplierErr error + supplierList, supplierErr = s.Repository.GetSuppliersByIDs(ctx, supplierIDs) + if supplierErr != nil { + s.Log.Errorf("Failed to validate suppliers: %+v", supplierErr) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate suppliers") + } + if len(supplierList) != len(supplierIDs) { + actualIDs := make([]uint, len(supplierList)) + for i, supplier := range supplierList { + actualIDs[i] = supplier.Id + } + missing := utils.MissingUintIDs(supplierIDs, actualIDs) + return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Suppliers with ids %v not found", missing)) + } + for _, sup := range supplierList { + if strings.ToUpper(sup.Category) != string(utils.SupplierCategoryBOP) { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Supplier with id %d is not category BOP", sup.Id)) + } + } + } + } + + var ( + flagUpdate bool + flagValues []string + ) + if req.Flags != nil { + flagUpdate = true + var flagErr error + flagValues, flagErr = normalizeNonstockFlags(*req.Flags) + if flagErr != nil { + return nil, flagErr + } + } + + if len(updateBody) == 0 && !supplierUpdate && !flagUpdate { + return s.GetOne(c, id) + } + + err := s.Repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + repoTx := s.Repository.WithTx(tx) + + if len(updateBody) > 0 { + if err := repoTx.PatchOne(ctx, id, updateBody, nil); err != nil { + return err + } + } else { + if _, err := repoTx.GetByID(ctx, id, nil); err != nil { + return err + } + } + + if supplierUpdate { + var ids []uint + if len(supplierIDs) > 0 { + ids = supplierIDs + } + if err := s.Repository.SyncSuppliersDiff(ctx, tx, id, ids); err != nil { + return err + } + } + + if flagUpdate { + if err := s.Repository.SyncFlags(ctx, tx, id, flagValues); err != nil { + return err + } + } + + return nil + }) + + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Nonstock not found") + } + s.Log.Errorf("Failed to update nonstock: %+v", err) + return nil, err + } + return s.GetOne(c, id) +} + +func (s nonstockService) DeleteOne(c *fiber.Ctx, id uint) error { + err := s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { + repoTx := s.Repository.WithTx(tx) + + if err := s.Repository.SyncSuppliersDiff(c.Context(), tx, id, nil); err != nil { + return err + } + + if err := s.Repository.DeleteFlags(c.Context(), tx, id); err != nil { + return err + } + + return repoTx.DeleteOne(c.Context(), id) + }) + + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Nonstock not found") + } + s.Log.Errorf("Failed to delete nonstock: %+v", err) + return err + } + return nil +} + +func normalizeNonstockFlags(raw []string) ([]string, error) { + normalized, invalid := utils.NormalizeFlagsForGroup(raw, utils.FlagGroupNonstock) + if len(invalid) > 0 { + invalidStr := strings.Join(utils.FlagTypesToStrings(invalid), ", ") + allowedStr := strings.Join(utils.FlagTypesToStrings(utils.AllowedFlagTypes(utils.FlagGroupNonstock)), ", ") + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid nonstock flags: %s. Allowed flags: %s", invalidStr, allowedStr)) + } + return utils.FlagTypesToStrings(normalized), nil +} diff --git a/internal/modules/master/nonstocks/validations/nonstock.validation.go b/internal/modules/master/nonstocks/validations/nonstock.validation.go new file mode 100644 index 00000000..9d93ce3d --- /dev/null +++ b/internal/modules/master/nonstocks/validations/nonstock.validation.go @@ -0,0 +1,21 @@ +package validation + +type Create struct { + Name string `json:"name" validate:"required_strict,min=3"` + UomID uint `json:"uom_id" validate:"required,gt=0"` + SupplierIDs []uint `json:"supplier_ids,omitempty" validate:"omitempty,dive,gt=0"` + Flags []string `json:"flags,omitempty" validate:"omitempty,dive,max=50"` +} + +type Update struct { + Name *string `json:"name,omitempty" validate:"omitempty,min=3"` + UomID *uint `json:"uom_id,omitempty" validate:"omitempty,gt=0"` + SupplierIDs *[]uint `json:"supplier_ids,omitempty" validate:"omitempty,dive,gt=0"` + Flags *[]string `json:"flags,omitempty" validate:"omitempty,dive,max=50"` +} + +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"` +} diff --git a/internal/modules/master/product-categories/controllers/product-category.controller.go b/internal/modules/master/product-categories/controllers/product-category.controller.go new file mode 100644 index 00000000..778a3188 --- /dev/null +++ b/internal/modules/master/product-categories/controllers/product-category.controller.go @@ -0,0 +1,140 @@ +package controller + +import ( + "math" + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/master/product-categories/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/master/product-categories/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/product-categories/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type ProductCategoryController struct { + ProductCategoryService service.ProductCategoryService +} + +func NewProductCategoryController(productCategoryService service.ProductCategoryService) *ProductCategoryController { + return &ProductCategoryController{ + ProductCategoryService: productCategoryService, + } +} + +func (u *ProductCategoryController) 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.ProductCategoryService.GetAll(c, query) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.ProductCategoryListDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get all product categories successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: dto.ToProductCategoryListDTOs(result), + }) +} + +func (u *ProductCategoryController) 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.ProductCategoryService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get product category successfully", + Data: dto.ToProductCategoryDetailDTO(*result), + }) +} + +func (u *ProductCategoryController) 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.ProductCategoryService.CreateOne(c, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Create product category successfully", + Data: dto.ToProductCategoryDetailDTO(*result), + }) +} + +func (u *ProductCategoryController) 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.ProductCategoryService.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 product category successfully", + Data: dto.ToProductCategoryDetailDTO(*result), + }) +} + +func (u *ProductCategoryController) 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.ProductCategoryService.DeleteOne(c, uint(id)); err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Common{ + Code: fiber.StatusOK, + Status: "success", + Message: "Delete product category successfully", + }) +} diff --git a/internal/modules/master/product-categories/dto/product-category.dto.go b/internal/modules/master/product-categories/dto/product-category.dto.go new file mode 100644 index 00000000..6750c7c7 --- /dev/null +++ b/internal/modules/master/product-categories/dto/product-category.dto.go @@ -0,0 +1,66 @@ +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 ProductCategoryBaseDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + Code string `json:"code"` +} + +type ProductCategoryListDTO struct { + ProductCategoryBaseDTO + CreatedUser *userDTO.UserBaseDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ProductCategoryDetailDTO struct { + ProductCategoryListDTO +} + +// === Mapper Functions === + +func ToProductCategoryBaseDTO(e entity.ProductCategory) ProductCategoryBaseDTO { + return ProductCategoryBaseDTO{ + Id: e.Id, + Name: e.Name, + Code: e.Code, + } +} + +func ToProductCategoryListDTO(e entity.ProductCategory) ProductCategoryListDTO { + var createdUser *userDTO.UserBaseDTO + if e.CreatedUser.Id != 0 { + mapped := userDTO.ToUserBaseDTO(e.CreatedUser) + createdUser = &mapped + } + + return ProductCategoryListDTO{ + ProductCategoryBaseDTO: ToProductCategoryBaseDTO(e), + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + CreatedUser: createdUser, + } +} + +func ToProductCategoryListDTOs(e []entity.ProductCategory) []ProductCategoryListDTO { + result := make([]ProductCategoryListDTO, len(e)) + for i, r := range e { + result[i] = ToProductCategoryListDTO(r) + } + return result +} + +func ToProductCategoryDetailDTO(e entity.ProductCategory) ProductCategoryDetailDTO { + return ProductCategoryDetailDTO{ + ProductCategoryListDTO: ToProductCategoryListDTO(e), + } +} diff --git a/internal/modules/master/product-categories/module.go b/internal/modules/master/product-categories/module.go new file mode 100644 index 00000000..fb022b20 --- /dev/null +++ b/internal/modules/master/product-categories/module.go @@ -0,0 +1,25 @@ +package productcategories + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + rProductCategory "gitlab.com/mbugroup/lti-api.git/internal/modules/master/product-categories/repositories" + sProductCategory "gitlab.com/mbugroup/lti-api.git/internal/modules/master/product-categories/services" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type ProductCategoryModule struct{} + +func (ProductCategoryModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + productCategoryRepo := rProductCategory.NewProductCategoryRepository(db) + userRepo := rUser.NewUserRepository(db) + + productCategoryService := sProductCategory.NewProductCategoryService(productCategoryRepo, validate) + userService := sUser.NewUserService(userRepo, validate) + + ProductCategoryRoutes(router, userService, productCategoryService) +} diff --git a/internal/modules/master/product-categories/repositories/product-category.repository.go b/internal/modules/master/product-categories/repositories/product-category.repository.go new file mode 100644 index 00000000..7b8be002 --- /dev/null +++ b/internal/modules/master/product-categories/repositories/product-category.repository.go @@ -0,0 +1,44 @@ +package repository + +import ( + "context" + + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type ProductCategoryRepository interface { + repository.BaseRepository[entity.ProductCategory] + NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) + CodeExists(ctx context.Context, code string, excludeID *uint) (bool, error) +} + +type ProductCategoryRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.ProductCategory] +} + +func NewProductCategoryRepository(db *gorm.DB) ProductCategoryRepository { + return &ProductCategoryRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.ProductCategory](db), + } +} + +func (r *ProductCategoryRepositoryImpl) NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) { + return repository.ExistsByName[entity.ProductCategory](ctx, r.DB(), name, excludeID) +} + +func (r *ProductCategoryRepositoryImpl) CodeExists(ctx context.Context, code string, excludeID *uint) (bool, error) { + var count int64 + q := r.DB().WithContext(ctx). + Model(new(entity.ProductCategory)). + Where("code = ?", code). + Where("deleted_at IS NULL") + if excludeID != nil { + q = q.Where("id <> ?", *excludeID) + } + if err := q.Count(&count).Error; err != nil { + return false, err + } + return count > 0, nil +} diff --git a/internal/modules/master/product-categories/route.go b/internal/modules/master/product-categories/route.go new file mode 100644 index 00000000..349fcb78 --- /dev/null +++ b/internal/modules/master/product-categories/route.go @@ -0,0 +1,28 @@ +package productcategories + +import ( + // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/master/product-categories/controllers" + productCategory "gitlab.com/mbugroup/lti-api.git/internal/modules/master/product-categories/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func ProductCategoryRoutes(v1 fiber.Router, u user.UserService, s productCategory.ProductCategoryService) { + ctrl := controller.NewProductCategoryController(s) + + route := v1.Group("/product-categories") + + // 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) + route.Patch("/:id", ctrl.UpdateOne) + route.Delete("/:id", ctrl.DeleteOne) +} diff --git a/internal/modules/master/product-categories/services/product-category.service.go b/internal/modules/master/product-categories/services/product-category.service.go new file mode 100644 index 00000000..90936d7b --- /dev/null +++ b/internal/modules/master/product-categories/services/product-category.service.go @@ -0,0 +1,175 @@ +package service + +import ( + "errors" + "fmt" + "strings" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/product-categories/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/product-categories/validations" + "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 ProductCategoryService interface { + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProductCategory, int64, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.ProductCategory, error) + CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.ProductCategory, error) + UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.ProductCategory, error) + DeleteOne(ctx *fiber.Ctx, id uint) error +} + +type productCategoryService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.ProductCategoryRepository +} + +func NewProductCategoryService(repo repository.ProductCategoryRepository, validate *validator.Validate) ProductCategoryService { + return &productCategoryService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + } +} + +func (s productCategoryService) withRelations(db *gorm.DB) *gorm.DB { + return db.Preload("CreatedUser") +} + +func (s productCategoryService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProductCategory, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + offset := (params.Page - 1) * params.Limit + + productCategories, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { + db = s.withRelations(db) + if params.Search != "" { + return db.Where("name LIKE ?", "%"+params.Search+"%") + } + return db.Order("created_at DESC").Order("updated_at DESC") + }) + + if err != nil { + s.Log.Errorf("Failed to get product categories: %+v", err) + return nil, 0, err + } + return productCategories, total, nil +} + +func (s productCategoryService) GetOne(c *fiber.Ctx, id uint) (*entity.ProductCategory, error) { + productCategory, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Product category not found") + } + if err != nil { + s.Log.Errorf("Failed get product category by id: %+v", err) + return nil, err + } + return productCategory, nil +} + +func (s *productCategoryService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.ProductCategory, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + ctx := c.Context() + name := strings.TrimSpace(req.Name) + code := strings.ToUpper(strings.TrimSpace(req.Code)) + + if exists, err := s.Repository.NameExists(ctx, name, nil); err != nil { + s.Log.Errorf("Failed to check product category name: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check product category name") + } else if exists { + return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Product category with name %s already exists", name)) + } + + if exists, err := s.Repository.CodeExists(ctx, code, nil); err != nil { + s.Log.Errorf("Failed to check product category code: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check product category code") + } else if exists { + return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Product category with code %s already exists", code)) + } + + createBody := &entity.ProductCategory{ + Name: name, + Code: code, + CreatedBy: 1, + } + + if err := s.Repository.CreateOne(ctx, createBody, nil); err != nil { + s.Log.Errorf("Failed to create product category: %+v", err) + return nil, err + } + + return s.GetOne(c, createBody.Id) +} + +func (s productCategoryService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.ProductCategory, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + updateBody := make(map[string]any) + + if req.Name != nil { + name := strings.TrimSpace(*req.Name) + if name == "" { + return nil, fiber.NewError(fiber.StatusBadRequest, "Name cannot be empty") + } + if exists, err := s.Repository.NameExists(c.Context(), name, &id); err != nil { + s.Log.Errorf("Failed to check product category name: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check product category name") + } else if exists { + return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Product category with name %s already exists", name)) + } + updateBody["name"] = name + } + + if req.Code != nil { + code := strings.ToUpper(strings.TrimSpace(*req.Code)) + if code == "" { + return nil, fiber.NewError(fiber.StatusBadRequest, "Code cannot be empty") + } + if exists, err := s.Repository.CodeExists(c.Context(), code, &id); err != nil { + s.Log.Errorf("Failed to check product category code: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check product category code") + } else if exists { + return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Product category with code %s already exists", code)) + } + updateBody["code"] = code + } + + 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, "Product category not found") + } + s.Log.Errorf("Failed to update product category: %+v", err) + return nil, err + } + + return s.GetOne(c, id) +} + +func (s productCategoryService) 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, "Product category not found") + } + s.Log.Errorf("Failed to delete product category: %+v", err) + return err + } + return nil +} diff --git a/internal/modules/master/product-categories/validations/product-category.validation.go b/internal/modules/master/product-categories/validations/product-category.validation.go new file mode 100644 index 00000000..7a7d6e40 --- /dev/null +++ b/internal/modules/master/product-categories/validations/product-category.validation.go @@ -0,0 +1,17 @@ +package validation + +type Create struct { + Name string `json:"name" validate:"required_strict,min=3"` + Code string `json:"code" validate:"required_strict,max=10"` +} + +type Update struct { + Name *string `json:"name,omitempty" validate:"omitempty"` + Code *string `json:"code,omitempty" validate:"omitempty,max=10"` +} + +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"` +} diff --git a/internal/modules/master/products/controllers/product.controller.go b/internal/modules/master/products/controllers/product.controller.go new file mode 100644 index 00000000..2406cd9c --- /dev/null +++ b/internal/modules/master/products/controllers/product.controller.go @@ -0,0 +1,140 @@ +package controller + +import ( + "math" + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type ProductController struct { + ProductService service.ProductService +} + +func NewProductController(productService service.ProductService) *ProductController { + return &ProductController{ + ProductService: productService, + } +} + +func (u *ProductController) 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.ProductService.GetAll(c, query) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.ProductListDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get all products successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: dto.ToProductListDTOs(result), + }) +} + +func (u *ProductController) 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.ProductService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get product successfully", + Data: dto.ToProductDetailDTO(*result), + }) +} + +func (u *ProductController) 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.ProductService.CreateOne(c, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Create product successfully", + Data: dto.ToProductDetailDTO(*result), + }) +} + +func (u *ProductController) 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.ProductService.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 product successfully", + Data: dto.ToProductDetailDTO(*result), + }) +} + +func (u *ProductController) 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.ProductService.DeleteOne(c, uint(id)); err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Common{ + Code: fiber.StatusOK, + Status: "success", + Message: "Delete product successfully", + }) +} diff --git a/internal/modules/master/products/dto/product.dto.go b/internal/modules/master/products/dto/product.dto.go new file mode 100644 index 00000000..b7f21008 --- /dev/null +++ b/internal/modules/master/products/dto/product.dto.go @@ -0,0 +1,116 @@ +package dto + +import ( + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + productCategoryDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/product-categories/dto" + supplierDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/dto" + uomDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/uoms/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"` +} + +type ProductListDTO struct { + ProductBaseDTO + Brand string `json:"brand"` + Sku *string `json:"sku,omitempty"` + ProductPrice float64 `json:"product_price"` + SellingPrice *float64 `json:"selling_price,omitempty"` + Tax *float64 `json:"tax,omitempty"` + ExpiryPeriod *int `json:"expiry_period,omitempty"` + Uom *uomDTO.UomBaseDTO `json:"uom,omitempty"` + ProductCategory *productCategoryDTO.ProductCategoryBaseDTO `json:"product_category,omitempty"` + Suppliers []supplierDTO.SupplierBaseDTO `json:"suppliers"` + Flags []string `json:"flags"` + CreatedUser *userDTO.UserBaseDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ProductDetailDTO struct { + ProductListDTO + Flags []string `json:"flags"` +} + +// === Mapper Functions === + +func ToProductBaseDTO(e entity.Product) ProductBaseDTO { + return ProductBaseDTO{ + Id: e.Id, + Name: e.Name, + } +} + +func ToProductListDTO(e entity.Product) ProductListDTO { + var createdUser *userDTO.UserBaseDTO + if e.CreatedUser.Id != 0 { + mapped := userDTO.ToUserBaseDTO(e.CreatedUser) + createdUser = &mapped + } + + var uomRef *uomDTO.UomBaseDTO + if e.Uom.Id != 0 { + mapped := uomDTO.ToUomBaseDTO(e.Uom) + uomRef = &mapped + } + + var categoryRef *productCategoryDTO.ProductCategoryBaseDTO + if e.ProductCategory.Id != 0 { + mapped := productCategoryDTO.ToProductCategoryBaseDTO(e.ProductCategory) + categoryRef = &mapped + } + + suppliers := make([]supplierDTO.SupplierBaseDTO, len(e.Suppliers)) + for i, s := range e.Suppliers { + suppliers[i] = supplierDTO.ToSupplierBaseDTO(s) + } + + flags := make([]string, len(e.Flags)) + for i, f := range e.Flags { + flags[i] = f.Name + } + + return ProductListDTO{ + Brand: e.Brand, + Sku: e.Sku, + ProductPrice: e.ProductPrice, + SellingPrice: e.SellingPrice, + Tax: e.Tax, + ExpiryPeriod: e.ExpiryPeriod, + ProductBaseDTO: ToProductBaseDTO(e), + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + CreatedUser: createdUser, + Uom: uomRef, + ProductCategory: categoryRef, + Suppliers: suppliers, + Flags: flags, + } +} + +func ToProductListDTOs(e []entity.Product) []ProductListDTO { + result := make([]ProductListDTO, len(e)) + for i, r := range e { + result[i] = ToProductListDTO(r) + } + return result +} + +func ToProductDetailDTO(e entity.Product) ProductDetailDTO { + flags := make([]string, len(e.Flags)) + for i, f := range e.Flags { + flags[i] = f.Name + } + + return ProductDetailDTO{ + ProductListDTO: ToProductListDTO(e), + Flags: flags, + } +} diff --git a/internal/modules/master/products/module.go b/internal/modules/master/products/module.go new file mode 100644 index 00000000..87c6fb46 --- /dev/null +++ b/internal/modules/master/products/module.go @@ -0,0 +1,26 @@ +package products + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + rProduct "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories" + sProduct "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/services" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type ProductModule struct{} + +func (ProductModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + productRepo := rProduct.NewProductRepository(db) + userRepo := rUser.NewUserRepository(db) + + productService := sProduct.NewProductService(productRepo, validate) + userService := sUser.NewUserService(userRepo, validate) + + ProductRoutes(router, userService, productService) +} + diff --git a/internal/modules/master/products/repositories/product.repository.go b/internal/modules/master/products/repositories/product.repository.go new file mode 100644 index 00000000..283b8547 --- /dev/null +++ b/internal/modules/master/products/repositories/product.repository.go @@ -0,0 +1,196 @@ +package repository + +import ( + "context" + + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type ProductRepository interface { + repository.BaseRepository[entity.Product] + 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) + 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 + SyncFlags(ctx context.Context, tx *gorm.DB, productID uint, flags []string) error + DeleteFlags(ctx context.Context, tx *gorm.DB, productID uint) error + GetFlags(ctx context.Context, productID uint) ([]entity.Flag, error) +} + +type ProductRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.Product] +} + +func NewProductRepository(db *gorm.DB) ProductRepository { + return &ProductRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.Product](db), + } +} + +func (r *ProductRepositoryImpl) NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) { + return repository.ExistsByName[entity.Product](ctx, r.DB(), name, excludeID) +} + +func (r *ProductRepositoryImpl) SkuExists(ctx context.Context, sku string, excludeID *uint) (bool, error) { + var count int64 + q := r.DB().WithContext(ctx). + Model(new(entity.Product)). + Where("sku = ?", sku). + Where("deleted_at IS NULL") + if excludeID != nil { + q = q.Where("id <> ?", *excludeID) + } + if err := q.Count(&count).Error; err != nil { + return false, err + } + return count > 0, nil +} + +func (r *ProductRepositoryImpl) UomExists(ctx context.Context, uomID uint) (bool, error) { + var count int64 + if err := r.DB().WithContext(ctx). + Model(&entity.Uom{}). + Where("id = ?", uomID). + Count(&count). + Error; err != nil { + return false, err + } + return count > 0, nil +} + +func (r *ProductRepositoryImpl) CategoryExists(ctx context.Context, categoryID uint) (bool, error) { + var count int64 + if err := r.DB().WithContext(ctx). + Model(&entity.ProductCategory{}). + Where("id = ?", categoryID). + Count(&count). + Error; err != nil { + return false, err + } + return count > 0, nil +} + +func (r *ProductRepositoryImpl) GetSuppliersByIDs(ctx context.Context, supplierIDs []uint) ([]entity.Supplier, error) { + if len(supplierIDs) == 0 { + return nil, nil + } + var suppliers []entity.Supplier + if err := r.DB().WithContext(ctx). + Select("id", "category"). + Where("id IN ?", supplierIDs). + Find(&suppliers). + Error; err != nil { + return nil, err + } + return suppliers, nil +} + +func (r *ProductRepositoryImpl) SyncSuppliersDiff(ctx context.Context, tx *gorm.DB, productID uint, supplierIDs []uint) error { + db := tx + if db == nil { + db = r.DB() + } + + if supplierIDs == nil { + return db.WithContext(ctx). + Where("product_id = ?", productID). + Delete(&entity.ProductSupplier{}). + Error + } + + var existing []entity.ProductSupplier + if err := db.WithContext(ctx). + Where("product_id = ?", productID). + Find(&existing). + Error; err != nil { + return err + } + + existingMap := make(map[uint]struct{}, len(existing)) + for _, rel := range existing { + existingMap[rel.SupplierID] = struct{}{} + } + + incomingMap := make(map[uint]struct{}, len(supplierIDs)) + for _, id := range supplierIDs { + incomingMap[id] = struct{}{} + if _, exists := existingMap[id]; exists { + continue + } + record := entity.ProductSupplier{ProductID: productID, SupplierID: id} + if err := db.WithContext(ctx).Create(&record).Error; err != nil { + return err + } + } + + for _, rel := range existing { + if _, keep := incomingMap[rel.SupplierID]; !keep { + if err := db.WithContext(ctx). + Where("product_id = ? AND supplier_id = ?", productID, rel.SupplierID). + Delete(&entity.ProductSupplier{}). + Error; err != nil { + return err + } + } + } + + return nil +} + +func (r *ProductRepositoryImpl) SyncFlags(ctx context.Context, tx *gorm.DB, productID uint, flags []string) error { + db := tx + if db == nil { + db = r.DB() + } + + // Hapus flags lama terlebih dahulu + if err := db.WithContext(ctx). + Where("flagable_id = ? AND flagable_type = ?", productID, entity.FlagableTypeProduct). + Delete(&entity.Flag{}). + Error; err != nil { + return err + } + + // Insert flags baru jika ada + if len(flags) > 0 { + newFlags := make([]entity.Flag, len(flags)) + for i, f := range flags { + newFlags[i] = entity.Flag{ + Name: f, + FlagableID: productID, + FlagableType: entity.FlagableTypeProduct, + } + } + if err := db.WithContext(ctx).Create(&newFlags).Error; err != nil { + return err + } + } + + return nil +} + +func (r *ProductRepositoryImpl) DeleteFlags(ctx context.Context, tx *gorm.DB, productID uint) error { + db := tx + if db == nil { + db = r.DB() + } + return db.WithContext(ctx). + Where("flagable_id = ? AND flagable_type = ?", productID, entity.FlagableTypeProduct). + Delete(&entity.Flag{}). + Error +} + +func (r *ProductRepositoryImpl) GetFlags(ctx context.Context, productID uint) ([]entity.Flag, error) { + var flags []entity.Flag + if err := r.DB().WithContext(ctx). + Where("flagable_id = ? AND flagable_type = ?", productID, entity.FlagableTypeProduct). + Find(&flags). + Error; err != nil { + return nil, err + } + return flags, nil +} diff --git a/internal/modules/master/products/route.go b/internal/modules/master/products/route.go new file mode 100644 index 00000000..ffa75dfa --- /dev/null +++ b/internal/modules/master/products/route.go @@ -0,0 +1,28 @@ +package products + +import ( + // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/controllers" + product "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func ProductRoutes(v1 fiber.Router, u user.UserService, s product.ProductService) { + ctrl := controller.NewProductController(s) + + route := v1.Group("/products") + + // 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) + route.Patch("/:id", ctrl.UpdateOne) + route.Delete("/:id", ctrl.DeleteOne) +} diff --git a/internal/modules/master/products/services/product.service.go b/internal/modules/master/products/services/product.service.go new file mode 100644 index 00000000..076cabf9 --- /dev/null +++ b/internal/modules/master/products/services/product.service.go @@ -0,0 +1,384 @@ +package service + +import ( + "errors" + "fmt" + "strings" + + common "gitlab.com/mbugroup/lti-api.git/internal/common/service" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/validations" + "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 ProductService interface { + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.Product, int64, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.Product, error) + CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.Product, error) + UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Product, error) + DeleteOne(ctx *fiber.Ctx, id uint) error +} + +type productService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.ProductRepository +} + +func normalizeProductFlags(raw []string) ([]string, error) { + normalized, invalid := utils.NormalizeFlagsForGroup(raw, utils.FlagGroupProduct) + if len(invalid) > 0 { + invalidStr := strings.Join(utils.FlagTypesToStrings(invalid), ", ") + allowedStr := strings.Join(utils.FlagTypesToStrings(utils.AllowedFlagTypes(utils.FlagGroupProduct)), ", ") + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid product flags: %s. Allowed flags: %s", invalidStr, allowedStr)) + } + return utils.FlagTypesToStrings(normalized), nil +} + +func NewProductService(repo repository.ProductRepository, validate *validator.Validate) ProductService { + return &productService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + } +} + +func (s productService) withRelations(db *gorm.DB) *gorm.DB { + return db. + Preload("CreatedUser"). + Preload("Uom"). + Preload("ProductCategory"). + Preload("Flags"). + Preload("Suppliers", func(db *gorm.DB) *gorm.DB { + return db.Order("suppliers.name ASC") + }) +} + +func (s productService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Product, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + offset := (params.Page - 1) * params.Limit + + products, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { + db = s.withRelations(db) + if params.Search != "" { + return db.Where("name LIKE ?", "%"+params.Search+"%") + } + return db.Order("created_at DESC").Order("updated_at DESC") + }) + + if err != nil { + s.Log.Errorf("Failed to get products: %+v", err) + return nil, 0, err + } + return products, total, nil +} + +func (s productService) GetOne(c *fiber.Ctx, id uint) (*entity.Product, error) { + product, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Product not found") + } + if err != nil { + s.Log.Errorf("Failed get product by id: %+v", err) + return nil, err + } + return product, nil +} + +func (s *productService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Product, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + ctx := c.Context() + name := strings.TrimSpace(req.Name) + brand := strings.TrimSpace(req.Brand) + var sku *string + if req.Sku != nil { + trimmed := strings.ToUpper(strings.TrimSpace(*req.Sku)) + if trimmed != "" { + sku = &trimmed + } + } + + if exists, err := s.Repository.NameExists(ctx, name, nil); err != nil { + s.Log.Errorf("Failed to check product name: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check product name") + } else if exists { + return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Product with name %s already exists", name)) + } + + if sku != nil { + if exists, err := s.Repository.SkuExists(ctx, *sku, nil); err != nil { + s.Log.Errorf("Failed to check product sku: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check product sku") + } else if exists { + return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Product with sku %s already exists", *sku)) + } + } + + if err := common.EnsureRelations(ctx, + common.RelationCheck{Name: "Uom", ID: &req.UomID, Exists: s.Repository.UomExists}, + common.RelationCheck{Name: "Product category", ID: &req.ProductCategoryID, Exists: s.Repository.CategoryExists}, + ); err != nil { + return nil, err + } + + supplierIDs := utils.UniqueUintSlice(req.SupplierIDs) + var err error + if len(supplierIDs) > 0 { + suppliers, err := s.Repository.GetSuppliersByIDs(ctx, supplierIDs) + if err != nil { + s.Log.Errorf("Failed to validate suppliers: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate suppliers") + } + if len(suppliers) != len(supplierIDs) { + actual := make([]uint, len(suppliers)) + for i, supplier := range suppliers { + actual[i] = supplier.Id + } + missing := utils.MissingUintIDs(supplierIDs, actual) + return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Suppliers with ids %v not found", missing)) + } + for _, sup := range suppliers { + if strings.ToUpper(sup.Category) != string(utils.SupplierCategorySapronak) { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Supplier with id %d is not category SAPRONAK", sup.Id)) + } + } + } + + productFlags, flagErr := normalizeProductFlags(req.Flags) + if flagErr != nil { + return nil, flagErr + } + + createBody := &entity.Product{ + Name: name, + Brand: brand, + Sku: sku, + UomId: req.UomID, + ProductCategoryId: req.ProductCategoryID, + ProductPrice: req.ProductPrice, + SellingPrice: req.SellingPrice, + Tax: req.Tax, + ExpiryPeriod: req.ExpiryPeriod, + CreatedBy: 1, + } + + err = s.Repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + repoTx := s.Repository.WithTx(tx) + + if err := repoTx.CreateOne(ctx, createBody, nil); err != nil { + return err + } + + if err := s.Repository.SyncFlags(ctx, tx, createBody.Id, productFlags); err != nil { + return err + } + + return s.Repository.SyncSuppliersDiff(ctx, tx, createBody.Id, supplierIDs) + }) + + if err != nil { + s.Log.Errorf("Failed to create product: %+v", err) + return nil, err + } + + return s.GetOne(c, createBody.Id) +} + +func (s productService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Product, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + updateBody := make(map[string]any) + + if req.Name != nil { + name := strings.TrimSpace(*req.Name) + if name == "" { + return nil, fiber.NewError(fiber.StatusBadRequest, "Name cannot be empty") + } + if exists, err := s.Repository.NameExists(c.Context(), name, &id); err != nil { + s.Log.Errorf("Failed to check product name: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check product name") + } else if exists { + return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Product with name %s already exists", name)) + } + updateBody["name"] = name + } + + if req.Brand != nil { + brand := strings.TrimSpace(*req.Brand) + if brand == "" { + return nil, fiber.NewError(fiber.StatusBadRequest, "Brand cannot be empty") + } + updateBody["brand"] = brand + } + + if req.Sku != nil { + sku := strings.ToUpper(strings.TrimSpace(*req.Sku)) + if sku == "" { + updateBody["sku"] = nil + } else { + if exists, err := s.Repository.SkuExists(c.Context(), sku, &id); err != nil { + s.Log.Errorf("Failed to check product sku: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check product sku") + } else if exists { + return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Product with sku %s already exists", sku)) + } + updateBody["sku"] = sku + } + } + + if err := common.EnsureRelations(c.Context(), + common.RelationCheck{Name: "Uom", ID: req.UomID, Exists: s.Repository.UomExists}, + common.RelationCheck{Name: "Product category", ID: req.ProductCategoryID, Exists: s.Repository.CategoryExists}, + ); err != nil { + return nil, err + } + + if req.UomID != nil { + updateBody["uom_id"] = *req.UomID + } + + if req.ProductCategoryID != nil { + updateBody["product_category_id"] = *req.ProductCategoryID + } + + if req.ProductPrice != nil { + updateBody["product_price"] = *req.ProductPrice + } + if req.SellingPrice != nil { + updateBody["selling_price"] = req.SellingPrice + } + if req.Tax != nil { + updateBody["tax"] = req.Tax + } + if req.ExpiryPeriod != nil { + updateBody["expiry_period"] = req.ExpiryPeriod + } + + ctx := c.Context() + + var suppliers []entity.Supplier + var supplierIDs []uint + var supplierUpdate bool + if req.SupplierIDs != nil { + supplierUpdate = true + supplierIDs = utils.UniqueUintSlice(*req.SupplierIDs) + if len(supplierIDs) > 0 { + var err error + suppliers, err = s.Repository.GetSuppliersByIDs(ctx, supplierIDs) + if err != nil { + s.Log.Errorf("Failed to validate suppliers: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate suppliers") + } + if len(suppliers) != len(supplierIDs) { + actual := make([]uint, len(suppliers)) + for i, supplier := range suppliers { + actual[i] = supplier.Id + } + missing := utils.MissingUintIDs(supplierIDs, actual) + return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Suppliers with ids %v not found", missing)) + } + for _, sup := range suppliers { + if strings.ToUpper(sup.Category) != string(utils.SupplierCategorySapronak) { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Supplier with id %d is not category SAPRONAK", sup.Id)) + } + } + } + } + + var ( + flagUpdate bool + flagValues []string + ) + if req.Flags != nil { + flagUpdate = true + var flagErr error + flagValues, flagErr = normalizeProductFlags(*req.Flags) + if flagErr != nil { + return nil, flagErr + } + } + + if len(updateBody) == 0 && !supplierUpdate && !flagUpdate { + return s.GetOne(c, id) + } + + err := s.Repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + repoTx := s.Repository.WithTx(tx) + + if len(updateBody) > 0 { + if err := repoTx.PatchOne(ctx, id, updateBody, nil); err != nil { + return err + } + } else { + if _, err := repoTx.GetByID(ctx, id, nil); err != nil { + return err + } + } + + if supplierUpdate { + var ids []uint + if len(supplierIDs) > 0 { + ids = supplierIDs + } + if err := s.Repository.SyncSuppliersDiff(ctx, tx, id, ids); err != nil { + return err + } + } + + if flagUpdate { + if err := s.Repository.SyncFlags(ctx, tx, id, flagValues); err != nil { + return err + } + } + + return nil + }) + + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Product not found") + } + s.Log.Errorf("Failed to update product: %+v", err) + return nil, err + } + return s.GetOne(c, id) +} + +func (s productService) DeleteOne(c *fiber.Ctx, id uint) error { + err := s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { + repoTx := s.Repository.WithTx(tx) + + if err := s.Repository.SyncSuppliersDiff(c.Context(), tx, id, nil); err != nil { + return err + } + + if err := s.Repository.DeleteFlags(c.Context(), tx, id); err != nil { + return err + } + + return repoTx.DeleteOne(c.Context(), id) + }) + + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Product not found") + } + s.Log.Errorf("Failed to delete product: %+v", err) + return err + } + return nil +} diff --git a/internal/modules/master/products/validations/product.validation.go b/internal/modules/master/products/validations/product.validation.go new file mode 100644 index 00000000..07f4c005 --- /dev/null +++ b/internal/modules/master/products/validations/product.validation.go @@ -0,0 +1,35 @@ +package validation + +type Create struct { + Name string `json:"name" validate:"required_strict,min=3"` + Brand string `json:"brand" validate:"required_strict,min=2"` + Sku *string `json:"sku,omitempty" validate:"omitempty"` + UomID uint `json:"uom_id" validate:"required,gt=0"` + ProductCategoryID uint `json:"product_category_id" validate:"required,gt=0"` + ProductPrice float64 `json:"product_price" validate:"required"` + SellingPrice *float64 `json:"selling_price,omitempty" validate:"omitempty"` + Tax *float64 `json:"tax,omitempty" validate:"omitempty"` + ExpiryPeriod *int `json:"expiry_period,omitempty" validate:"omitempty,gt=0"` + SupplierIDs []uint `json:"supplier_ids,omitempty" validate:"omitempty,dive,gt=0"` + Flags []string `json:"flags,omitempty" validate:"omitempty,dive"` +} + +type Update struct { + Name *string `json:"name,omitempty" validate:"omitempty,min=3"` + Brand *string `json:"brand,omitempty" validate:"omitempty,min=2"` + Sku *string `json:"sku,omitempty" validate:"omitempty"` + UomID *uint `json:"uom_id,omitempty" validate:"omitempty,gt=0"` + ProductCategoryID *uint `json:"product_category_id,omitempty" validate:"omitempty,gt=0"` + ProductPrice *float64 `json:"product_price,omitempty" validate:"omitempty"` + SellingPrice *float64 `json:"selling_price,omitempty" validate:"omitempty"` + Tax *float64 `json:"tax,omitempty" validate:"omitempty"` + ExpiryPeriod *int `json:"expiry_period,omitempty" validate:"omitempty,gt=0"` + SupplierIDs *[]uint `json:"supplier_ids,omitempty" validate:"omitempty,dive,gt=0"` + Flags *[]string `json:"flags,omitempty" validate:"omitempty,dive"` +} + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1"` + Limit int `query:"limit" validate:"omitempty,number,min=1"` + Search string `query:"search" validate:"omitempty,max=50"` +} diff --git a/internal/modules/master/route.go b/internal/modules/master/route.go index 28ae3794..6c98db4f 100644 --- a/internal/modules/master/route.go +++ b/internal/modules/master/route.go @@ -9,10 +9,16 @@ import ( areas "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas" customers "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers" + fcrs "gitlab.com/mbugroup/lti-api.git/internal/modules/master/fcrs" kandangs "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs" locations "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations" + nonstocks "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks" + productcategories "gitlab.com/mbugroup/lti-api.git/internal/modules/master/product-categories" + suppliers "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers" uoms "gitlab.com/mbugroup/lti-api.git/internal/modules/master/uoms" warehouses "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses" + products "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products" + banks "gitlab.com/mbugroup/lti-api.git/internal/modules/master/banks" // MODULE IMPORTS ) @@ -26,6 +32,12 @@ func RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Valida kandangs.KandangModule{}, warehouses.WarehouseModule{}, customers.CustomerModule{}, + suppliers.SupplierModule{}, + fcrs.FcrModule{}, + nonstocks.NonstockModule{}, + productcategories.ProductCategoryModule{}, + products.ProductModule{}, + banks.BankModule{}, // MODULE REGISTRY } diff --git a/internal/modules/master/suppliers/controllers/supplier.controller.go b/internal/modules/master/suppliers/controllers/supplier.controller.go new file mode 100644 index 00000000..a76904a9 --- /dev/null +++ b/internal/modules/master/suppliers/controllers/supplier.controller.go @@ -0,0 +1,140 @@ +package controller + +import ( + "math" + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type SupplierController struct { + SupplierService service.SupplierService +} + +func NewSupplierController(supplierService service.SupplierService) *SupplierController { + return &SupplierController{ + SupplierService: supplierService, + } +} + +func (u *SupplierController) 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.SupplierService.GetAll(c, query) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.SupplierListDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get all suppliers successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: dto.ToSupplierListDTOs(result), + }) +} + +func (u *SupplierController) 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.SupplierService.GetOne(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get supplier successfully", + Data: dto.ToSupplierListDTO(*result), + }) +} + +func (u *SupplierController) 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.SupplierService.CreateOne(c, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Create supplier successfully", + Data: dto.ToSupplierListDTO(*result), + }) +} + +func (u *SupplierController) 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.SupplierService.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 supplier successfully", + Data: dto.ToSupplierListDTO(*result), + }) +} + +func (u *SupplierController) 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.SupplierService.DeleteOne(c, uint(id)); err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Common{ + Code: fiber.StatusOK, + Status: "success", + Message: "Delete supplier successfully", + }) +} diff --git a/internal/modules/master/suppliers/dto/supplier.dto.go b/internal/modules/master/suppliers/dto/supplier.dto.go new file mode 100644 index 00000000..7e0df680 --- /dev/null +++ b/internal/modules/master/suppliers/dto/supplier.dto.go @@ -0,0 +1,88 @@ +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 SupplierBaseDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + Alias string `json:"alias"` + Category string `json:"category"` +} + +type SupplierListDTO struct { + SupplierBaseDTO + Pic string `json:"pic"` + Type string `json:"type"` + Hatchery *string `json:"hatchery,omitempty"` + Phone string `json:"phone"` + Email string `json:"email"` + Address string `json:"address"` + Npwp *string `json:"npwp,omitempty"` + AccountNumber *string `json:"account_number,omitempty"` + Balance float64 `json:"balance"` + DueDate int `json:"due_date"` + CreatedUser *userDTO.UserBaseDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type SupplierDetailDTO struct { + SupplierListDTO +} + +// === Mapper Functions === + +func ToSupplierBaseDTO(e entity.Supplier) SupplierBaseDTO { + return SupplierBaseDTO{ + Id: e.Id, + Name: e.Name, + Alias: e.Alias, + Category: e.Category, + } +} + +func ToSupplierListDTO(e entity.Supplier) SupplierListDTO { + var createdUser *userDTO.UserBaseDTO + if e.CreatedUser.Id != 0 { + mapped := userDTO.ToUserBaseDTO(e.CreatedUser) + createdUser = &mapped + } + + return SupplierListDTO{ + Pic: e.Pic, + Type: e.Type, + Hatchery: e.Hatchery, + Phone: e.Phone, + Email: e.Email, + Address: e.Address, + Npwp: e.Npwp, + AccountNumber: e.AccountNumber, + Balance: e.Balance, + DueDate: e.DueDate, + SupplierBaseDTO: ToSupplierBaseDTO(e), + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + CreatedUser: createdUser, + } +} + +func ToSupplierListDTOs(e []entity.Supplier) []SupplierListDTO { + result := make([]SupplierListDTO, len(e)) + for i, r := range e { + result[i] = ToSupplierListDTO(r) + } + return result +} + +func ToSupplierDetailDTO(e entity.Supplier) SupplierDetailDTO { + return SupplierDetailDTO{ + SupplierListDTO: ToSupplierListDTO(e), + } +} diff --git a/internal/modules/master/suppliers/module.go b/internal/modules/master/suppliers/module.go new file mode 100644 index 00000000..f4619a0d --- /dev/null +++ b/internal/modules/master/suppliers/module.go @@ -0,0 +1,26 @@ +package suppliers + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + rSupplier "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/repositories" + sSupplier "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/services" + + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type SupplierModule struct{} + +func (SupplierModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + supplierRepo := rSupplier.NewSupplierRepository(db) + userRepo := rUser.NewUserRepository(db) + + supplierService := sSupplier.NewSupplierService(supplierRepo, validate) + userService := sUser.NewUserService(userRepo, validate) + + SupplierRoutes(router, userService, supplierService) +} + diff --git a/internal/modules/master/suppliers/repositories/supplier.repository.go b/internal/modules/master/suppliers/repositories/supplier.repository.go new file mode 100644 index 00000000..ea4e43bf --- /dev/null +++ b/internal/modules/master/suppliers/repositories/supplier.repository.go @@ -0,0 +1,30 @@ +package repository + +import ( + "context" + + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type SupplierRepository interface { + repository.BaseRepository[entity.Supplier] + NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) +} + +type SupplierRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.Supplier] + db *gorm.DB +} + +func NewSupplierRepository(db *gorm.DB) SupplierRepository { + return &SupplierRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.Supplier](db), + db: db, + } +} + +func (r *SupplierRepositoryImpl) NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) { + return repository.ExistsByName[entity.Supplier](ctx, r.db, name, excludeID) +} diff --git a/internal/modules/master/suppliers/route.go b/internal/modules/master/suppliers/route.go new file mode 100644 index 00000000..b176c40c --- /dev/null +++ b/internal/modules/master/suppliers/route.go @@ -0,0 +1,28 @@ +package suppliers + +import ( + // m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/controllers" + supplier "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func SupplierRoutes(v1 fiber.Router, u user.UserService, s supplier.SupplierService) { + ctrl := controller.NewSupplierController(s) + + route := v1.Group("/suppliers") + + // 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) + route.Patch("/:id", ctrl.UpdateOne) + route.Delete("/:id", ctrl.DeleteOne) +} diff --git a/internal/modules/master/suppliers/services/supplier.service.go b/internal/modules/master/suppliers/services/supplier.service.go new file mode 100644 index 00000000..f8422350 --- /dev/null +++ b/internal/modules/master/suppliers/services/supplier.service.go @@ -0,0 +1,221 @@ +package service + +import ( + "errors" + "fmt" + "strings" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/validations" + "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 SupplierService interface { + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.Supplier, int64, error) + GetOne(ctx *fiber.Ctx, id uint) (*entity.Supplier, error) + CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.Supplier, error) + UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Supplier, error) + DeleteOne(ctx *fiber.Ctx, id uint) error +} + +type supplierService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.SupplierRepository +} + +func NewSupplierService(repo repository.SupplierRepository, validate *validator.Validate) SupplierService { + return &supplierService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + } +} + +func (s supplierService) withRelations(db *gorm.DB) *gorm.DB { + return db.Preload("CreatedUser") +} + +func (s supplierService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Supplier, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + offset := (params.Page - 1) * params.Limit + + suppliers, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { + db = s.withRelations(db) + if params.Search != "" { + return db.Where("name LIKE ?", "%"+params.Search+"%") + } + return db.Order("created_at DESC").Order("updated_at DESC") + }) + + if err != nil { + s.Log.Errorf("Failed to get suppliers: %+v", err) + return nil, 0, err + } + return suppliers, total, nil +} + +func (s supplierService) GetOne(c *fiber.Ctx, id uint) (*entity.Supplier, error) { + supplier, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Supplier not found") + } + if err != nil { + s.Log.Errorf("Failed get supplier by id: %+v", err) + return nil, err + } + return supplier, nil +} + +func (s *supplierService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Supplier, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + if exists, err := s.Repository.NameExists(c.Context(), req.Name, nil); err != nil { + s.Log.Errorf("Failed to check supplier name: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check supplier name") + } else if exists { + return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Supplier with name %s already exists", req.Name)) + } + + typ := strings.ToUpper(req.Type) + if !utils.IsValidCustomerSupplierType(typ) { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid supplier type") + } + + category := strings.ToUpper(req.Category) + if !utils.IsValidSupplierCategory(category) { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid supplier category") + } + + alias := strings.TrimSpace(strings.ToUpper(req.Alias)) + + //TODO: created by dummy + createBody := &entity.Supplier{ + Name: req.Name, + Alias: alias, + Pic: req.Pic, + Type: typ, + Category: category, + Hatchery: req.Hatchery, + Phone: req.Phone, + Email: req.Email, + Address: req.Address, + Npwp: req.Npwp, + AccountNumber: req.AccountNumber, + DueDate: req.DueDate, + CreatedBy: 1, + } + + if err := s.Repository.CreateOne(c.Context(), createBody, nil); err != nil { + s.Log.Errorf("Failed to create supplier: %+v", err) + return nil, err + } + + return s.GetOne(c, createBody.Id) +} + +func (s supplierService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Supplier, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + updateBody := make(map[string]any) + + if req.Name != nil { + if exists, err := s.Repository.NameExists(c.Context(), *req.Name, &id); err != nil { + s.Log.Errorf("Failed to check supplier name: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check supplier name") + } else if exists { + return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Supplier with name %s already exists", *req.Name)) + } + updateBody["name"] = *req.Name + } + + if req.Alias != nil { + updateBody["alias"] = strings.TrimSpace(strings.ToUpper(*req.Alias)) + } + + if req.Pic != nil { + updateBody["pic"] = *req.Pic + } + + if req.Type != nil { + typ := strings.ToUpper(*req.Type) + if !utils.IsValidCustomerSupplierType(typ) { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid supplier type") + } + updateBody["type"] = typ + } + + if req.Category != nil { + category := strings.ToUpper(*req.Category) + if !utils.IsValidSupplierCategory(category) { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid supplier category") + } + updateBody["category"] = category + } + + if req.Hatchery != nil { + updateBody["hatchery"] = *req.Hatchery + } + + if req.Phone != nil { + updateBody["phone"] = *req.Phone + } + + if req.Email != nil { + updateBody["email"] = *req.Email + } + + if req.Address != nil { + updateBody["address"] = *req.Address + } + + if req.Npwp != nil { + updateBody["npwp"] = *req.Npwp + } + + if req.AccountNumber != nil { + updateBody["account_number"] = *req.AccountNumber + } + + if req.DueDate != nil { + updateBody["due_date"] = *req.DueDate + } + + 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, "Supplier not found") + } + s.Log.Errorf("Failed to update supplier: %+v", err) + return nil, err + } + + return s.GetOne(c, id) +} + +func (s supplierService) 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, "Supplier not found") + } + s.Log.Errorf("Failed to delete supplier: %+v", err) + return err + } + return nil +} diff --git a/internal/modules/master/suppliers/validations/supplier.validation.go b/internal/modules/master/suppliers/validations/supplier.validation.go new file mode 100644 index 00000000..7eeff716 --- /dev/null +++ b/internal/modules/master/suppliers/validations/supplier.validation.go @@ -0,0 +1,37 @@ +package validation + +type Create struct { + Name string `json:"name" validate:"required_strict,min=3"` + Alias string `json:"alias" validate:"required_strict,max=5"` + Pic string `json:"pic" validate:"required_strict"` + Type string `json:"type" validate:"required_strict"` + Category string `json:"category" validate:"required_strict"` + Hatchery *string `json:"hatchery,omitempty" validate:"omitempty"` + Phone string `json:"phone" validate:"required_strict,max=20"` + Email string `json:"email" validate:"required_strict,email"` + Address string `json:"address" validate:"required_strict"` + Npwp *string `json:"npwp,omitempty" validate:"omitempty,max=50"` + AccountNumber *string `json:"account_number,omitempty" validate:"omitempty,max=50"` + DueDate int `json:"due_date" validate:"required_strict,number,gt=0"` +} + +type Update struct { + Name *string `json:"name,omitempty" validate:"omitempty,min=3"` + Alias *string `json:"alias,omitempty" validate:"omitempty,max=5"` + Pic *string `json:"pic,omitempty" validate:"omitempty"` + Type *string `json:"type,omitempty" validate:"omitempty"` + Category *string `json:"category,omitempty" validate:"omitempty"` + Hatchery *string `json:"hatchery,omitempty" validate:"omitempty"` + Phone *string `json:"phone,omitempty" validate:"omitempty,max=20"` + Email *string `json:"email,omitempty" validate:"omitempty,email"` + Address *string `json:"address,omitempty" validate:"omitempty"` + Npwp *string `json:"npwp,omitempty" validate:"omitempty,max=50"` + AccountNumber *string `json:"account_number,omitempty" validate:"omitempty,max=50"` + DueDate *int `json:"due_date,omitempty" validate:"omitempty,number,gt=0"` +} + +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"` +} diff --git a/internal/modules/master/uoms/dto/uom.dto.go b/internal/modules/master/uoms/dto/uom.dto.go index 726a426d..476309b2 100644 --- a/internal/modules/master/uoms/dto/uom.dto.go +++ b/internal/modules/master/uoms/dto/uom.dto.go @@ -56,3 +56,9 @@ func ToUomListDTOs(e []entity.Uom) []UomListDTO { } return result } + +func ToUomDetailDTO(e entity.Uom) UomDetailDTO { + return UomDetailDTO{ + UomListDTO: ToUomListDTO(e), + } +} diff --git a/internal/modules/master/uoms/services/uom.service.go b/internal/modules/master/uoms/services/uom.service.go index 931740ee..b0888751 100644 --- a/internal/modules/master/uoms/services/uom.service.go +++ b/internal/modules/master/uoms/services/uom.service.go @@ -98,7 +98,7 @@ func (s *uomService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Uo return nil, err } - return s.Repository.GetByID(c.Context(), createBody.Id, s.withRelations) + return s.GetOne(c, createBody.Id) } func (s uomService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Uom, error) { diff --git a/internal/modules/master/uoms/validations/uom.validation.go b/internal/modules/master/uoms/validations/uom.validation.go index e423aa8c..56bbd601 100644 --- a/internal/modules/master/uoms/validations/uom.validation.go +++ b/internal/modules/master/uoms/validations/uom.validation.go @@ -1,11 +1,11 @@ package validation type Create struct { - Name string `json:"name" validate:"required_strict,min=3"` + Name string `json:"name" validate:"required_strict,min=3"` } type Update struct { - Name *string `json:"name,omitempty" validate:"omitempty,max=50"` + Name *string `json:"name,omitempty" validate:"omitempty"` } type Query struct { diff --git a/internal/modules/master/warehouses/dto/warehouse.dto.go b/internal/modules/master/warehouses/dto/warehouse.dto.go index c46a9ecb..b5432127 100644 --- a/internal/modules/master/warehouses/dto/warehouse.dto.go +++ b/internal/modules/master/warehouses/dto/warehouse.dto.go @@ -85,3 +85,9 @@ func ToWarehouseListDTOs(e []entity.Warehouse) []WarehouseListDTO { } return result } + +func ToWarehouseDetailDTO(e entity.Warehouse) WarehouseDetailDTO { + return WarehouseDetailDTO{ + WarehouseListDTO: ToWarehouseListDTO(e), + } +} diff --git a/internal/modules/master/warehouses/services/warehouse.service.go b/internal/modules/master/warehouses/services/warehouse.service.go index ea8626a9..3b45de5f 100644 --- a/internal/modules/master/warehouses/services/warehouse.service.go +++ b/internal/modules/master/warehouses/services/warehouse.service.go @@ -122,7 +122,7 @@ func (s *warehouseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent return nil, err } - return s.Repository.GetByID(c.Context(), createBody.Id, s.withRelations) + return s.GetOne(c, createBody.Id) } func (s warehouseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Warehouse, error) { @@ -152,22 +152,20 @@ func (s warehouseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin updateBody["type"] = normalizedType req.Type = &normalizedType } + if err := common.EnsureRelations(c.Context(), + common.RelationCheck{Name: "Area", ID: req.AreaId, Exists: s.Repository.AreaExists}, + common.RelationCheck{Name: "Location", ID: req.LocationId, Exists: s.Repository.LocationExists}, + common.RelationCheck{Name: "Kandang", ID: req.KandangId, Exists: s.Repository.KandangExists}, + ); err != nil { + return nil, err + } if req.AreaId != nil { - if err := common.EnsureRelations(c.Context(), common.RelationCheck{Name: "Area", ID: req.AreaId, Exists: s.Repository.AreaExists}); err != nil { - return nil, err - } updateBody["area_id"] = *req.AreaId } if req.LocationId != nil { - if err := common.EnsureRelations(c.Context(), common.RelationCheck{Name: "Location", ID: req.LocationId, Exists: s.Repository.LocationExists}); err != nil { - return nil, err - } updateBody["location_id"] = req.LocationId } if req.KandangId != nil { - if err := common.EnsureRelations(c.Context(), common.RelationCheck{Name: "Kandang", ID: req.KandangId, Exists: s.Repository.KandangExists}); err != nil { - return nil, err - } updateBody["kandang_id"] = req.KandangId } diff --git a/internal/modules/master/warehouses/validations/warehouse.validation.go b/internal/modules/master/warehouses/validations/warehouse.validation.go index d5513652..ab16c02a 100644 --- a/internal/modules/master/warehouses/validations/warehouse.validation.go +++ b/internal/modules/master/warehouses/validations/warehouse.validation.go @@ -9,7 +9,7 @@ type Create struct { } type Update struct { - Name *string `json:"name,omitempty" validate:"omitempty,max=50"` + Name *string `json:"name,omitempty" validate:"omitempty"` Type *string `json:"type,omitempty" validate:"omitempty"` AreaId *uint `json:"area_id,omitempty" validate:"omitempty,number,gt=0"` LocationId *uint `json:"location_id,omitempty" validate:"omitempty,number,gt=0"` diff --git a/internal/modules/users/validations/user.validation.go b/internal/modules/users/validations/user.validation.go index e423aa8c..56bbd601 100644 --- a/internal/modules/users/validations/user.validation.go +++ b/internal/modules/users/validations/user.validation.go @@ -1,11 +1,11 @@ package validation type Create struct { - Name string `json:"name" validate:"required_strict,min=3"` + Name string `json:"name" validate:"required_strict,min=3"` } type Update struct { - Name *string `json:"name,omitempty" validate:"omitempty,max=50"` + Name *string `json:"name,omitempty" validate:"omitempty"` } type Query struct { diff --git a/internal/route/route.go b/internal/route/route.go index 627be6e4..c4bfa4b0 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -2,14 +2,15 @@ package route import ( "gitlab.com/mbugroup/lti-api.git/internal/common/validation" - "gitlab.com/mbugroup/lti-api.git/internal/modules" trim "gitlab.com/mbugroup/lti-api.git/internal/middleware/trim" + "gitlab.com/mbugroup/lti-api.git/internal/modules" "github.com/gofiber/fiber/v2" "gorm.io/gorm" master "gitlab.com/mbugroup/lti-api.git/internal/modules/master" users "gitlab.com/mbugroup/lti-api.git/internal/modules/users" + constants "gitlab.com/mbugroup/lti-api.git/internal/modules/constants" // MODULE IMPORTS ) @@ -22,6 +23,7 @@ func Routes(app *fiber.App, db *gorm.DB) { allModules := []modules.Module{ users.UserModule{}, master.MasterModule{}, + constants.ConstantModule{}, // MODULE REGISTRY } diff --git a/internal/utils/constant.go b/internal/utils/constant.go index c8ee5fc8..941c8a5e 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -1,14 +1,68 @@ package utils +import "strings" + // ------------------------------------------------------------------- -// FlagType +// FlagType & Groups // ------------------------------------------------------------------- + type FlagType string +type FlagGroup string + const ( FlagIsActive FlagType = "IS_ACTIVE" + + FlagDOC FlagType = "DOC" + FlagPakan FlagType = "PAKAN" + FlagPreStarter FlagType = "PRE-STARTER" + FlagStarter FlagType = "STARTER" + FlagFinisher FlagType = "FINISHER" + FlagOVK FlagType = "OVK" + FlagObat FlagType = "OBAT" + FlagVitamin FlagType = "VITAMIN" + FlagKimia FlagType = "KIMIA" + FlagEkspedisi FlagType = "EKSPEDISI" ) +const ( + FlagGroupProduct FlagGroup = "PRODUCT" + FlagGroupNonstock FlagGroup = "NONSTOCK" +) + +var flagGroupOptions = map[FlagGroup][]FlagType{ + FlagGroupProduct: { + FlagDOC, + FlagPakan, + FlagPreStarter, + FlagStarter, + FlagFinisher, + FlagOVK, + FlagObat, + FlagVitamin, + FlagKimia, + }, + FlagGroupNonstock: { + FlagEkspedisi, + }, +} + +var allFlagTypes = func() map[FlagType]struct{} { + m := map[FlagType]struct{}{ + FlagIsActive: {}, + } + for _, flags := range flagGroupOptions { + for _, flag := range flags { + m[flag] = struct{}{} + } + } + return m +}() + +func AllFlagTypes() map[FlagType]struct{} { + return allFlagTypes +} + // ------------------------------------------------------------------- // WarehouseType // ------------------------------------------------------------------- @@ -32,16 +86,101 @@ const ( CustomerSupplierTypeIndividual CustomerSupplierType = "INDIVIDUAL" ) +// ------------------------------------------------------------------- +// SupplierCategory +// ------------------------------------------------------------------- + +type SupplierCategory string + +const ( + SupplierCategoryBOP SupplierCategory = "BOP" + SupplierCategorySapronak SupplierCategory = "SAPRONAK" +) + // ------------------------------------------------------------------- // Validators // ------------------------------------------------------------------- func IsValidFlagType(v string) bool { - switch FlagType(v) { - case FlagIsActive: - return true + _, ok := allFlagTypes[FlagType(strings.ToUpper(strings.TrimSpace(v)))] + return ok +} + +func AllowedFlagTypes(group FlagGroup) []FlagType { + options, ok := flagGroupOptions[group] + if !ok { + return nil } - return false + result := make([]FlagType, len(options)) + copy(result, options) + return result +} + +func InvalidFlagsForGroup(flags []FlagType, group FlagGroup) []FlagType { + if len(flags) == 0 { + return nil + } + options, ok := flagGroupOptions[group] + if !ok { + return flags + } + allowed := make(map[FlagType]struct{}, len(options)) + for _, flag := range options { + allowed[flag] = struct{}{} + } + invalid := make([]FlagType, 0) + for _, flag := range flags { + if _, ok := allowed[flag]; !ok { + invalid = append(invalid, flag) + } + } + return invalid +} + +func NormalizeFlagTypes(flags []string) []FlagType { + if len(flags) == 0 { + return nil + } + seen := make(map[FlagType]struct{}, len(flags)) + result := make([]FlagType, 0, len(flags)) + for _, flag := range flags { + normalized := FlagType(strings.ToUpper(strings.TrimSpace(flag))) + if normalized == "" { + continue + } + if _, exists := seen[normalized]; exists { + continue + } + seen[normalized] = struct{}{} + result = append(result, normalized) + } + if len(result) == 0 { + return nil + } + return result +} + +func FlagTypesToStrings(flags []FlagType) []string { + if len(flags) == 0 { + return nil + } + result := make([]string, len(flags)) + for i, flag := range flags { + result[i] = string(flag) + } + return result +} + +func NormalizeFlagsForGroup(raw []string, group FlagGroup) (normalized []FlagType, invalid []FlagType) { + normalized = NormalizeFlagTypes(raw) + if len(normalized) == 0 { + return nil, nil + } + invalid = InvalidFlagsForGroup(normalized, group) + if len(invalid) == 0 { + return normalized, nil + } + return normalized, invalid } func IsValidWarehouseType(v string) bool { @@ -60,10 +199,18 @@ func IsValidCustomerSupplierType(v string) bool { return false } +func IsValidSupplierCategory(v string) bool { + switch SupplierCategory(v) { + case SupplierCategoryBOP, SupplierCategorySapronak: + return true + } + return false +} + // example use /** if !utils.IsValidFlagType(req.FlagName) { - return fiber.NewError(fiber.StatusBadRequest, "invalid flag type") + return fiber.NewError(fiber.StatusBadRequest, "Invalid flag type") } */ diff --git a/internal/utils/slice.go b/internal/utils/slice.go new file mode 100644 index 00000000..d9716927 --- /dev/null +++ b/internal/utils/slice.go @@ -0,0 +1,34 @@ +package utils + +// UniqueUintSlice returns a new slice containing distinct values in the order of first appearance. +func UniqueUintSlice(values []uint) []uint { + if len(values) == 0 { + return nil + } + + seen := make(map[uint]struct{}, len(values)) + result := make([]uint, 0, len(values)) + for _, v := range values { + if _, ok := seen[v]; ok { + continue + } + seen[v] = struct{}{} + result = append(result, v) + } + return result +} + +// MissingUintIDs returns the values present in expected but missing from actual. +func MissingUintIDs(expected, actual []uint) []uint { + lookup := make(map[uint]struct{}, len(actual)) + for _, v := range actual { + lookup[v] = struct{}{} + } + missing := make([]uint, 0) + for _, v := range expected { + if _, ok := lookup[v]; !ok { + missing = append(missing, v) + } + } + return missing +} diff --git a/test/integration/master_data/bank_test.go b/test/integration/master_data/bank_test.go new file mode 100644 index 00000000..49a8c2a7 --- /dev/null +++ b/test/integration/master_data/bank_test.go @@ -0,0 +1,127 @@ +package test + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "testing" + + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + "gitlab.com/mbugroup/lti-api.git/internal/entities" +) + +func TestBankIntegration(t *testing.T) { + app, db := setupIntegrationApp(t) + + const bankAlias = "BNI" + const bankName = "Bank Negara Indonesia" + const bankOwner = "John Doe" + const bankAccountNumber = "1234567890" + var bankID uint + + t.Run("creating bank succeeds", func(t *testing.T) { + bankID = createBank(t, app, bankName, bankAlias, bankAccountNumber, bankOwner) + bank := fetchBank(t, db, bankID) + if bank.Alias != bankAlias { + t.Fatalf("expected alias %q, got %q", bankAlias, bank.Alias) + } + if bank.AccountNumber != bankAccountNumber { + t.Fatalf("expected account number %q, got %q", bankAccountNumber, bank.AccountNumber) + } + }) + + t.Run("creating bank with duplicate name fails", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodPost, "/api/master-data/banks", map[string]any{ + "name": bankName, + "alias": "NEWALIAS", + "owner": "Owner Name", + "account_number": "1122334455", + }) + if resp.StatusCode != fiber.StatusConflict { + t.Fatalf("expected 409 when creating duplicate bank name, got %d: %s", resp.StatusCode, string(body)) + } + }) + + t.Run("getting existing bank succeeds", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodGet, fmt.Sprintf("/api/master-data/banks/%d", bankID), nil) + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("expected 200 when fetching bank, got %d: %s", resp.StatusCode, string(body)) + } + var payload struct { + Data struct { + Id uint `json:"id"` + Name string `json:"name"` + Alias string `json:"alias"` + AccountNumber string `json:"account_number"` + } `json:"data"` + } + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + if payload.Data.Id != bankID { + t.Fatalf("expected id %d, got %d", bankID, payload.Data.Id) + } + if payload.Data.Alias != bankAlias { + t.Fatalf("expected alias %q, got %q", bankAlias, payload.Data.Alias) + } + if payload.Data.AccountNumber != bankAccountNumber { + t.Fatalf("expected account number %q, got %q", bankAccountNumber, payload.Data.AccountNumber) + } + }) + + const updatedName = "BNI Updated" + + t.Run("updating bank name succeeds", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodPatch, fmt.Sprintf("/api/master-data/banks/%d", bankID), map[string]any{ + "name": updatedName, + }) + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("expected 200 when updating bank, got %d: %s", resp.StatusCode, string(body)) + } + var payload struct { + Data struct { + Name string `json:"name"` + } `json:"data"` + } + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + if payload.Data.Name != updatedName { + t.Fatalf("expected updated name %q, got %q", updatedName, payload.Data.Name) + } + bank := fetchBank(t, db, bankID) + if bank.Name != updatedName { + t.Fatalf("expected persisted name %q, got %q", updatedName, bank.Name) + } + }) + + t.Run("updating non existent bank returns 404", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodPatch, "/api/master-data/banks/9999", map[string]any{ + "name": "Does Not Matter", + }) + if resp.StatusCode != fiber.StatusNotFound { + t.Fatalf("expected 404 when updating missing bank, got %d: %s", resp.StatusCode, string(body)) + } + }) + + t.Run("deleting bank succeeds", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodDelete, fmt.Sprintf("/api/master-data/banks/%d", bankID), nil) + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("expected 200 when deleting bank, got %d: %s", resp.StatusCode, string(body)) + } + var bank entities.Bank + if err := db.First(&bank, bankID).Error; !errors.Is(err, gorm.ErrRecordNotFound) { + t.Fatalf("expected bank to be deleted, got error %v", err) + } + }) + + t.Run("deleting non existent bank returns 404", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodDelete, fmt.Sprintf("/api/master-data/banks/%d", bankID), nil) + if resp.StatusCode != fiber.StatusNotFound { + t.Fatalf("expected 404 when deleting missing bank, got %d: %s", resp.StatusCode, string(body)) + } + }) +} diff --git a/test/integration/master_data/customer_test.go b/test/integration/master_data/customer_test.go index 737ff109..f3b3dbed 100644 --- a/test/integration/master_data/customer_test.go +++ b/test/integration/master_data/customer_test.go @@ -24,7 +24,7 @@ func TestCustomerIntegration(t *testing.T) { "type": utils.CustomerSupplierTypeBisnis, "address": "Somewhere", "phone": "0800000000", - "email": "invalid@example.com", + "email": "Invalid@example.com", "account_number": "ACC-INVALID", }) if resp.StatusCode != fiber.StatusNotFound { @@ -32,18 +32,18 @@ func TestCustomerIntegration(t *testing.T) { } }) - t.Run("creating customer with invalid type fails", func(t *testing.T) { + t.Run("creating customer with Invalid type fails", func(t *testing.T) { resp, body := doJSONRequest(t, app, http.MethodPost, "/api/master-data/customers", map[string]any{ "name": "Invalid Type", "pic_id": 1, "type": "UNKNOWN", "address": "Somewhere", "phone": "081234567891", - "email": "invalid-type@example.com", + "email": "Invalid-type@example.com", "account_number": "ACC-INVALID-TYPE", }) if resp.StatusCode != fiber.StatusBadRequest { - t.Fatalf("expected 400 when type is invalid, got %d: %s", resp.StatusCode, string(body)) + t.Fatalf("expected 400 when type is Invalid, got %d: %s", resp.StatusCode, string(body)) } }) @@ -140,16 +140,16 @@ func TestCustomerIntegration(t *testing.T) { } }) - t.Run("updating customer with invalid type fails", func(t *testing.T) { + t.Run("updating customer with Invalid type fails", func(t *testing.T) { resp, body := doJSONRequest(t, app, http.MethodPatch, fmt.Sprintf("/api/master-data/customers/%d", customerID), map[string]any{ "type": "random-type", }) if resp.StatusCode != fiber.StatusBadRequest { - t.Fatalf("expected 400 when updating with invalid type, got %d: %s", resp.StatusCode, string(body)) + t.Fatalf("expected 400 when updating with Invalid type, got %d: %s", resp.StatusCode, string(body)) } customer := fetchCustomer(t, db, customerID) if customer.Type != string(utils.CustomerSupplierTypeIndividual) { - t.Fatalf("expected type to remain %q after invalid update, got %q", utils.CustomerSupplierTypeIndividual, customer.Type) + t.Fatalf("expected type to remain %q after Invalid update, got %q", utils.CustomerSupplierTypeIndividual, customer.Type) } }) diff --git a/test/integration/master_data/fcr_test.go b/test/integration/master_data/fcr_test.go new file mode 100644 index 00000000..a07666d8 --- /dev/null +++ b/test/integration/master_data/fcr_test.go @@ -0,0 +1,218 @@ +package test + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "testing" + + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + "gitlab.com/mbugroup/lti-api.git/internal/entities" +) + +func TestFcrIntegration(t *testing.T) { + app, db := setupIntegrationApp(t) + + t.Run("creating fcr without standards fails", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodPost, "/api/master-data/fcrs", map[string]any{ + "name": "FCR Alpha", + }) + if resp.StatusCode != fiber.StatusBadRequest { + t.Fatalf("expected 400 when standards missing, got %d: %s", resp.StatusCode, string(body)) + } + }) + + initialStandards := []map[string]any{ + { + "weight": 7.2, + "fcr_number": 400.0, + "mortality": 12.0, + }, + { + "weight": 7.4, + "fcr_number": 410.0, + "mortality": 11.5, + }, + } + + var fcrID uint + + t.Run("creating fcr succeeds", func(t *testing.T) { + fcrID = createFcr(t, app, "FCR Layer", initialStandards) + fcr := fetchFcr(t, db, fcrID) + if fcr.Name != "FCR Layer" { + t.Fatalf("expected name FCR Layer, got %q", fcr.Name) + } + if len(fcr.Standards) != len(initialStandards) { + t.Fatalf("expected %d standards, got %d", len(initialStandards), len(fcr.Standards)) + } + if fcr.CreatedBy != 1 { + t.Fatalf("expected created_by 1, got %d", fcr.CreatedBy) + } + }) + + t.Run("creating duplicate fcr name fails", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodPost, "/api/master-data/fcrs", map[string]any{ + "name": "FCR Layer", + "fcr_standards": initialStandards, + }) + if resp.StatusCode != fiber.StatusConflict { + t.Fatalf("expected 409 when creating duplicate, got %d: %s", resp.StatusCode, string(body)) + } + }) + + t.Run("getting fcr detail succeeds", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodGet, fmt.Sprintf("/api/master-data/fcrs/%d", fcrID), nil) + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("expected 200 when fetching fcr, got %d: %s", resp.StatusCode, string(body)) + } + var payload struct { + Data struct { + Id uint `json:"id"` + Name string `json:"name"` + FcrStandards []map[string]any `json:"fcr_standards"` + } `json:"data"` + } + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatalf("failed to parse fcr detail: %v", err) + } + if payload.Data.Id != fcrID { + t.Fatalf("expected id %d, got %d", fcrID, payload.Data.Id) + } + if len(payload.Data.FcrStandards) != len(initialStandards) { + t.Fatalf("expected %d standards in response, got %d", len(initialStandards), len(payload.Data.FcrStandards)) + } + }) + + updatedStandards := []map[string]any{ + { + "weight": 7.2, + "fcr_number": 395.0, + "mortality": 10.0, + }, + { + "weight": 7.5, + "fcr_number": 420.0, + "mortality": 13.0, + }, + } + + t.Run("updating fcr name and standards succeeds", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodPatch, fmt.Sprintf("/api/master-data/fcrs/%d", fcrID), map[string]any{ + "name": "FCR Layer Updated", + "fcr_standards": updatedStandards, + }) + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("expected 200 when updating fcr, got %d: %s", resp.StatusCode, string(body)) + } + var payload struct { + Data struct { + Name string `json:"name"` + FcrStandards []map[string]any `json:"fcr_standards"` + } `json:"data"` + } + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatalf("failed to parse update response: %v", err) + } + if payload.Data.Name != "FCR Layer Updated" { + t.Fatalf("expected updated name, got %q", payload.Data.Name) + } + if len(payload.Data.FcrStandards) != len(updatedStandards) { + t.Fatalf("expected %d standards after update, got %d", len(updatedStandards), len(payload.Data.FcrStandards)) + } + fcr := fetchFcr(t, db, fcrID) + if fcr.Name != "FCR Layer Updated" { + t.Fatalf("expected persisted name FCR Layer Updated, got %q", fcr.Name) + } + if len(fcr.Standards) != len(updatedStandards) { + t.Fatalf("expected %d persisted standards, got %d", len(updatedStandards), len(fcr.Standards)) + } + if fcr.Standards[0].FcrNumber != 395.0 { + t.Fatalf("expected first standard fcr_number 395, got %f", fcr.Standards[0].FcrNumber) + } + }) + + var otherFcrID uint + t.Run("creating another fcr for duplicate update", func(t *testing.T) { + otherFcrID = createFcr(t, app, "FCR Grower", []map[string]any{ + { + "weight": 8.0, + "fcr_number": 430.0, + "mortality": 9.0, + }, + }) + if otherFcrID == 0 { + t.Fatal("expected other fcr id to be non zero") + } + }) + + t.Run("updating fcr with duplicate name fails", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodPatch, fmt.Sprintf("/api/master-data/fcrs/%d", fcrID), map[string]any{ + "name": "FCR Grower", + }) + if resp.StatusCode != fiber.StatusConflict { + t.Fatalf("expected 409 when renaming to existing fcr, got %d: %s", resp.StatusCode, string(body)) + } + fcr := fetchFcr(t, db, fcrID) + if fcr.Name != "FCR Layer Updated" { + t.Fatalf("expected name unchanged after failed update, got %q", fcr.Name) + } + }) + + t.Run("updating fcr with invalid standards fails", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodPatch, fmt.Sprintf("/api/master-data/fcrs/%d", fcrID), map[string]any{ + "fcr_standards": []map[string]any{ + { + "weight": -1, + "fcr_number": 300, + "mortality": 5, + }, + }, + }) + if resp.StatusCode != fiber.StatusBadRequest { + t.Fatalf("expected 400 when updating with invalid standard, got %d: %s", resp.StatusCode, string(body)) + } + }) + + t.Run("deleting fcr succeeds", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodDelete, fmt.Sprintf("/api/master-data/fcrs/%d", fcrID), nil) + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("expected 200 when deleting fcr, got %d: %s", resp.StatusCode, string(body)) + } + var fcr entities.Fcr + if err := db.First(&fcr, fcrID).Error; !errors.Is(err, gorm.ErrRecordNotFound) { + t.Fatalf("expected fcr to be deleted, got error %v", err) + } + var standardsCount int64 + if err := db.Model(&entities.FcrStandard{}).Where("fcr_id = ?", fcrID).Count(&standardsCount).Error; err != nil { + t.Fatalf("failed counting standards: %v", err) + } + if standardsCount != 0 { + t.Fatalf("expected standards removed, found %d", standardsCount) + } + }) + + t.Run("deleting fcr again returns 404", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodDelete, fmt.Sprintf("/api/master-data/fcrs/%d", fcrID), nil) + if resp.StatusCode != fiber.StatusNotFound { + t.Fatalf("expected 404 when deleting missing fcr, got %d: %s", resp.StatusCode, string(body)) + } + }) + + t.Run("getting deleted fcr returns 404", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodGet, fmt.Sprintf("/api/master-data/fcrs/%d", fcrID), nil) + if resp.StatusCode != fiber.StatusNotFound { + t.Fatalf("expected 404 when fetching deleted fcr, got %d: %s", resp.StatusCode, string(body)) + } + }) + + t.Run("cleanup other fcr", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodDelete, fmt.Sprintf("/api/master-data/fcrs/%d", otherFcrID), nil) + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("expected 200 when deleting other fcr, got %d: %s", resp.StatusCode, string(body)) + } + }) +} diff --git a/test/integration/master_data/master_data.go b/test/integration/master_data/master_data.go index ca4b3745..1ccc4fea 100644 --- a/test/integration/master_data/master_data.go +++ b/test/integration/master_data/master_data.go @@ -44,6 +44,16 @@ func setupIntegrationApp(t *testing.T) (*fiber.App, *gorm.DB) { &entities.Warehouse{}, &entities.Uom{}, &entities.Customer{}, + &entities.Supplier{}, + &entities.Flag{}, + &entities.ProductCategory{}, + &entities.Nonstock{}, + &entities.NonstockSupplier{}, + &entities.Product{}, + &entities.ProductSupplier{}, + &entities.Fcr{}, + &entities.FcrStandard{}, + &entities.Bank{}, ); err != nil { t.Fatalf("auto migrate failed: %v", err) } @@ -60,7 +70,7 @@ func setupIntegrationApp(t *testing.T) (*fiber.App, *gorm.DB) { t.Fatalf("failed to seed user: %v", err) } - app := fiber.New() + app := fiber.New(fiber.Config{ErrorHandler: utils.ErrorHandler}) route.Routes(app, db) return app, db } @@ -129,6 +139,15 @@ func createLocation(t *testing.T, app *fiber.App, name, address string, areaID u return parseID(t, body) } +func createUom(t *testing.T, app *fiber.App, name string) uint { + t.Helper() + resp, body := doJSONRequest(t, app, http.MethodPost, "/api/master-data/uoms", map[string]any{"name": name}) + if resp.StatusCode != fiber.StatusCreated { + t.Fatalf("expected 201 when creating uom, got %d: %s", resp.StatusCode, string(body)) + } + return parseID(t, body) +} + func createKandang(t *testing.T, app *fiber.App, name string, locationID, picID uint) uint { t.Helper() resp, body := doJSONRequest(t, app, http.MethodPost, "/api/master-data/kandangs", map[string]any{ @@ -169,6 +188,152 @@ func fetchCustomer(t *testing.T, db *gorm.DB, id uint) entities.Customer { return customer } +func createSupplier(t *testing.T, app *fiber.App, name, alias, category string) uint { + t.Helper() + identifier := strings.ToLower(strings.ReplaceAll(name, " ", "_")) + resp, body := doJSONRequest(t, app, http.MethodPost, "/api/master-data/suppliers", map[string]any{ + "name": name, + "alias": alias, + "pic": "John Doe", + "type": utils.CustomerSupplierTypeBisnis, + "category": category, + "hatchery": "Hatchery A", + "phone": "081234567890", + "email": fmt.Sprintf("%s@supplier.com", identifier), + "address": "Supplier address", + "npwp": "NPWP-123", + "account_number": "ACC-SUPPLIER", + "due_date": 30, + }) + if resp.StatusCode != fiber.StatusCreated { + t.Fatalf("expected 201 when creating supplier, got %d: %s", resp.StatusCode, string(body)) + } + return parseID(t, body) +} + +func createProductCategory(t *testing.T, app *fiber.App, name, code string) uint { + t.Helper() + resp, body := doJSONRequest(t, app, http.MethodPost, "/api/master-data/product-categories", map[string]any{ + "name": name, + "code": code, + }) + if resp.StatusCode != fiber.StatusCreated { + t.Fatalf("expected 201 when creating product category, got %d: %s", resp.StatusCode, string(body)) + } + return parseID(t, body) +} + +func fetchProductCategory(t *testing.T, db *gorm.DB, id uint) entities.ProductCategory { + t.Helper() + var pc entities.ProductCategory + if err := db.Preload("CreatedUser").First(&pc, id).Error; err != nil { + t.Fatalf("failed to fetch product category: %v", err) + } + return pc +} + +func createProduct(t *testing.T, app *fiber.App, name, brand string, sku *string, uomID, categoryID uint, productPrice float64, supplierIDs []uint, flags []string) uint { + t.Helper() + payload := map[string]any{ + "name": name, + "brand": brand, + "uom_id": uomID, + "product_category_id": categoryID, + "product_price": productPrice, + "supplier_ids": supplierIDs, + } + if sku != nil { + payload["sku"] = *sku + } + if len(flags) > 0 { + payload["flags"] = flags + } + resp, body := doJSONRequest(t, app, http.MethodPost, "/api/master-data/products", payload) + if resp.StatusCode != fiber.StatusCreated { + t.Fatalf("expected 201 when creating product, got %d: %s", resp.StatusCode, string(body)) + } + return parseID(t, body) +} + +func fetchProduct(t *testing.T, db *gorm.DB, id uint) entities.Product { + t.Helper() + var product entities.Product + if err := db.Preload("CreatedUser"). + Preload("Uom"). + Preload("ProductCategory"). + Preload("Suppliers", func(tx *gorm.DB) *gorm.DB { return tx.Order("suppliers.name ASC") }). + Preload("Flags", func(tx *gorm.DB) *gorm.DB { return tx.Order("flags.name ASC") }). + First(&product, id).Error; err != nil { + t.Fatalf("failed to fetch product: %v", err) + } + return product +} + +func fetchSupplier(t *testing.T, db *gorm.DB, id uint) entities.Supplier { + t.Helper() + var supplier entities.Supplier + if err := db.Preload("CreatedUser").First(&supplier, id).Error; err != nil { + t.Fatalf("failed to fetch supplier: %v", err) + } + return supplier +} + +func createFcr(t *testing.T, app *fiber.App, name string, standards []map[string]any) uint { + t.Helper() + payload := map[string]any{ + "name": name, + "fcr_standards": standards, + } + resp, body := doJSONRequest(t, app, http.MethodPost, "/api/master-data/fcrs", payload) + if resp.StatusCode != fiber.StatusCreated { + t.Fatalf("expected 201 when creating fcr, got %d: %s", resp.StatusCode, string(body)) + } + return parseID(t, body) +} + +func fetchFcr(t *testing.T, db *gorm.DB, id uint) entities.Fcr { + t.Helper() + var fcr entities.Fcr + if err := db.Preload("CreatedUser"). + Preload("Standards", func(tx *gorm.DB) *gorm.DB { + return tx.Order("weight ASC") + }). + First(&fcr, id).Error; err != nil { + t.Fatalf("failed to fetch fcr: %v", err) + } + return fcr +} + +func createNonstock(t *testing.T, app *fiber.App, name string, uomID uint, supplierIDs []uint, flags []string) uint { + t.Helper() + payload := map[string]any{ + "name": name, + "uom_id": uomID, + "supplier_ids": supplierIDs, + } + if len(flags) > 0 { + payload["flags"] = flags + } + resp, body := doJSONRequest(t, app, http.MethodPost, "/api/master-data/nonstocks", payload) + if resp.StatusCode != fiber.StatusCreated { + t.Fatalf("expected 201 when creating nonstock, got %d: %s", resp.StatusCode, string(body)) + } + return parseID(t, body) +} + +func fetchNonstock(t *testing.T, db *gorm.DB, id uint) entities.Nonstock { + t.Helper() + var nonstock entities.Nonstock + if err := db.Preload("CreatedUser"). + Preload("Uom"). + Preload("Suppliers", func(tx *gorm.DB) *gorm.DB { return tx.Order("suppliers.name ASC") }). + Preload("Flags", func(tx *gorm.DB) *gorm.DB { return tx.Order("flags.name ASC") }). + First(&nonstock, id).Error; err != nil { + t.Fatalf("failed to fetch nonstock: %v", err) + } + return nonstock +} + func fetchAreaName(t *testing.T, db *gorm.DB, id uint) string { t.Helper() var area entities.Area @@ -186,3 +351,27 @@ func fetchWarehouse(t *testing.T, db *gorm.DB, id uint) entities.Warehouse { } return wh } + +func createBank(t *testing.T, app *fiber.App, name, alias, accountNumber string, owner any) uint { + t.Helper() + payload := map[string]any{ + "name": name, + "alias": alias, + "account_number": accountNumber, + "owner": owner, + } + resp, body := doJSONRequest(t, app, http.MethodPost, "/api/master-data/banks", payload) + if resp.StatusCode != fiber.StatusCreated { + t.Fatalf("expected 201 when creating bank, got %d: %s", resp.StatusCode, string(body)) + } + return parseID(t, body) +} + +func fetchBank(t *testing.T, db *gorm.DB, id uint) entities.Bank { + t.Helper() + var bank entities.Bank + if err := db.Preload("CreatedUser").First(&bank, id).Error; err != nil { + t.Fatalf("failed to fetch bank: %v", err) + } + return bank +} diff --git a/test/integration/master_data/nonstock_test.go b/test/integration/master_data/nonstock_test.go new file mode 100644 index 00000000..cadea99d --- /dev/null +++ b/test/integration/master_data/nonstock_test.go @@ -0,0 +1,309 @@ +package test + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "testing" + + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/utils" +) + +func TestNonstockIntegration(t *testing.T) { + app, db := setupIntegrationApp(t) + + uomID := createUom(t, app, "Unit Piece") + altUomID := createUom(t, app, "Unit Box") + + supplierID1 := createSupplier(t, app, "Nonstock Supplier One", "ns1", string(utils.SupplierCategoryBOP)) + supplierID2 := createSupplier(t, app, "Nonstock Supplier Two", "ns2", string(utils.SupplierCategoryBOP)) + supplierID3 := createSupplier(t, app, "Nonstock Supplier Three", "ns3", string(utils.SupplierCategoryBOP)) + sapronakSupplierID := createSupplier(t, app, "SAPRONAK Supplier", "sap1", string(utils.SupplierCategorySapronak)) + + nonstockFlags := []string{string(utils.FlagEkspedisi)} + + t.Run("create nonstock without suppliers succeeds with empty relations", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodPost, "/api/master-data/nonstocks", map[string]any{ + "name": "Supplierless Nonstock", + "uom_id": uomID, + }) + if resp.StatusCode != fiber.StatusCreated { + t.Fatalf("expected 201 when suppliers omitted, got %d: %s", resp.StatusCode, string(body)) + } + id := parseID(t, body) + ns := fetchNonstock(t, db, id) + if len(ns.Suppliers) != 0 { + t.Fatalf("expected no suppliers persisted, found %d", len(ns.Suppliers)) + } + if len(ns.Flags) != 0 { + t.Fatalf("expected no flags persisted, found %d", len(ns.Flags)) + } + resp, _ = doJSONRequest(t, app, http.MethodDelete, fmt.Sprintf("/api/master-data/nonstocks/%d", id), nil) + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("expected cleanup delete to succeed, got %d", resp.StatusCode) + } + }) + + t.Run("create nonstock with unknown supplier fails", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodPost, "/api/master-data/nonstocks", map[string]any{ + "name": "Unknown Supplier Nonstock", + "uom_id": uomID, + "supplier_ids": []uint{99999}, + }) + if resp.StatusCode != fiber.StatusNotFound { + t.Fatalf("expected 404 when supplier missing, got %d: %s", resp.StatusCode, string(body)) + } + }) + + t.Run("create nonstock with sapronak supplier fails", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodPost, "/api/master-data/nonstocks", map[string]any{ + "name": "Invalid Category Nonstock", + "uom_id": uomID, + "supplier_ids": []uint{sapronakSupplierID}, + }) + if resp.StatusCode != fiber.StatusBadRequest { + t.Fatalf("expected 400 when supplier category is not BOP, got %d: %s", resp.StatusCode, string(body)) + } + }) + + t.Run("create nonstock with invalid flags fails", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodPost, "/api/master-data/nonstocks", map[string]any{ + "name": "Invalid Flag Nonstock", + "uom_id": uomID, + "supplier_ids": []uint{supplierID1}, + "flags": []string{"UNKNOWN"}, + }) + if resp.StatusCode != fiber.StatusBadRequest { + t.Fatalf("expected 400 when flags invalid, got %d: %s", resp.StatusCode, string(body)) + } + }) + + var nonstockID uint + + t.Run("create nonstock succeeds", func(t *testing.T) { + nonstockID = createNonstock(t, app, "Layer Feed", uomID, []uint{supplierID1, supplierID2, supplierID1}, nonstockFlags) + if nonstockID == 0 { + t.Fatal("expected nonstock id to be non zero") + } + ns := fetchNonstock(t, db, nonstockID) + if ns.Name != "Layer Feed" { + t.Fatalf("expected name Layer Feed, got %q", ns.Name) + } + if ns.UomId != uomID { + t.Fatalf("expected uom_id %d, got %d", uomID, ns.UomId) + } + if ns.CreatedBy != 1 { + t.Fatalf("expected created_by 1, got %d", ns.CreatedBy) + } + if len(ns.Suppliers) != 2 { + t.Fatalf("expected 2 unique suppliers, got %d", len(ns.Suppliers)) + } + if len(ns.Flags) != len(nonstockFlags) { + t.Fatalf("expected %d flags, got %d", len(nonstockFlags), len(ns.Flags)) + } + expectedFlags := make(map[string]struct{}, len(nonstockFlags)) + for _, flag := range nonstockFlags { + expectedFlags[strings.ToUpper(flag)] = struct{}{} + } + for _, flag := range ns.Flags { + upper := strings.ToUpper(flag.Name) + if _, ok := expectedFlags[upper]; !ok { + t.Fatalf("unexpected flag stored: %s", upper) + } + delete(expectedFlags, upper) + } + if len(expectedFlags) != 0 { + t.Fatalf("missing flags after create: %v", expectedFlags) + } + }) + + t.Run("get nonstock detail includes suppliers", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodGet, fmt.Sprintf("/api/master-data/nonstocks/%d", nonstockID), nil) + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("expected 200 when fetching nonstock, got %d: %s", resp.StatusCode, string(body)) + } + var payload struct { + Data struct { + Id uint `json:"id"` + Name string `json:"name"` + UomID uint `json:"uom_id"` + Suppliers []map[string]any `json:"suppliers"` + Flags []string `json:"flags"` + } `json:"data"` + } + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatalf("failed to parse nonstock detail: %v", err) + } + if payload.Data.Id != nonstockID { + t.Fatalf("expected id %d, got %d", nonstockID, payload.Data.Id) + } + if payload.Data.UomID != uomID { + t.Fatalf("expected response uom_id %d, got %d", uomID, payload.Data.UomID) + } + if len(payload.Data.Suppliers) != 2 { + t.Fatalf("expected 2 suppliers in response, got %d", len(payload.Data.Suppliers)) + } + if len(payload.Data.Flags) != len(nonstockFlags) { + t.Fatalf("expected %d flags in response, got %d", len(nonstockFlags), len(payload.Data.Flags)) + } + expected := make(map[string]struct{}, len(nonstockFlags)) + for _, flag := range nonstockFlags { + expected[strings.ToUpper(flag)] = struct{}{} + } + for _, flag := range payload.Data.Flags { + flag = strings.ToUpper(flag) + if _, ok := expected[flag]; !ok { + t.Fatalf("unexpected flag %s returned", flag) + } + delete(expected, flag) + } + if len(expected) != 0 { + t.Fatalf("missing flags in response: %v", expected) + } + }) + + t.Run("update nonstock with invalid uom fails", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodPatch, fmt.Sprintf("/api/master-data/nonstocks/%d", nonstockID), map[string]any{ + "uom_id": 99999, + }) + if resp.StatusCode != fiber.StatusNotFound { + t.Fatalf("expected 404 when updating with invalid uom, got %d: %s", resp.StatusCode, string(body)) + } + }) + + t.Run("update nonstock with invalid supplier fails", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodPatch, fmt.Sprintf("/api/master-data/nonstocks/%d", nonstockID), map[string]any{ + "supplier_ids": []uint{supplierID1, 99999}, + }) + if resp.StatusCode != fiber.StatusNotFound { + t.Fatalf("expected 404 when updating with invalid supplier, got %d: %s", resp.StatusCode, string(body)) + } + }) + + t.Run("update nonstock with sapronak supplier fails", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodPatch, fmt.Sprintf("/api/master-data/nonstocks/%d", nonstockID), map[string]any{ + "supplier_ids": []uint{sapronakSupplierID}, + }) + if resp.StatusCode != fiber.StatusBadRequest { + t.Fatalf("expected 400 when updating with non-BOP supplier, got %d: %s", resp.StatusCode, string(body)) + } + }) + + t.Run("update nonstock with invalid flags fails", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodPatch, fmt.Sprintf("/api/master-data/nonstocks/%d", nonstockID), map[string]any{ + "flags": []string{"BAD"}, + }) + if resp.StatusCode != fiber.StatusBadRequest { + t.Fatalf("expected 400 when updating flags invalid, got %d: %s", resp.StatusCode, string(body)) + } + }) + + t.Run("update nonstock name uom and suppliers succeeds", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodPatch, fmt.Sprintf("/api/master-data/nonstocks/%d", nonstockID), map[string]any{ + "name": "Layer Feed Premium", + "uom_id": altUomID, + "supplier_ids": []uint{supplierID3}, + "flags": nonstockFlags, + }) + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("expected 200 when updating nonstock, got %d: %s", resp.StatusCode, string(body)) + } + ns := fetchNonstock(t, db, nonstockID) + if ns.Name != "Layer Feed Premium" { + t.Fatalf("expected name Layer Feed Premium, got %q", ns.Name) + } + if ns.UomId != altUomID { + t.Fatalf("expected uom_id %d, got %d", altUomID, ns.UomId) + } + if len(ns.Suppliers) != 1 || ns.Suppliers[0].Id != supplierID3 { + t.Fatalf("expected suppliers to contain only %d", supplierID3) + } + if len(ns.Flags) != len(nonstockFlags) { + t.Fatalf("expected flags retained, got %d", len(ns.Flags)) + } + }) + + t.Run("clear suppliers succeeds", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodPatch, fmt.Sprintf("/api/master-data/nonstocks/%d", nonstockID), map[string]any{ + "supplier_ids": []uint{}, + }) + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("expected 200 when clearing suppliers, got %d: %s", resp.StatusCode, string(body)) + } + ns := fetchNonstock(t, db, nonstockID) + if len(ns.Suppliers) != 0 { + t.Fatalf("expected suppliers to be cleared, got %d entries", len(ns.Suppliers)) + } + if len(ns.Flags) != len(nonstockFlags) { + t.Fatalf("expected flags unaffected, got %d", len(ns.Flags)) + } + }) + + t.Run("clear nonstock flags succeeds", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodPatch, fmt.Sprintf("/api/master-data/nonstocks/%d", nonstockID), map[string]any{ + "flags": []string{}, + }) + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("expected 200 when clearing nonstock flags, got %d: %s", resp.StatusCode, string(body)) + } + ns := fetchNonstock(t, db, nonstockID) + if len(ns.Flags) != 0 { + t.Fatalf("expected flags cleared, got %d", len(ns.Flags)) + } + }) + + t.Run("delete nonstock succeeds", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodDelete, fmt.Sprintf("/api/master-data/nonstocks/%d", nonstockID), nil) + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("expected 200 when deleting nonstock, got %d: %s", resp.StatusCode, string(body)) + } + var ns entities.Nonstock + if err := db.First(&ns, nonstockID).Error; !errors.Is(err, gorm.ErrRecordNotFound) { + t.Fatalf("expected nonstock to be deleted, got error %v", err) + } + var links int64 + if err := db.Model(&entities.NonstockSupplier{}).Where("nonstock_id = ?", nonstockID).Count(&links).Error; err != nil { + t.Fatalf("failed counting nonstock suppliers: %v", err) + } + if links != 0 { + t.Fatalf("expected link table cleared, found %d rows", links) + } + var flagCount int64 + if err := db.Model(&entities.Flag{}). + Where("flagable_id = ? AND flagable_type = ?", nonstockID, entities.FlagableTypeNonstock). + Count(&flagCount).Error; err != nil { + t.Fatalf("failed counting nonstock flags: %v", err) + } + if flagCount != 0 { + t.Fatalf("expected flags removed, found %d", flagCount) + } + }) + + t.Run("deleting nonstock twice returns 404", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodDelete, fmt.Sprintf("/api/master-data/nonstocks/%d", nonstockID), nil) + if resp.StatusCode != fiber.StatusNotFound { + t.Fatalf("expected 404 when deleting missing nonstock, got %d: %s", resp.StatusCode, string(body)) + } + }) + + t.Run("fetching deleted nonstock returns 404", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodGet, fmt.Sprintf("/api/master-data/nonstocks/%d", nonstockID), nil) + if resp.StatusCode != fiber.StatusNotFound { + t.Fatalf("expected 404 when fetching deleted nonstock, got %d: %s", resp.StatusCode, string(body)) + } + }) + + t.Run("cleanup additional supplier references", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodDelete, fmt.Sprintf("/api/master-data/suppliers/%d", supplierID3), nil) + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("expected 200 when deleting supplier, got %d: %s", resp.StatusCode, string(body)) + } + doJSONRequest(t, app, http.MethodDelete, fmt.Sprintf("/api/master-data/suppliers/%d", sapronakSupplierID), nil) + }) +} diff --git a/test/integration/master_data/product_category_test.go b/test/integration/master_data/product_category_test.go new file mode 100644 index 00000000..14012301 --- /dev/null +++ b/test/integration/master_data/product_category_test.go @@ -0,0 +1,150 @@ +package test + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "testing" + + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + "gitlab.com/mbugroup/lti-api.git/internal/entities" +) + +func TestProductCategoryIntegration(t *testing.T) { + app, db := setupIntegrationApp(t) + + t.Run("create product category missing code fails", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodPost, "/api/master-data/product-categories", map[string]any{ + "name": "Layer Feed", + }) + if resp.StatusCode != fiber.StatusBadRequest { + t.Fatalf("expected 400 when code missing, got %d: %s", resp.StatusCode, string(body)) + } + }) + + var categoryID uint + + t.Run("create product category succeeds", func(t *testing.T) { + categoryID = createProductCategory(t, app, "Layer Feed", "LFD") + pc := fetchProductCategory(t, db, categoryID) + if pc.Name != "Layer Feed" { + t.Fatalf("expected name Layer Feed, got %q", pc.Name) + } + if pc.Code != "LFD" { + t.Fatalf("expected code LFD, got %q", pc.Code) + } + if pc.CreatedBy != 1 { + t.Fatalf("expected created_by 1, got %d", pc.CreatedBy) + } + }) + + t.Run("creating duplicate name fails", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodPost, "/api/master-data/product-categories", map[string]any{ + "name": "Layer Feed", + "code": "LF2", + }) + if resp.StatusCode != fiber.StatusConflict { + t.Fatalf("expected 409 when creating duplicate name, got %d: %s", resp.StatusCode, string(body)) + } + }) + + t.Run("creating duplicate code fails", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodPost, "/api/master-data/product-categories", map[string]any{ + "name": "Layer Feed Premium", + "code": "LFD", + }) + if resp.StatusCode != fiber.StatusConflict { + t.Fatalf("expected 409 when creating duplicate code, got %d: %s", resp.StatusCode, string(body)) + } + }) + + t.Run("get product category detail", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodGet, fmt.Sprintf("/api/master-data/product-categories/%d", categoryID), nil) + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("expected 200 when fetching product category, got %d: %s", resp.StatusCode, string(body)) + } + var payload struct { + Data struct { + Id uint `json:"id"` + Name string `json:"name"` + Code string `json:"code"` + } `json:"data"` + } + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + if payload.Data.Id != categoryID { + t.Fatalf("expected id %d, got %d", categoryID, payload.Data.Id) + } + if payload.Data.Code != "LFD" { + t.Fatalf("expected code LFD, got %q", payload.Data.Code) + } + }) + + t.Run("update product category with duplicate name fails", func(t *testing.T) { + otherID := createProductCategory(t, app, "Layer Feed Alt", "LFA") + resp, body := doJSONRequest(t, app, http.MethodPatch, fmt.Sprintf("/api/master-data/product-categories/%d", otherID), map[string]any{ + "name": "Layer Feed", + }) + if resp.StatusCode != fiber.StatusConflict { + t.Fatalf("expected 409 when updating with duplicate name, got %d: %s", resp.StatusCode, string(body)) + } + }) + + t.Run("update product category succeeds", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodPatch, fmt.Sprintf("/api/master-data/product-categories/%d", categoryID), map[string]any{ + "name": "Layer Feed Updated", + "code": "LFU", + }) + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("expected 200 when updating product category, got %d: %s", resp.StatusCode, string(body)) + } + var payload struct { + Data struct { + Name string `json:"name"` + Code string `json:"code"` + } `json:"data"` + } + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatalf("failed to parse update response: %v", err) + } + if payload.Data.Name != "Layer Feed Updated" { + t.Fatalf("expected updated name, got %q", payload.Data.Name) + } + if payload.Data.Code != "LFU" { + t.Fatalf("expected code LFU, got %q", payload.Data.Code) + } + pc := fetchProductCategory(t, db, categoryID) + if pc.Code != "LFU" { + t.Fatalf("expected persisted code LFU, got %q", pc.Code) + } + }) + + t.Run("delete product category", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodDelete, fmt.Sprintf("/api/master-data/product-categories/%d", categoryID), nil) + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("expected 200 when deleting product category, got %d: %s", resp.StatusCode, string(body)) + } + var pc entities.ProductCategory + if err := db.First(&pc, categoryID).Error; !errors.Is(err, gorm.ErrRecordNotFound) { + t.Fatalf("expected product category deleted, got error %v", err) + } + }) + + t.Run("delete non existing product category returns 404", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodDelete, fmt.Sprintf("/api/master-data/product-categories/%d", categoryID), nil) + if resp.StatusCode != fiber.StatusNotFound { + t.Fatalf("expected 404 when deleting missing product category, got %d: %s", resp.StatusCode, string(body)) + } + }) + + t.Run("get deleted product category returns 404", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodGet, fmt.Sprintf("/api/master-data/product-categories/%d", categoryID), nil) + if resp.StatusCode != fiber.StatusNotFound { + t.Fatalf("expected 404 when fetching deleted product category, got %d: %s", resp.StatusCode, string(body)) + } + }) +} diff --git a/test/integration/master_data/product_test.go b/test/integration/master_data/product_test.go new file mode 100644 index 00000000..4ffb1463 --- /dev/null +++ b/test/integration/master_data/product_test.go @@ -0,0 +1,410 @@ +package test + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "testing" + + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/utils" +) + +func TestProductIntegration(t *testing.T) { + app, db := setupIntegrationApp(t) + + uomID := createUom(t, app, "Kilogram") + categoryID := createProductCategory(t, app, "Feed", "FED") + + sapSupplier1 := createSupplier(t, app, "Feed Supplier One", "fs1", string(utils.SupplierCategorySapronak)) + sapSupplier2 := createSupplier(t, app, "Feed Supplier Two", "fs2", string(utils.SupplierCategorySapronak)) + bopSupplier := createSupplier(t, app, "BOP Supplier", "bop1", string(utils.SupplierCategoryBOP)) + + productFlags := []string{string(utils.FlagDOC), string(utils.FlagPakan)} + updatedProductFlags := []string{string(utils.FlagFinisher), string(utils.FlagObat)} + + t.Run("create product without suppliers succeeds with empty relations", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodPost, "/api/master-data/products", map[string]any{ + "name": "Supplierless Product", + "brand": "Brand A", + "uom_id": uomID, + "product_category_id": categoryID, + "product_price": 12000, + }) + if resp.StatusCode != fiber.StatusCreated { + t.Fatalf("expected 201 when suppliers omitted, got %d: %s", resp.StatusCode, string(body)) + } + id := parseID(t, body) + product := fetchProduct(t, db, id) + if len(product.Suppliers) != 0 { + t.Fatalf("expected no suppliers persisted, found %d", len(product.Suppliers)) + } + if len(product.Flags) != 0 { + t.Fatalf("expected no flags persisted, found %d", len(product.Flags)) + } + resp, _ = doJSONRequest(t, app, http.MethodDelete, fmt.Sprintf("/api/master-data/products/%d", id), nil) + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("expected cleanup delete to succeed, got %d", resp.StatusCode) + } + }) + + t.Run("create product with invalid uom fails", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodPost, "/api/master-data/products", map[string]any{ + "name": "Layer Feed Invalid UOM", + "brand": "Brand A", + "uom_id": 9999, + "product_category_id": categoryID, + "product_price": 12000, + "supplier_ids": []uint{sapSupplier1}, + }) + if resp.StatusCode != fiber.StatusNotFound { + t.Fatalf("expected 404 when uom missing, got %d: %s", resp.StatusCode, string(body)) + } + }) + + t.Run("create product with invalid category fails", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodPost, "/api/master-data/products", map[string]any{ + "name": "Layer Feed Invalid Category", + "brand": "Brand A", + "uom_id": uomID, + "product_category_id": 9999, + "product_price": 12000, + "supplier_ids": []uint{sapSupplier1}, + }) + if resp.StatusCode != fiber.StatusNotFound { + t.Fatalf("expected 404 when category missing, got %d: %s", resp.StatusCode, string(body)) + } + }) + + t.Run("create product with invalid supplier fails", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodPost, "/api/master-data/products", map[string]any{ + "name": "Layer Feed Missing Supplier", + "brand": "Brand A", + "uom_id": uomID, + "product_category_id": categoryID, + "product_price": 12000, + "supplier_ids": []uint{99999}, + }) + if resp.StatusCode != fiber.StatusNotFound { + t.Fatalf("expected 404 when supplier missing, got %d: %s", resp.StatusCode, string(body)) + } + }) + + t.Run("create product with BOP supplier fails", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodPost, "/api/master-data/products", map[string]any{ + "name": "Layer Feed Wrong Supplier", + "brand": "Brand A", + "uom_id": uomID, + "product_category_id": categoryID, + "product_price": 12000, + "supplier_ids": []uint{bopSupplier}, + }) + if resp.StatusCode != fiber.StatusBadRequest { + t.Fatalf("expected 400 when supplier category invalid, got %d: %s", resp.StatusCode, string(body)) + } + }) + + t.Run("create product with invalid flags fails", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodPost, "/api/master-data/products", map[string]any{ + "name": "Layer Feed Invalid Flags", + "brand": "Brand A", + "uom_id": uomID, + "product_category_id": categoryID, + "product_price": 12000, + "supplier_ids": []uint{sapSupplier1}, + "flags": []string{"INVALID"}, + }) + if resp.StatusCode != fiber.StatusBadRequest { + t.Fatalf("expected 400 when flags invalid, got %d: %s", resp.StatusCode, string(body)) + } + }) + + var productID uint + + t.Run("create product succeeds", func(t *testing.T) { + sku := "lfd-001" + productID = createProduct(t, app, "Layer Feed", "Brand A", &sku, uomID, categoryID, 15000, []uint{sapSupplier1, sapSupplier2}, productFlags) + product := fetchProduct(t, db, productID) + if product.Name != "Layer Feed" { + t.Fatalf("expected name Layer Feed, got %q", product.Name) + } + if product.Brand != "Brand A" { + t.Fatalf("expected brand Brand A, got %q", product.Brand) + } + if product.Sku == nil || *product.Sku != strings.ToUpper(sku) { + t.Fatalf("expected sku %q, got %+v", strings.ToUpper(sku), product.Sku) + } + if product.UomId != uomID { + t.Fatalf("expected uom_id %d, got %d", uomID, product.UomId) + } + if product.ProductCategoryId != categoryID { + t.Fatalf("expected product_category_id %d, got %d", categoryID, product.ProductCategoryId) + } + if product.CreatedBy != 1 { + t.Fatalf("expected created_by 1, got %d", product.CreatedBy) + } + if len(product.Suppliers) != 2 { + t.Fatalf("expected 2 suppliers, got %d", len(product.Suppliers)) + } + if len(product.Flags) != len(productFlags) { + t.Fatalf("expected %d flags, got %d", len(productFlags), len(product.Flags)) + } + expectedFlags := make(map[string]struct{}, len(productFlags)) + for _, flag := range productFlags { + expectedFlags[strings.ToUpper(flag)] = struct{}{} + } + for _, flag := range product.Flags { + if _, ok := expectedFlags[strings.ToUpper(flag.Name)]; !ok { + t.Fatalf("unexpected flag %s present", flag.Name) + } + delete(expectedFlags, strings.ToUpper(flag.Name)) + } + if len(expectedFlags) != 0 { + t.Fatalf("missing expected flags: %v", expectedFlags) + } + }) + + t.Run("creating duplicate name fails", func(t *testing.T) { + sku := "lfd-002" + resp, body := doJSONRequest(t, app, http.MethodPost, "/api/master-data/products", map[string]any{ + "name": "Layer Feed", + "brand": "Brand B", + "sku": sku, + "uom_id": uomID, + "product_category_id": categoryID, + "product_price": 16000, + "supplier_ids": []uint{sapSupplier1}, + }) + if resp.StatusCode != fiber.StatusConflict { + t.Fatalf("expected 409 when creating duplicate name, got %d: %s", resp.StatusCode, string(body)) + } + }) + + t.Run("creating duplicate sku fails", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodPost, "/api/master-data/products", map[string]any{ + "name": "Layer Feed Premium", + "brand": "Brand B", + "sku": "LFD-001", + "uom_id": uomID, + "product_category_id": categoryID, + "product_price": 17000, + "supplier_ids": []uint{sapSupplier1}, + }) + if resp.StatusCode != fiber.StatusConflict { + t.Fatalf("expected 409 when creating duplicate sku, got %d: %s", resp.StatusCode, string(body)) + } + }) + + t.Run("get product detail returns nested data", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodGet, fmt.Sprintf("/api/master-data/products/%d", productID), nil) + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("expected 200 when fetching product, got %d: %s", resp.StatusCode, string(body)) + } + var payload struct { + Data struct { + Id uint `json:"id"` + Name string `json:"name"` + Brand string `json:"brand"` + Uom *struct { + Id uint `json:"id"` + } `json:"uom"` + Suppliers []map[string]any `json:"suppliers"` + Flags []string `json:"flags"` + } `json:"data"` + } + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatalf("failed to parse product detail: %v", err) + } + if payload.Data.Id != productID { + t.Fatalf("expected id %d, got %d", productID, payload.Data.Id) + } + if payload.Data.Uom == nil || payload.Data.Uom.Id != uomID { + t.Fatalf("expected uom id %d, got %+v", uomID, payload.Data.Uom) + } + if len(payload.Data.Suppliers) != 2 { + t.Fatalf("expected 2 suppliers in response, got %d", len(payload.Data.Suppliers)) + } + if len(payload.Data.Flags) != len(productFlags) { + t.Fatalf("expected %d flags in response, got %d", len(productFlags), len(payload.Data.Flags)) + } + expected := make(map[string]struct{}, len(productFlags)) + for _, flag := range productFlags { + expected[strings.ToUpper(flag)] = struct{}{} + } + for _, flag := range payload.Data.Flags { + flag = strings.ToUpper(flag) + if _, ok := expected[flag]; !ok { + t.Fatalf("unexpected flag %s returned", flag) + } + delete(expected, flag) + } + if len(expected) != 0 { + t.Fatalf("missing expected flags in response: %v", expected) + } + }) + + t.Run("update product with invalid supplier category fails", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodPatch, fmt.Sprintf("/api/master-data/products/%d", productID), map[string]any{ + "supplier_ids": []uint{bopSupplier}, + }) + if resp.StatusCode != fiber.StatusBadRequest { + t.Fatalf("expected 400 when updating with non SAPRONAK supplier, got %d: %s", resp.StatusCode, string(body)) + } + }) + + t.Run("update product with invalid flags fails", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodPatch, fmt.Sprintf("/api/master-data/products/%d", productID), map[string]any{ + "flags": []string{"UNKNOWN"}, + }) + if resp.StatusCode != fiber.StatusBadRequest { + t.Fatalf("expected 400 when updating with invalid flags, got %d: %s", resp.StatusCode, string(body)) + } + }) + + t.Run("update product succeeds", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodPatch, fmt.Sprintf("/api/master-data/products/%d", productID), map[string]any{ + "name": "Layer Feed Updated", + "brand": "Brand C", + "sku": "lfd-100", + "product_price": 18000, + "selling_price": 19000, + "tax": 1000, + "expiry_period": 30, + "supplier_ids": []uint{sapSupplier1}, + "flags": updatedProductFlags, + }) + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("expected 200 when updating product, got %d: %s", resp.StatusCode, string(body)) + } + var payload struct { + Data struct { + Name string `json:"name"` + Brand string `json:"brand"` + Sku *string `json:"sku"` + ProductPrice float64 `json:"product_price"` + SupplierIds []map[string]any `json:"suppliers"` + Flags []string `json:"flags"` + } `json:"data"` + } + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatalf("failed to parse update response: %v", err) + } + if payload.Data.Name != "Layer Feed Updated" { + t.Fatalf("expected updated name, got %q", payload.Data.Name) + } + if len(payload.Data.Flags) != len(updatedProductFlags) { + t.Fatalf("expected %d flags in response, got %d", len(updatedProductFlags), len(payload.Data.Flags)) + } + respFlags := make(map[string]struct{}, len(payload.Data.Flags)) + for _, flag := range payload.Data.Flags { + respFlags[strings.ToUpper(flag)] = struct{}{} + } + for _, flag := range updatedProductFlags { + if _, ok := respFlags[strings.ToUpper(flag)]; !ok { + t.Fatalf("missing flag %s in response", flag) + } + } + product := fetchProduct(t, db, productID) + if product.Brand != "Brand C" { + t.Fatalf("expected persisted brand Brand C, got %q", product.Brand) + } + if product.Sku == nil || *product.Sku != "LFD-100" { + t.Fatalf("expected persisted sku LFD-100, got %+v", product.Sku) + } + if len(product.Suppliers) != 1 || product.Suppliers[0].Id != sapSupplier1 { + t.Fatalf("expected supplier to be %d", sapSupplier1) + } + if len(product.Flags) != len(updatedProductFlags) { + t.Fatalf("expected %d flags after update, got %d", len(updatedProductFlags), len(product.Flags)) + } + expectedFlags := make(map[string]struct{}, len(updatedProductFlags)) + for _, flag := range updatedProductFlags { + expectedFlags[strings.ToUpper(flag)] = struct{}{} + } + for _, flag := range product.Flags { + upper := strings.ToUpper(flag.Name) + if _, ok := expectedFlags[upper]; !ok { + t.Fatalf("unexpected flag after update: %s", upper) + } + delete(expectedFlags, upper) + } + if len(expectedFlags) != 0 { + t.Fatalf("missing flags after update: %v", expectedFlags) + } + }) + + t.Run("clear suppliers succeeds", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodPatch, fmt.Sprintf("/api/master-data/products/%d", productID), map[string]any{ + "supplier_ids": []uint{}, + }) + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("expected 200 when clearing suppliers, got %d: %s", resp.StatusCode, string(body)) + } + product := fetchProduct(t, db, productID) + if len(product.Suppliers) != 0 { + t.Fatalf("expected no suppliers after clearing, got %d", len(product.Suppliers)) + } + if len(product.Flags) != len(updatedProductFlags) { + t.Fatalf("expected flags untouched after clearing suppliers, got %d", len(product.Flags)) + } + }) + + t.Run("clear flags succeeds", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodPatch, fmt.Sprintf("/api/master-data/products/%d", productID), map[string]any{ + "flags": []string{}, + }) + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("expected 200 when clearing flags, got %d: %s", resp.StatusCode, string(body)) + } + product := fetchProduct(t, db, productID) + if len(product.Flags) != 0 { + t.Fatalf("expected all flags cleared, got %d", len(product.Flags)) + } + }) + + t.Run("delete product succeeds", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodDelete, fmt.Sprintf("/api/master-data/products/%d", productID), nil) + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("expected 200 when deleting product, got %d: %s", resp.StatusCode, string(body)) + } + var product entities.Product + if err := db.First(&product, productID).Error; !errors.Is(err, gorm.ErrRecordNotFound) { + t.Fatalf("expected product deleted, got error %v", err) + } + var links int64 + if err := db.Model(&entities.ProductSupplier{}).Where("product_id = ?", productID).Count(&links).Error; err != nil { + t.Fatalf("failed counting product suppliers: %v", err) + } + if links != 0 { + t.Fatalf("expected pivot cleared, found %d rows", links) + } + var flagCount int64 + if err := db.Model(&entities.Flag{}). + Where("flagable_id = ? AND flagable_type = ?", productID, entities.FlagableTypeProduct). + Count(&flagCount).Error; err != nil { + t.Fatalf("failed counting product flags: %v", err) + } + if flagCount != 0 { + t.Fatalf("expected flags removed, found %d", flagCount) + } + }) + + t.Run("delete missing product returns 404", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodDelete, fmt.Sprintf("/api/master-data/products/%d", productID), nil) + if resp.StatusCode != fiber.StatusNotFound { + t.Fatalf("expected 404 when deleting missing product, got %d: %s", resp.StatusCode, string(body)) + } + }) + + t.Run("get deleted product returns 404", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodGet, fmt.Sprintf("/api/master-data/products/%d", productID), nil) + if resp.StatusCode != fiber.StatusNotFound { + t.Fatalf("expected 404 when fetching deleted product, got %d: %s", resp.StatusCode, string(body)) + } + }) +} diff --git a/test/integration/master_data/supplier_test.go b/test/integration/master_data/supplier_test.go new file mode 100644 index 00000000..2a53d9a5 --- /dev/null +++ b/test/integration/master_data/supplier_test.go @@ -0,0 +1,238 @@ +package test + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "testing" + + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/utils" +) + +func TestSupplierIntegration(t *testing.T) { + app, db := setupIntegrationApp(t) + + t.Run("creating supplier with invalid type fails", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodPost, "/api/master-data/suppliers", map[string]any{ + "name": "Invalid Supplier", + "alias": "inv01", + "pic": "Jane Doe", + "type": "random-type", + "category": utils.SupplierCategoryBOP, + "hatchery": "Hatchery X", + "phone": "081234567891", + "email": "invalid@supplier.com", + "address": "Somewhere", + "npwp": "NPWP-INVALID", + "account_number": "ACC-INVALID", + "due_date": 30, + }) + if resp.StatusCode != fiber.StatusBadRequest { + t.Fatalf("expected 400 when type is invalid, got %d: %s", resp.StatusCode, string(body)) + } + }) + + t.Run("creating supplier with invalid category fails", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodPost, "/api/master-data/suppliers", map[string]any{ + "name": "Invalid Category Supplier", + "alias": "cat01", + "pic": "Jane Doe", + "type": utils.CustomerSupplierTypeBisnis, + "category": "invalid", + "phone": "081234567892", + "email": "invalid-category@supplier.com", + "address": "Somewhere", + "due_date": 30, + }) + if resp.StatusCode != fiber.StatusBadRequest { + t.Fatalf("expected 400 when category is invalid, got %d: %s", resp.StatusCode, string(body)) + } + }) + + const supplierName = "Supplier Alpha" + const alias = "al001" + var supplierID uint + + t.Run("creating supplier succeeds", func(t *testing.T) { + supplierID = createSupplier(t, app, supplierName, alias, string(utils.SupplierCategoryBOP)) + supplier := fetchSupplier(t, db, supplierID) + if supplier.Name != supplierName { + t.Fatalf("expected name %q, got %q", supplierName, supplier.Name) + } + if supplier.Alias != strings.ToUpper(alias) { + t.Fatalf("expected alias %q, got %q", strings.ToUpper(alias), supplier.Alias) + } + if supplier.Type != string(utils.CustomerSupplierTypeBisnis) { + t.Fatalf("expected type %q, got %q", utils.CustomerSupplierTypeBisnis, supplier.Type) + } + if supplier.Category != string(utils.SupplierCategoryBOP) { + t.Fatalf("expected category %q, got %q", utils.SupplierCategoryBOP, supplier.Category) + } + if supplier.DueDate != 30 { + t.Fatalf("expected due date 30, got %d", supplier.DueDate) + } + if supplier.CreatedUser.Id != 1 { + t.Fatalf("expected created user id 1, got %d", supplier.CreatedUser.Id) + } + }) + + t.Run("creating supplier with duplicate name fails", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodPost, "/api/master-data/suppliers", map[string]any{ + "name": supplierName, + "alias": "dup01", + "pic": "Jane Doe", + "type": utils.CustomerSupplierTypeBisnis, + "category": utils.SupplierCategoryBOP, + "phone": "0811111111", + "email": "duplicate@supplier.com", + "address": "Duplicate address", + "due_date": 15, + }) + if resp.StatusCode != fiber.StatusConflict { + t.Fatalf("expected 409 when creating duplicate supplier, got %d: %s", resp.StatusCode, string(body)) + } + }) + + t.Run("getting existing supplier succeeds", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodGet, fmt.Sprintf("/api/master-data/suppliers/%d", supplierID), nil) + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("expected 200 when fetching supplier, got %d: %s", resp.StatusCode, string(body)) + } + var payload struct { + Data struct { + Id uint `json:"id"` + Name string `json:"name"` + Alias string `json:"alias"` + Type string `json:"type"` + Category string `json:"category"` + CreatedUser *struct { + Id uint `json:"id"` + } `json:"created_user"` + } `json:"data"` + } + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatalf("failed to parse supplier response: %v", err) + } + if payload.Data.Id != supplierID { + t.Fatalf("expected id %d, got %d", supplierID, payload.Data.Id) + } + if payload.Data.Name != supplierName { + t.Fatalf("expected name %q, got %q", supplierName, payload.Data.Name) + } + if payload.Data.Alias != strings.ToUpper(alias) { + t.Fatalf("expected alias %q, got %q", strings.ToUpper(alias), payload.Data.Alias) + } + if payload.Data.Type != string(utils.CustomerSupplierTypeBisnis) { + t.Fatalf("expected type %q, got %q", utils.CustomerSupplierTypeBisnis, payload.Data.Type) + } + if payload.Data.Category != string(utils.SupplierCategoryBOP) { + t.Fatalf("expected category %q, got %q", utils.SupplierCategoryBOP, payload.Data.Category) + } + if payload.Data.CreatedUser == nil || payload.Data.CreatedUser.Id != 1 { + t.Fatalf("expected created_user id 1, got %+v", payload.Data.CreatedUser) + } + }) + + const updatedAlias = "beta1" + + t.Run("updating supplier fields succeeds", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodPatch, fmt.Sprintf("/api/master-data/suppliers/%d", supplierID), map[string]any{ + "alias": updatedAlias, + "type": utils.CustomerSupplierTypeIndividual, + "category": utils.SupplierCategorySapronak, + "due_date": 45, + }) + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("expected 200 when updating supplier, got %d: %s", resp.StatusCode, string(body)) + } + var payload struct { + Data struct { + Alias string `json:"alias"` + Type string `json:"type"` + Category string `json:"category"` + } `json:"data"` + } + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatalf("failed to parse update response: %v", err) + } + if payload.Data.Alias != strings.ToUpper(updatedAlias) { + t.Fatalf("expected alias %q, got %q", strings.ToUpper(updatedAlias), payload.Data.Alias) + } + if payload.Data.Type != string(utils.CustomerSupplierTypeIndividual) { + t.Fatalf("expected type %q, got %q", utils.CustomerSupplierTypeIndividual, payload.Data.Type) + } + if payload.Data.Category != string(utils.SupplierCategorySapronak) { + t.Fatalf("expected category %q, got %q", utils.SupplierCategorySapronak, payload.Data.Category) + } + supplier := fetchSupplier(t, db, supplierID) + if supplier.Alias != strings.ToUpper(updatedAlias) { + t.Fatalf("expected persisted alias %q, got %q", strings.ToUpper(updatedAlias), supplier.Alias) + } + if supplier.Type != string(utils.CustomerSupplierTypeIndividual) { + t.Fatalf("expected persisted type %q, got %q", utils.CustomerSupplierTypeIndividual, supplier.Type) + } + if supplier.Category != string(utils.SupplierCategorySapronak) { + t.Fatalf("expected persisted category %q, got %q", utils.SupplierCategorySapronak, supplier.Category) + } + if supplier.DueDate != 45 { + t.Fatalf("expected due date 45, got %d", supplier.DueDate) + } + }) + + t.Run("updating supplier with invalid type fails", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodPatch, fmt.Sprintf("/api/master-data/suppliers/%d", supplierID), map[string]any{ + "type": "invalid-type", + }) + if resp.StatusCode != fiber.StatusBadRequest { + t.Fatalf("expected 400 when updating with invalid type, got %d: %s", resp.StatusCode, string(body)) + } + supplier := fetchSupplier(t, db, supplierID) + if supplier.Type != string(utils.CustomerSupplierTypeIndividual) { + t.Fatalf("expected type to remain %q after invalid update, got %q", utils.CustomerSupplierTypeIndividual, supplier.Type) + } + }) + + t.Run("updating supplier with invalid category fails", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodPatch, fmt.Sprintf("/api/master-data/suppliers/%d", supplierID), map[string]any{ + "category": "invalid", + }) + if resp.StatusCode != fiber.StatusBadRequest { + t.Fatalf("expected 400 when updating with invalid category, got %d: %s", resp.StatusCode, string(body)) + } + supplier := fetchSupplier(t, db, supplierID) + if supplier.Category != string(utils.SupplierCategorySapronak) { + t.Fatalf("expected category to remain %q after invalid update, got %q", utils.SupplierCategorySapronak, supplier.Category) + } + }) + + t.Run("deleting supplier succeeds", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodDelete, fmt.Sprintf("/api/master-data/suppliers/%d", supplierID), nil) + if resp.StatusCode != fiber.StatusOK { + t.Fatalf("expected 200 when deleting supplier, got %d: %s", resp.StatusCode, string(body)) + } + var supplier entities.Supplier + if err := db.First(&supplier, supplierID).Error; !errors.Is(err, gorm.ErrRecordNotFound) { + t.Fatalf("expected supplier to be deleted, got error %v", err) + } + }) + + t.Run("deleting non existent supplier returns 404", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodDelete, fmt.Sprintf("/api/master-data/suppliers/%d", supplierID), nil) + if resp.StatusCode != fiber.StatusNotFound { + t.Fatalf("expected 404 when deleting missing supplier, got %d: %s", resp.StatusCode, string(body)) + } + }) + + t.Run("getting deleted supplier returns 404", func(t *testing.T) { + resp, body := doJSONRequest(t, app, http.MethodGet, fmt.Sprintf("/api/master-data/suppliers/%d", supplierID), nil) + if resp.StatusCode != fiber.StatusNotFound { + t.Fatalf("expected 404 when fetching deleted supplier, got %d: %s", resp.StatusCode, string(body)) + } + }) +} diff --git a/tools/gen.go b/tools/gen.go index 9906b8fa..a14ecc2c 100644 --- a/tools/gen.go +++ b/tools/gen.go @@ -29,6 +29,7 @@ func main() { feat := os.Args[1] parts := strings.Split(feat, "/") entity := parts[len(parts)-1] + pluralEntityKebab := toPlural(toKebab(entity)) d := Data{ FeatName: feat, @@ -52,43 +53,43 @@ func main() { }, { TplPath: "tools/templates/validation.tmpl", - OutDir: filepath.Join("internal", "modules", toKebabPath(d.Parts[:len(d.Parts)-1]), toKebab(d.Entity)+"s", "validations"), + OutDir: filepath.Join("internal", "modules", toKebabPath(d.Parts[:len(d.Parts)-1]), pluralEntityKebab, "validations"), OutSuffix: ".validation.go", TplName: "validation", }, { TplPath: "tools/templates/service.tmpl", - OutDir: filepath.Join("internal", "modules", toKebabPath(d.Parts[:len(d.Parts)-1]), toKebab(d.Entity)+"s", "services"), + OutDir: filepath.Join("internal", "modules", toKebabPath(d.Parts[:len(d.Parts)-1]), pluralEntityKebab, "services"), OutSuffix: ".service.go", TplName: "service", }, { TplPath: "tools/templates/controller.tmpl", - OutDir: filepath.Join("internal", "modules", toKebabPath(d.Parts[:len(d.Parts)-1]), toKebab(d.Entity)+"s", "controllers"), + OutDir: filepath.Join("internal", "modules", toKebabPath(d.Parts[:len(d.Parts)-1]), pluralEntityKebab, "controllers"), OutSuffix: ".controller.go", TplName: "controller", }, { TplPath: "tools/templates/repository.tmpl", - OutDir: filepath.Join("internal", "modules", toKebabPath(d.Parts[:len(d.Parts)-1]), toKebab(d.Entity)+"s", "repositories"), + OutDir: filepath.Join("internal", "modules", toKebabPath(d.Parts[:len(d.Parts)-1]), pluralEntityKebab, "repositories"), OutSuffix: ".repository.go", TplName: "repository", }, { TplPath: "tools/templates/dto.tmpl", - OutDir: filepath.Join("internal", "modules", toKebabPath(d.Parts[:len(d.Parts)-1]), toKebab(d.Entity)+"s", "dto"), + OutDir: filepath.Join("internal", "modules", toKebabPath(d.Parts[:len(d.Parts)-1]), pluralEntityKebab, "dto"), OutSuffix: ".dto.go", TplName: "dto", }, { TplPath: "tools/templates/route.tmpl", - OutDir: filepath.Join("internal", "modules", toKebabPath(d.Parts[:len(d.Parts)-1]), toKebab(d.Entity)+"s"), + OutDir: filepath.Join("internal", "modules", toKebabPath(d.Parts[:len(d.Parts)-1]), pluralEntityKebab), OutSuffix: "", TplName: "route", }, { TplPath: "tools/templates/module.tmpl", - OutDir: filepath.Join("internal", "modules", toKebabPath(d.Parts[:len(d.Parts)-1]), toKebab(d.Entity)+"s"), + OutDir: filepath.Join("internal", "modules", toKebabPath(d.Parts[:len(d.Parts)-1]), pluralEntityKebab), OutSuffix: "", TplName: "module", }, diff --git a/tools/templates/dto.tmpl b/tools/templates/dto.tmpl index 3780b080..a03d7018 100644 --- a/tools/templates/dto.tmpl +++ b/tools/templates/dto.tmpl @@ -56,4 +56,10 @@ func To{{Pascal .Entity}}ListDTOs(e []entity.{{Pascal .Entity}}) []{{Pascal .Ent } return result } + +func To{{Pascal .Entity}}DetailDTO(e entity.{{Pascal .Entity}}) {{Pascal .Entity}}DetailDTO { + return {{Pascal .Entity}}DetailDTO{ + {{Pascal .Entity}}ListDTO: To{{Pascal .Entity}}ListDTO(e), + } +} {{end}} diff --git a/tools/templates/entity.tmpl b/tools/templates/entity.tmpl index 87da6f9d..70def390 100644 --- a/tools/templates/entity.tmpl +++ b/tools/templates/entity.tmpl @@ -8,7 +8,7 @@ import ( type {{Pascal .Entity}} struct { Id uint `gorm:"primaryKey"` - Name string `gorm:"not null"` + Name string `gorm:"not null;uniqueIndex:idx_name,where:deleted_at IS NULL"` CreatedBy uint `gorm:"not null"` CreatedAt time.Time `gorm:"autoCreateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"` diff --git a/tools/templates/service.tmpl b/tools/templates/service.tmpl index 8977a921..bbcee5f1 100644 --- a/tools/templates/service.tmpl +++ b/tools/templates/service.tmpl @@ -88,7 +88,7 @@ func (s *{{Camel .Entity}}Service) CreateOne(c *fiber.Ctx, req *validation.Creat return nil, err } - return createBody, nil + return s.GetOne(c, createBody.Id) } func (s {{Camel .Entity}}Service) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.{{Pascal .Entity}}, error) { diff --git a/tools/templates/validation.tmpl b/tools/templates/validation.tmpl index a01cf8c1..3aa587eb 100644 --- a/tools/templates/validation.tmpl +++ b/tools/templates/validation.tmpl @@ -5,7 +5,7 @@ type Create struct { } type Update struct { - Name *string `json:"name,omitempty" validate:"omitempty,max=50"` + Name *string `json:"name,omitempty" validate:"omitempty"` } type Query struct {