From 79b3dd47b8ca8f89ad061321fe6913e3206c01d9 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Thu, 23 Oct 2025 22:00:55 +0700 Subject: [PATCH 01/10] fix[BE]: validate query page/limit defaults and add exists helpers --- .../repository/common.exists.repository.go | 19 +++++++++++++++++++ .../areas/controllers/area.controller.go | 4 ++++ .../banks/controllers/bank.controller.go | 4 ++++ .../banks/repositories/bank.repository.go | 5 +++++ .../master/banks/services/bank.service.go | 7 +++++++ .../controllers/customer.controller.go | 4 ++++ .../master/fcrs/controllers/fcr.controller.go | 4 ++++ .../flocks/controllers/flock.controller.go | 4 ++++ .../controllers/kandang.controller.go | 4 ++++ .../controllers/location.controller.go | 4 ++++ .../controllers/nonstock.controller.go | 4 ++++ .../product-category.controller.go | 4 ++++ .../controllers/product.controller.go | 4 ++++ .../controllers/supplier.controller.go | 4 ++++ .../repositories/supplier.repository.go | 6 +++++- .../suppliers/services/supplier.service.go | 13 +++++++++++++ .../master/uoms/controllers/uom.controller.go | 4 ++++ .../controllers/warehouse.controller.go | 4 ++++ tools/templates/controller.tmpl | 4 ++++ 19 files changed, 105 insertions(+), 1 deletion(-) diff --git a/internal/common/repository/common.exists.repository.go b/internal/common/repository/common.exists.repository.go index ef371330..c6bc11f0 100644 --- a/internal/common/repository/common.exists.repository.go +++ b/internal/common/repository/common.exists.repository.go @@ -2,6 +2,7 @@ package repository import ( "context" + "fmt" "gorm.io/gorm" ) @@ -32,3 +33,21 @@ func ExistsByName[T any](ctx context.Context, db *gorm.DB, name string, excludeI } return count > 0, nil } + +func ExistsByField[T any](ctx context.Context, db *gorm.DB, field string, value any, excludeID *uint) (bool, error) { + if field == "" { + return false, fmt.Errorf("field is required") + } + var count int64 + q := db.WithContext(ctx). + Model(new(T)). + Where(fmt.Sprintf("%s = ?", field), value). + 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/areas/controllers/area.controller.go b/internal/modules/master/areas/controllers/area.controller.go index e08dba7d..252bc769 100644 --- a/internal/modules/master/areas/controllers/area.controller.go +++ b/internal/modules/master/areas/controllers/area.controller.go @@ -29,6 +29,10 @@ func (u *AreaController) GetAll(c *fiber.Ctx) error { Search: c.Query("search", ""), } + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + result, totalResults, err := u.AreaService.GetAll(c, query) if err != nil { return err diff --git a/internal/modules/master/banks/controllers/bank.controller.go b/internal/modules/master/banks/controllers/bank.controller.go index 7625d078..ffe61cea 100644 --- a/internal/modules/master/banks/controllers/bank.controller.go +++ b/internal/modules/master/banks/controllers/bank.controller.go @@ -29,6 +29,10 @@ func (u *BankController) GetAll(c *fiber.Ctx) error { Search: c.Query("search", ""), } + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + result, totalResults, err := u.BankService.GetAll(c, query) if err != nil { return err diff --git a/internal/modules/master/banks/repositories/bank.repository.go b/internal/modules/master/banks/repositories/bank.repository.go index 53d27713..d309d3c1 100644 --- a/internal/modules/master/banks/repositories/bank.repository.go +++ b/internal/modules/master/banks/repositories/bank.repository.go @@ -11,6 +11,7 @@ import ( type BankRepository interface { repository.BaseRepository[entity.Bank] NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) + AccountNumberExists(ctx context.Context, accountNumber string, excludeID *uint) (bool, error) } type BankRepositoryImpl struct { @@ -28,3 +29,7 @@ func NewBankRepository(db *gorm.DB) BankRepository { func (r *BankRepositoryImpl) NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) { return repository.ExistsByName[entity.Bank](ctx, r.db, name, excludeID) } + +func (r *BankRepositoryImpl) AccountNumberExists(ctx context.Context, accountNumber string, excludeID *uint) (bool, error) { + return repository.ExistsByField[entity.Bank](ctx, r.db, "account_number", accountNumber, excludeID) +} diff --git a/internal/modules/master/banks/services/bank.service.go b/internal/modules/master/banks/services/bank.service.go index b62bf864..83d3029d 100644 --- a/internal/modules/master/banks/services/bank.service.go +++ b/internal/modules/master/banks/services/bank.service.go @@ -87,6 +87,13 @@ func (s *bankService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.B return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Bank with name %s already exists", req.Name)) } + if exists, err := s.Repository.AccountNumberExists(c.Context(), req.AccountNumber, nil); err != nil { + s.Log.Errorf("Failed to check bank account number: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check bank account number") + } else if exists { + return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Bank with account number %s already exists", req.AccountNumber)) + } + createBody := &entity.Bank{ Name: req.Name, Alias: req.Alias, diff --git a/internal/modules/master/customers/controllers/customer.controller.go b/internal/modules/master/customers/controllers/customer.controller.go index 2f9c0ed4..02805f6f 100644 --- a/internal/modules/master/customers/controllers/customer.controller.go +++ b/internal/modules/master/customers/controllers/customer.controller.go @@ -29,6 +29,10 @@ func (u *CustomerController) GetAll(c *fiber.Ctx) error { Search: c.Query("search", ""), } + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + result, totalResults, err := u.CustomerService.GetAll(c, query) if err != nil { return err diff --git a/internal/modules/master/fcrs/controllers/fcr.controller.go b/internal/modules/master/fcrs/controllers/fcr.controller.go index 33353ffa..52db463d 100644 --- a/internal/modules/master/fcrs/controllers/fcr.controller.go +++ b/internal/modules/master/fcrs/controllers/fcr.controller.go @@ -29,6 +29,10 @@ func (u *FcrController) GetAll(c *fiber.Ctx) error { Search: c.Query("search", ""), } + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + result, totalResults, err := u.FcrService.GetAll(c, query) if err != nil { return err diff --git a/internal/modules/master/flocks/controllers/flock.controller.go b/internal/modules/master/flocks/controllers/flock.controller.go index 8265f3e4..f8df0587 100644 --- a/internal/modules/master/flocks/controllers/flock.controller.go +++ b/internal/modules/master/flocks/controllers/flock.controller.go @@ -29,6 +29,10 @@ func (u *FlockController) GetAll(c *fiber.Ctx) error { Search: c.Query("search", ""), } + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + result, totalResults, err := u.FlockService.GetAll(c, query) if err != nil { return err diff --git a/internal/modules/master/kandangs/controllers/kandang.controller.go b/internal/modules/master/kandangs/controllers/kandang.controller.go index 23d22334..b1d016df 100644 --- a/internal/modules/master/kandangs/controllers/kandang.controller.go +++ b/internal/modules/master/kandangs/controllers/kandang.controller.go @@ -31,6 +31,10 @@ func (u *KandangController) GetAll(c *fiber.Ctx) error { PicId: c.QueryInt("pic_id", 0), } + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + result, totalResults, err := u.KandangService.GetAll(c, query) if err != nil { return err diff --git a/internal/modules/master/locations/controllers/location.controller.go b/internal/modules/master/locations/controllers/location.controller.go index 8f8211d7..f360a9c9 100644 --- a/internal/modules/master/locations/controllers/location.controller.go +++ b/internal/modules/master/locations/controllers/location.controller.go @@ -30,6 +30,10 @@ func (u *LocationController) GetAll(c *fiber.Ctx) error { AreaId: c.QueryInt("area_id", 0), } + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + result, totalResults, err := u.LocationService.GetAll(c, query) if err != nil { return err diff --git a/internal/modules/master/nonstocks/controllers/nonstock.controller.go b/internal/modules/master/nonstocks/controllers/nonstock.controller.go index d8b688b7..d991c4da 100644 --- a/internal/modules/master/nonstocks/controllers/nonstock.controller.go +++ b/internal/modules/master/nonstocks/controllers/nonstock.controller.go @@ -29,6 +29,10 @@ func (u *NonstockController) GetAll(c *fiber.Ctx) error { Search: c.Query("search", ""), } + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + result, totalResults, err := u.NonstockService.GetAll(c, query) if err != nil { return err diff --git a/internal/modules/master/product-categories/controllers/product-category.controller.go b/internal/modules/master/product-categories/controllers/product-category.controller.go index 778a3188..e4531a1f 100644 --- a/internal/modules/master/product-categories/controllers/product-category.controller.go +++ b/internal/modules/master/product-categories/controllers/product-category.controller.go @@ -29,6 +29,10 @@ func (u *ProductCategoryController) GetAll(c *fiber.Ctx) error { Search: c.Query("search", ""), } + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + result, totalResults, err := u.ProductCategoryService.GetAll(c, query) if err != nil { return err diff --git a/internal/modules/master/products/controllers/product.controller.go b/internal/modules/master/products/controllers/product.controller.go index ee2c95f8..197a6b5f 100644 --- a/internal/modules/master/products/controllers/product.controller.go +++ b/internal/modules/master/products/controllers/product.controller.go @@ -30,6 +30,10 @@ func (u *ProductController) GetAll(c *fiber.Ctx) error { ProductCategoryID: c.QueryInt("product_category_id", 0), } + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + result, totalResults, err := u.ProductService.GetAll(c, query) if err != nil { return err diff --git a/internal/modules/master/suppliers/controllers/supplier.controller.go b/internal/modules/master/suppliers/controllers/supplier.controller.go index a76904a9..5d70e43e 100644 --- a/internal/modules/master/suppliers/controllers/supplier.controller.go +++ b/internal/modules/master/suppliers/controllers/supplier.controller.go @@ -29,6 +29,10 @@ func (u *SupplierController) GetAll(c *fiber.Ctx) error { Search: c.Query("search", ""), } + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + result, totalResults, err := u.SupplierService.GetAll(c, query) if err != nil { return err diff --git a/internal/modules/master/suppliers/repositories/supplier.repository.go b/internal/modules/master/suppliers/repositories/supplier.repository.go index 46fb2983..6b5a0ae2 100644 --- a/internal/modules/master/suppliers/repositories/supplier.repository.go +++ b/internal/modules/master/suppliers/repositories/supplier.repository.go @@ -11,7 +11,7 @@ import ( type SupplierRepository interface { repository.BaseRepository[entity.Supplier] NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) - + AliasExists(ctx context.Context, alias string, excludeID *uint) (bool, error) } type SupplierRepositoryImpl struct { @@ -29,3 +29,7 @@ func NewSupplierRepository(db *gorm.DB) SupplierRepository { func (r *SupplierRepositoryImpl) NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) { return repository.ExistsByName[entity.Supplier](ctx, r.db, name, excludeID) } + +func (r *SupplierRepositoryImpl) AliasExists(ctx context.Context, alias string, excludeID *uint) (bool, error) { + return repository.ExistsByField[entity.Supplier](ctx, r.db, "alias", alias, excludeID) +} diff --git a/internal/modules/master/suppliers/services/supplier.service.go b/internal/modules/master/suppliers/services/supplier.service.go index f8422350..99e15b29 100644 --- a/internal/modules/master/suppliers/services/supplier.service.go +++ b/internal/modules/master/suppliers/services/supplier.service.go @@ -88,6 +88,13 @@ func (s *supplierService) CreateOne(c *fiber.Ctx, req *validation.Create) (*enti return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Supplier with name %s already exists", req.Name)) } + if exists, err := s.Repository.AliasExists(c.Context(), strings.TrimSpace(strings.ToUpper(req.Alias)), nil); err != nil { + s.Log.Errorf("Failed to check supplier alias: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check supplier alias") + } else if exists { + return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Supplier with alias %s already exists", strings.TrimSpace(strings.ToUpper(req.Alias)))) + } + typ := strings.ToUpper(req.Type) if !utils.IsValidCustomerSupplierType(typ) { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid supplier type") @@ -143,6 +150,12 @@ func (s supplierService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint } if req.Alias != nil { + if exists, err := s.Repository.AliasExists(c.Context(), strings.TrimSpace(strings.ToUpper(*req.Alias)), &id); err != nil { + s.Log.Errorf("Failed to check supplier alias: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check supplier alias") + } else if exists { + return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Supplier with alias %s already exists", strings.TrimSpace(strings.ToUpper(*req.Alias)))) + } updateBody["alias"] = strings.TrimSpace(strings.ToUpper(*req.Alias)) } diff --git a/internal/modules/master/uoms/controllers/uom.controller.go b/internal/modules/master/uoms/controllers/uom.controller.go index 0bd3a382..ecef1f69 100644 --- a/internal/modules/master/uoms/controllers/uom.controller.go +++ b/internal/modules/master/uoms/controllers/uom.controller.go @@ -29,6 +29,10 @@ func (u *UomController) GetAll(c *fiber.Ctx) error { Search: c.Query("search", ""), } + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + result, totalResults, err := u.UomService.GetAll(c, query) if err != nil { return err diff --git a/internal/modules/master/warehouses/controllers/warehouse.controller.go b/internal/modules/master/warehouses/controllers/warehouse.controller.go index b841d4ef..afa90660 100644 --- a/internal/modules/master/warehouses/controllers/warehouse.controller.go +++ b/internal/modules/master/warehouses/controllers/warehouse.controller.go @@ -30,6 +30,10 @@ func (u *WarehouseController) GetAll(c *fiber.Ctx) error { AreaId: c.QueryInt("area_id", 0), } + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + result, totalResults, err := u.WarehouseService.GetAll(c, query) if err != nil { return err diff --git a/tools/templates/controller.tmpl b/tools/templates/controller.tmpl index 9fcf6d9b..f2eb615e 100644 --- a/tools/templates/controller.tmpl +++ b/tools/templates/controller.tmpl @@ -29,6 +29,10 @@ func (u *{{Pascal .Entity}}Controller) GetAll(c *fiber.Ctx) error { Search: c.Query("search", ""), } + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + result, totalResults, err := u.{{Pascal .Entity}}Service.GetAll(c, query) if err != nil { return err From aeeb5a38c137a1044898f7409a310d9e4e72165b Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Fri, 24 Oct 2025 09:51:50 +0700 Subject: [PATCH 02/10] Feat[BE] : add avaibility DOC on lookup porject flock API, add note request json on chickin --- .../product_warehouse.controller.go | 6 +- .../services/product_warehouse.service.go | 20 +++ .../chickins/services/chickin.service.go | 6 +- .../validations/chickin.validation.go | 1 + .../controllers/projectflock.controller.go | 30 ++++- .../dto/projectflock_kandang.dto.go | 41 +++--- .../production/project_flocks/module.go | 6 +- .../services/projectflock.service.go | 123 +++++++++--------- 8 files changed, 137 insertions(+), 96 deletions(-) diff --git a/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller.go b/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller.go index a0b72a4d..b44eab28 100644 --- a/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller.go +++ b/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller.go @@ -30,6 +30,10 @@ func (u *ProductWarehouseController) GetAll(c *fiber.Ctx) error { WarehouseId: uint(c.QueryInt("warehouse_id", 0)), } + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + result, totalResults, err := u.ProductWarehouseService.GetAll(c, query) if err != nil { return err @@ -71,5 +75,3 @@ func (u *ProductWarehouseController) GetOne(c *fiber.Ctx) error { Data: dto.ToProductWarehouseListDTO(*result), }) } - - diff --git a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go index 4fad5dc5..e9e31ab5 100644 --- a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go +++ b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go @@ -49,6 +49,26 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) return nil, 0, err } + if params.ProductId > 0 { + isProductExist, err := s.Repository.IsProductExist(c.Context(), params.ProductId) + if err != nil { + return nil, 0, err + } + if !isProductExist { + return nil, 0, fiber.NewError(fiber.StatusNotFound, "Product not found") + } + } + + if params.WarehouseId > 0 { + isWarehouseExist, err := s.Repository.IsWarehouseExist(c.Context(), params.WarehouseId) + if err != nil { + return nil, 0, err + } + if !isWarehouseExist { + return nil, 0, fiber.NewError(fiber.StatusNotFound, "Warehouse not found") + } + } + offset := (params.Page - 1) * params.Limit productWarehouses, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index 0df1b6b5..66793c8c 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -136,8 +136,6 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit if len(productWarehouses) == 0 { return nil, fiber.NewError(fiber.StatusNotFound, "Product Warehouse not found for the given Project Flock and Warehouse") } - - // Jumlahkan semua quantity DOC totalQuantity := 0.0 for _, pw := range productWarehouses { totalQuantity += pw.Quantity @@ -147,7 +145,6 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit return nil, fiber.NewError(fiber.StatusBadRequest, "Insufficient quantity in Product Warehouses") } - // Buat satu chickin dengan total quantity chickinDate, err := utils.ParseDateString(req.ChickInDate) if err != nil { s.Log.Errorf("Failed to parse chickin date: %+v", err) @@ -157,7 +154,7 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit ProjectFlockKandangId: projectflockkandang.Id, ChickInDate: chickinDate, Quantity: totalQuantity, - Note: "", + Note: req.Note, CreatedBy: 1, //todo: ganti dengan user login } err = s.Repository.CreateOne(c.Context(), newChickin, nil) @@ -176,7 +173,6 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit return nil, err } - // add ke detail chickin newChickinDetail := &entity.ProjectChickinDetail{ ProjectChickinId: newChickin.Id, ProductWarehouseId: pw.Id, diff --git a/internal/modules/production/chickins/validations/chickin.validation.go b/internal/modules/production/chickins/validations/chickin.validation.go index c122c100..66d4924c 100644 --- a/internal/modules/production/chickins/validations/chickin.validation.go +++ b/internal/modules/production/chickins/validations/chickin.validation.go @@ -3,6 +3,7 @@ package validation type Create struct { ProjectFlockKandangId uint `json:"project_flock_kandang_id" validate:"required,number,min=1"` ChickInDate string `json:"chick_in_date" validate:"required,datetime=2006-01-02"` + Note string `json:"note" validate:"omitempty` } type Update struct { diff --git a/internal/modules/production/project_flocks/controllers/projectflock.controller.go b/internal/modules/production/project_flocks/controllers/projectflock.controller.go index ca60d5df..668743b3 100644 --- a/internal/modules/production/project_flocks/controllers/projectflock.controller.go +++ b/internal/modules/production/project_flocks/controllers/projectflock.controller.go @@ -246,17 +246,39 @@ func (u *ProjectflockController) GetFlockPeriodSummary(c *fiber.Ctx) error { } func (u *ProjectflockController) LookupProjectFlockKandang(c *fiber.Ctx) error { - projectFlockIdStr := c.Query("project_flock_id", "") - kandangIdStr := c.Query("kandang_id", "") + projectFlockId := c.QueryInt("project_flock_id", 0) + kandangId := c.QueryInt("kandang_id", 0) - result, err := u.ProjectflockService.GetProjectFlockKandangByParams(c, "", projectFlockIdStr, kandangIdStr) + if projectFlockId == 0 || kandangId == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_id or kandang_id") + } + + result, availableStock, err := u.ProjectflockService.GetProjectFlockKandangByProjectAndKandang(c, uint(projectFlockId), uint(kandangId)) if err != nil { return err } + dtoResult := dto.ToProjectFlockKandangDTO(*result) + dtoResult.AvailableQuantity = float64(availableStock) + + // populate available quantity for each kandang inside project_flock + if dtoResult.ProjectFlock != nil { + for i := range dtoResult.ProjectFlock.Kandangs { + kand := &dtoResult.ProjectFlock.Kandangs[i] + if kand.Id == 0 { + continue + } + if q, qerr := u.ProjectflockService.GetAvailableDocQuantity(c, kand.Id); qerr == nil { + kand.AvailableQuantity = q + } + } + // remove inner kandangs from project_flock to avoid duplication + dtoResult.ProjectFlock.Kandangs = nil + } + return c.Status(fiber.StatusOK). JSON(response.Success{Code: fiber.StatusOK, Status: "success", Message: "Get projectflock kandang successfully", - Data: dto.ToProjectFlockKandangDTO(*result)}) + Data: dtoResult}) } diff --git a/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go b/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go index ff82fba9..27a68011 100644 --- a/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go +++ b/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go @@ -10,10 +10,9 @@ import ( userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" ) -// internal DTO used only for lookup response: project flock with kandangs carrying pivot ids type KandangWithPivotDTO struct { kandangDTO.KandangBaseDTO - ProjectFlockKandangId *uint `json:"project_flock_kandang_id,omitempty"` + AvailableQuantity float64 `json:"available_quantity"` } type ProjectFlockWithPivotDTO struct { @@ -28,11 +27,13 @@ type ProjectFlockWithPivotDTO struct { } type ProjectFlockKandangDTO struct { - Id uint `json:"id"` - ProjectFlockId uint `json:"project_flock_id"` - KandangId uint `json:"kandang_id"` - Kandang *kandangDTO.KandangBaseDTO `json:"kandang,omitempty"` - ProjectFlock *ProjectFlockWithPivotDTO `json:"project_flock,omitempty"` + Id uint `json:"id"` + ProjectFlockKandangId uint `json:"project_flock_kandang_id"` + ProjectFlockId uint `json:"project_flock_id"` + KandangId uint `json:"kandang_id"` + Kandang *kandangDTO.KandangBaseDTO `json:"kandang,omitempty"` + ProjectFlock *ProjectFlockWithPivotDTO `json:"project_flock,omitempty"` + AvailableQuantity float64 `json:"available_quantity"` } func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangDTO { @@ -44,7 +45,7 @@ func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangD var pf *ProjectFlockWithPivotDTO if e.ProjectFlock.Id != 0 { - // build project flock with kandangs that include pivot ids + pfLocal := ProjectFlockWithPivotDTO{ ProjectFlockBaseDTO: ProjectFlockBaseDTO{ Id: e.ProjectFlock.Id, @@ -53,7 +54,6 @@ func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangD Category: e.ProjectFlock.Category, } - // fill related small summaries if e.ProjectFlock.Flock.Id != 0 { mapped := ToFlockSummaryDTO(e.ProjectFlock.Flock) pfLocal.Flock = &mapped @@ -75,23 +75,16 @@ func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangD pfLocal.CreatedUser = &mapped } - // build pivot map pivotMap := make(map[uint]uint) for _, ph := range e.ProjectFlock.KandangHistory { pivotMap[ph.KandangId] = ph.Id } - // populate kandangs with pivot ids for _, k := range e.ProjectFlock.Kandangs { kb := kandangDTO.ToKandangBaseDTO(k) - var pid *uint - if v, ok := pivotMap[k.Id]; ok { - vv := v - pid = &vv - } pfLocal.Kandangs = append(pfLocal.Kandangs, KandangWithPivotDTO{ - KandangBaseDTO: kb, - ProjectFlockKandangId: pid, + KandangBaseDTO: kb, + AvailableQuantity: 0, }) } @@ -99,10 +92,12 @@ func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangD } return ProjectFlockKandangDTO{ - Id: e.Id, - ProjectFlockId: e.ProjectFlockId, - KandangId: e.KandangId, - Kandang: kandang, - ProjectFlock: pf, + Id: e.Id, + ProjectFlockKandangId: e.Id, + ProjectFlockId: e.ProjectFlockId, + KandangId: e.KandangId, + Kandang: kandang, + ProjectFlock: pf, + AvailableQuantity: 0, } } diff --git a/internal/modules/production/project_flocks/module.go b/internal/modules/production/project_flocks/module.go index 994eb4a4..4fd932a4 100644 --- a/internal/modules/production/project_flocks/module.go +++ b/internal/modules/production/project_flocks/module.go @@ -9,8 +9,10 @@ import ( commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" "gorm.io/gorm" + rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" rFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/repositories" rKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" + rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" rProjectflock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" sProjectflock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/services" @@ -27,6 +29,8 @@ func (ProjectflockModule) RegisterRoutes(router fiber.Router, db *gorm.DB, valid kandangRepo := rKandang.NewKandangRepository(db) projectflockRepo := rProjectflock.NewProjectflockRepository(db) projectflockKandangRepo := rProjectflock.NewProjectFlockKandangRepository(db) + warehouseRepo := rWarehouse.NewWarehouseRepository(db) + productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) userRepo := rUser.NewUserRepository(db) approvalRepo := commonRepo.NewApprovalRepository(db) @@ -35,7 +39,7 @@ func (ProjectflockModule) RegisterRoutes(router fiber.Router, db *gorm.DB, valid panic(fmt.Sprintf("failed to register project flock approval workflow: %v", err)) } - projectflockService := sProjectflock.NewProjectflockService(projectflockRepo, flockRepo, kandangRepo, projectflockKandangRepo, approvalService, validate) + projectflockService := sProjectflock.NewProjectflockService(projectflockRepo, flockRepo, kandangRepo, projectflockKandangRepo, warehouseRepo, productWarehouseRepo, approvalService, validate) userService := sUser.NewUserService(userRepo, validate) ProjectflockRoutes(router, userService, projectflockService) diff --git a/internal/modules/production/project_flocks/services/projectflock.service.go b/internal/modules/production/project_flocks/services/projectflock.service.go index f9c7881e..23097585 100644 --- a/internal/modules/production/project_flocks/services/projectflock.service.go +++ b/internal/modules/production/project_flocks/services/projectflock.service.go @@ -4,14 +4,15 @@ import ( "context" "errors" "fmt" - "strconv" "strings" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + productWarehouseRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" flockRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/repositories" kandangRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" + warehouseRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/validations" utils "gitlab.com/mbugroup/lti-api.git/internal/utils" @@ -29,20 +30,23 @@ type ProjectflockService interface { CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.ProjectFlock, error) UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.ProjectFlock, error) DeleteOne(ctx *fiber.Ctx, id uint) error - GetProjectFlockKandangByParams(ctx *fiber.Ctx, idStr string, projectFlockIdStr string, kandangIdStr string) (*entity.ProjectFlockKandang, error) + GetProjectFlockKandangByProjectAndKandang(ctx *fiber.Ctx, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, int, error) + GetAvailableDocQuantity(ctx *fiber.Ctx, kandangID uint) (float64, error) GetFlockPeriodSummary(ctx *fiber.Ctx, flockID uint) (*FlockPeriodSummary, error) Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.ProjectFlock, error) } type projectflockService struct { - Log *logrus.Logger - Validate *validator.Validate - Repository repository.ProjectflockRepository - FlockRepo flockRepository.FlockRepository - KandangRepo kandangRepository.KandangRepository - PivotRepo repository.ProjectFlockKandangRepository - ApprovalSvc commonSvc.ApprovalService - approvalWorkflow approvalutils.ApprovalWorkflowKey + Log *logrus.Logger + Validate *validator.Validate + Repository repository.ProjectflockRepository + FlockRepo flockRepository.FlockRepository + KandangRepo kandangRepository.KandangRepository + WarehouseRepo warehouseRepository.WarehouseRepository + ProductWarehouseRepo productWarehouseRepository.ProductWarehouseRepository + ProjectFlockKandangRepo repository.ProjectFlockKandangRepository + ApprovalSvc commonSvc.ApprovalService + approvalWorkflow approvalutils.ApprovalWorkflowKey } type FlockPeriodSummary struct { @@ -54,19 +58,23 @@ func NewProjectflockService( repo repository.ProjectflockRepository, flockRepo flockRepository.FlockRepository, kandangRepo kandangRepository.KandangRepository, - pivotRepo repository.ProjectFlockKandangRepository, + ProjectFlockKandangRepo repository.ProjectFlockKandangRepository, + warehouseRepo warehouseRepository.WarehouseRepository, + productWarehouseRepo productWarehouseRepository.ProductWarehouseRepository, approvalSvc commonSvc.ApprovalService, validate *validator.Validate, ) ProjectflockService { return &projectflockService{ - Log: utils.Log, - Validate: validate, - Repository: repo, - FlockRepo: flockRepo, - KandangRepo: kandangRepo, - PivotRepo: pivotRepo, - ApprovalSvc: approvalSvc, - approvalWorkflow: utils.ApprovalWorkflowProjectFlock, + Log: utils.Log, + Validate: validate, + Repository: repo, + FlockRepo: flockRepo, + KandangRepo: kandangRepo, + WarehouseRepo: warehouseRepo, + ProductWarehouseRepo: productWarehouseRepo, + ProjectFlockKandangRepo: ProjectFlockKandangRepo, + ApprovalSvc: approvalSvc, + approvalWorkflow: utils.ApprovalWorkflowProjectFlock, } } @@ -641,55 +649,48 @@ func (s projectflockService) DeleteOne(c *fiber.Ctx, id uint) error { return nil } -func (s projectflockService) GetProjectFlockKandang(ctx *fiber.Ctx, id uint) (*entity.ProjectFlockKandang, error) { - // keep for backward compatibility; delegate to new consolidated method - return s.GetProjectFlockKandangByParams(ctx, fmt.Sprintf("%d", id), "", "") -} +func (s projectflockService) GetProjectFlockKandangByProjectAndKandang(ctx *fiber.Ctx, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, int, error) { -func (s projectflockService) GetProjectFlockKandangByProjectAndKandang(ctx *fiber.Ctx, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, error) { + availableStock, err := s.GetAvailableDocQuantity(ctx, kandangID) + if err != nil { + return nil, 0, err + } - pfk, err := s.PivotRepo.GetByProjectFlockAndKandang(ctx.Context(), projectFlockID, kandangID) + projectFlockKandang, err := s.ProjectFlockKandangRepo.GetByProjectFlockAndKandang(ctx.Context(), projectFlockID, kandangID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "ProjectFlockKandang not found") + return nil, 0, fiber.NewError(fiber.StatusNotFound, "ProjectFlockKandang not found") } - return nil, err + return nil, 0, err } - return pfk, nil + + return projectFlockKandang, int(availableStock), nil } -func (s projectflockService) GetProjectFlockKandangByParams(ctx *fiber.Ctx, idStr string, projectFlockIdStr string, kandangIdStr string) (*entity.ProjectFlockKandang, error) { - idStr = strings.TrimSpace(idStr) - projectFlockIdStr = strings.TrimSpace(projectFlockIdStr) - kandangIdStr = strings.TrimSpace(kandangIdStr) +func (s projectflockService) GetAvailableDocQuantity(ctx *fiber.Ctx, kandangID uint) (float64, error) { - if idStr != "" { - id, err := strconv.Atoi(idStr) - if err != nil || id <= 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid Id") - } - pfk, err := s.PivotRepo.GetByID(ctx.Context(), uint(id)) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "ProjectFlockKandang not found") - } - return nil, err - } - return pfk, nil + wh, err := s.WarehouseRepo.GetByKandangID(ctx.Context(), kandangID) + if err != nil { + return 0, err } - if projectFlockIdStr == "" || kandangIdStr == "" { - return nil, fiber.NewError(fiber.StatusBadRequest, "Missing lookup parameters") + var productWarehouses []entity.ProductWarehouse + err = s.ProductWarehouseRepo.DB(). + WithContext(ctx.Context()). + Joins("JOIN products ON products.id = product_warehouses.product_id"). + Joins("JOIN product_categories ON product_categories.id = products.product_category_id"). + Where("product_categories.code = ? AND product_warehouses.warehouse_id = ?", "DOC", wh.Id). + Order("created_at DESC"). + Find(&productWarehouses).Error + if err != nil { + return 0, err } - pfid, err := strconv.Atoi(projectFlockIdStr) - if err != nil || pfid <= 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_id") + + total := 0.0 + for _, pw := range productWarehouses { + total += pw.Quantity } - kid, err := strconv.Atoi(kandangIdStr) - if err != nil || kid <= 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid kandang_id") - } - return s.GetProjectFlockKandangByProjectAndKandang(ctx, uint(pfid), uint(kid)) + return total, nil } func (s projectflockService) GetFlockPeriodSummary(c *fiber.Ctx, flockID uint) (*FlockPeriodSummary, error) { @@ -784,7 +785,7 @@ func (s projectflockService) attachKandangs(ctx context.Context, dbTransaction * return fiber.NewError(fiber.StatusInternalServerError, "Failed to update kandangs") } - pivotRepo := s.pivotRepoWithTx(dbTransaction) + ProjectFlockKandangRepo := s.ProjectFlockKandangRepoWithTx(dbTransaction) records := make([]*entity.ProjectFlockKandang, len(kandangIDs)) for i, id := range kandangIDs { records[i] = &entity.ProjectFlockKandang{ @@ -792,7 +793,7 @@ func (s projectflockService) attachKandangs(ctx context.Context, dbTransaction * KandangId: id, } } - if err := pivotRepo.CreateMany(ctx, records); err != nil { + if err := ProjectFlockKandangRepo.CreateMany(ctx, records); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to persist project flock history") } return nil @@ -814,15 +815,15 @@ func (s projectflockService) detachKandangs(ctx context.Context, dbTransaction * return fiber.NewError(fiber.StatusInternalServerError, "Failed to update kandangs") } - if err := s.pivotRepoWithTx(dbTransaction).DeleteMany(ctx, projectFlockID, kandangIDs); err != nil { + if err := s.ProjectFlockKandangRepoWithTx(dbTransaction).DeleteMany(ctx, projectFlockID, kandangIDs); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to persist project flock history") } return nil } -func (s projectflockService) pivotRepoWithTx(dbTransaction *gorm.DB) repository.ProjectFlockKandangRepository { - if s.PivotRepo == nil { +func (s projectflockService) ProjectFlockKandangRepoWithTx(dbTransaction *gorm.DB) repository.ProjectFlockKandangRepository { + if s.ProjectFlockKandangRepo == nil { return repository.NewProjectFlockKandangRepository(dbTransaction) } - return s.PivotRepo.WithTx(dbTransaction) + return s.ProjectFlockKandangRepo.WithTx(dbTransaction) } From 222d53aa37cd276eef2f2439a533fb39ecb76fe8 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Fri, 24 Oct 2025 10:25:05 +0700 Subject: [PATCH 03/10] FIX[BE] : use repository instead of raw query on service on productflock service --- .../product_warehouse.repository.go | 39 +++++++++++++------ .../chickins/services/chickin.service.go | 11 +----- 2 files changed, 30 insertions(+), 20 deletions(-) diff --git a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go index cc4adf64..f1f1fa57 100644 --- a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go +++ b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go @@ -16,6 +16,7 @@ type ProductWarehouseRepository interface { ProductWarehouseExistByProductAndWarehouseID(ctx context.Context, productId, warehouseId uint) (bool, error) ExistsByID(ctx context.Context, id uint) (bool, error) GetProductWarehouseByProductAndWarehouseID(ctx context.Context, productId, warehouseId uint) (*entity.ProductWarehouse, error) + GetByCategoryCodeAndWarehouseID(ctx context.Context, categoryCode string, warehouseId uint) ([]entity.ProductWarehouse, error) } type ProductWarehouseRepositoryImpl struct { @@ -30,6 +31,17 @@ func NewProductWarehouseRepository(db *gorm.DB) ProductWarehouseRepository { } } +func (r *ProductWarehouseRepositoryImpl) IsProductExist(ctx context.Context, productId uint) (bool, error) { + return repository.Exists[entity.Product](ctx, r.db, productId) +} +func (r *ProductWarehouseRepositoryImpl) IsWarehouseExist(ctx context.Context, warehouseId uint) (bool, error) { + return repository.Exists[entity.Warehouse](ctx, r.db, warehouseId) +} + +func (r *ProductWarehouseRepositoryImpl) ExistsByID(ctx context.Context, id uint) (bool, error) { + return repository.Exists[entity.ProductWarehouse](ctx, r.db, id) +} + func (r *ProductWarehouseRepositoryImpl) ProductWarehouseExists(ctx context.Context, productId, warehouseId uint, excludeID *uint) (bool, error) { var count int64 query := r.db.WithContext(ctx).Model(&entity.ProductWarehouse{}). @@ -43,17 +55,6 @@ func (r *ProductWarehouseRepositoryImpl) ProductWarehouseExists(ctx context.Cont return count > 0, nil } -func (r *ProductWarehouseRepositoryImpl) IsProductExist(ctx context.Context, productId uint) (bool, error) { - return repository.Exists[entity.Product](ctx, r.db, productId) -} -func (r *ProductWarehouseRepositoryImpl) IsWarehouseExist(ctx context.Context, warehouseId uint) (bool, error) { - return repository.Exists[entity.Warehouse](ctx, r.db, warehouseId) -} - -func (r *ProductWarehouseRepositoryImpl) ExistsByID(ctx context.Context, id uint) (bool, error) { - return repository.Exists[entity.ProductWarehouse](ctx, r.db, id) -} - func (r *ProductWarehouseRepositoryImpl) ProductWarehouseExistByProductAndWarehouseID(ctx context.Context, productId, warehouseId uint) (bool, error) { var count int64 if err := r.db.WithContext(ctx). @@ -72,3 +73,19 @@ func (r *ProductWarehouseRepositoryImpl) GetProductWarehouseByProductAndWarehous } return &productWarehouse, nil } + +func (r *ProductWarehouseRepositoryImpl) GetByCategoryCodeAndWarehouseID(ctx context.Context, categoryCode string, warehouseId uint) ([]entity.ProductWarehouse, error) { + var productWarehouses []entity.ProductWarehouse + err := r.db.WithContext(ctx). + Table("product_warehouses"). + Select("product_warehouses.*"). + Joins("JOIN products ON products.id = product_warehouses.product_id"). + Joins("JOIN product_categories ON product_categories.id = products.product_category_id"). + Where("product_categories.code = ? AND product_warehouses.warehouse_id = ?", categoryCode, warehouseId). + Order("product_warehouses.created_at DESC"). + Find(&productWarehouses).Error + if err != nil { + return nil, err + } + return productWarehouses, nil +} diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index 66793c8c..ec2b31aa 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -121,14 +121,8 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit return nil, err } - var productWarehouses []entity.ProductWarehouse - err = s.ProductWarehouseRepo.DB(). - WithContext(c.Context()). - Joins("JOIN products ON products.id = product_warehouses.product_id"). - Joins("JOIN product_categories ON product_categories.id = products.product_category_id"). - Where("product_categories.code = ? AND product_warehouses.warehouse_id = ?", "DOC", warehouse.Id). - Order("created_at DESC"). - Find(&productWarehouses).Error + // move complex DB query into repository for cleaner service + productWarehouses, err := s.ProductWarehouseRepo.GetByCategoryCodeAndWarehouseID(c.Context(), "DOC", warehouse.Id) if err != nil { s.Log.Errorf("Failed to get product warehouses: %+v", err) return nil, err @@ -289,7 +283,6 @@ func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error { return rollback(err) } - // helper: restore quantities from details; returns (restored bool, error) restoreFromDetails := func() (bool, error) { var details []entity.ProjectChickinDetail if err := tx.WithContext(c.Context()).Where("project_chickin_id = ?", chickin.Id).Find(&details).Error; err != nil { From 7f2175a8cfc1a4741068c9913f008dfa03a35edf Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Fri, 24 Oct 2025 11:16:12 +0700 Subject: [PATCH 04/10] Feat[Be-117]: Menambahkan note upda update chickin api --- .../modules/production/chickins/services/chickin.service.go | 3 +++ .../production/chickins/validations/chickin.validation.go | 1 + 2 files changed, 4 insertions(+) diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index ec2b31aa..f422666f 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -222,6 +222,9 @@ func (s chickinService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) if req.ChickInDate != "" { updateBody["chick_in_date"] = req.ChickInDate } + if req.Note != "" { + updateBody["note"] = req.Note + } if len(updateBody) == 0 { return s.GetOne(c, id) } diff --git a/internal/modules/production/chickins/validations/chickin.validation.go b/internal/modules/production/chickins/validations/chickin.validation.go index 66d4924c..9747ee07 100644 --- a/internal/modules/production/chickins/validations/chickin.validation.go +++ b/internal/modules/production/chickins/validations/chickin.validation.go @@ -8,6 +8,7 @@ type Create struct { type Update struct { ChickInDate string `json:"chick_in_date" validate:"required,datetime=2006-01-02"` + Note string `json:"note" validate:"omitempty"` } type Query struct { From ef99a4a3c1f79e1f99b642328242218c0afdea51 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Fri, 24 Oct 2025 13:29:37 +0700 Subject: [PATCH 05/10] FIX[BE] : fix productwarehouses flags faram become multiple param --- .../product_warehouse.controller.go | 1 + .../services/product_warehouse.service.go | 8 ++++ .../product_warehouse.validation.go | 9 +++-- internal/utils/strings.go | 38 ++++++++++++++++++- 4 files changed, 51 insertions(+), 5 deletions(-) diff --git a/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller.go b/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller.go index b44eab28..26f23278 100644 --- a/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller.go +++ b/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller.go @@ -28,6 +28,7 @@ func (u *ProductWarehouseController) GetAll(c *fiber.Ctx) error { Limit: c.QueryInt("limit", 10), ProductId: uint(c.QueryInt("product_id", 0)), WarehouseId: uint(c.QueryInt("warehouse_id", 0)), + Flags: c.Query("flags", ""), } if query.Page < 1 || query.Limit < 1 { diff --git a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go index e9e31ab5..3a0468ca 100644 --- a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go +++ b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go @@ -71,6 +71,8 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) offset := (params.Page - 1) * params.Limit + cleanFlags := utils.ParseFlags(params.Flags) + productWarehouses, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) @@ -82,6 +84,12 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) db = db.Where("warehouse_id = ?", params.WarehouseId) } + if len(cleanFlags) > 0 { + db = db.Joins("JOIN products ON products.id = product_warehouses.product_id"). + Joins("JOIN flags ON flags.flagable_id = products.id AND flags.flagable_type = ?", "products"). + Where("flags.name IN ?", cleanFlags) + } + return db.Order("created_at DESC").Order("updated_at DESC") }) diff --git a/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go b/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go index 02648300..3a3acb28 100644 --- a/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go +++ b/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go @@ -13,8 +13,9 @@ type Update struct { } type Query struct { - Page int `query:"page" validate:"omitempty,number,min=1"` - Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` - ProductId uint `query:"product_id" validate:"omitempty,number,min=1"` - WarehouseId uint `query:"warehouse_id" validate:"omitempty,number,min=1"` + Page int `query:"page" validate:"omitempty,number,min=1"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` + ProductId uint `query:"product_id" validate:"omitempty,number,min=1"` + WarehouseId uint `query:"warehouse_id" validate:"omitempty,number,min=1"` + Flags string `query:"flags" validate:"omitempty"` } diff --git a/internal/utils/strings.go b/internal/utils/strings.go index f6560191..a58ba1ac 100644 --- a/internal/utils/strings.go +++ b/internal/utils/strings.go @@ -1,6 +1,9 @@ package utils -import "strings" +import ( + "sort" + "strings" +) // NormalizeTrim returns the input string without leading/trailing whitespace. func NormalizeTrim(value string) string { @@ -11,3 +14,36 @@ func NormalizeTrim(value string) string { func NormalizeUpper(value string) string { return strings.ToUpper(NormalizeTrim(value)) } + +// NormalizeFlag trims whitespace, removes surrounding brackets/quotes and returns upper-case flag +func NormalizeFlag(value string) string { + v := NormalizeTrim(value) + v = strings.Trim(v, "[]\"'") + return strings.ToUpper(v) +} + +// ParseFlags parses a raw flags string like "[DOC, PAKAN]" or "DOC,PAKAN" +// and returns a deduplicated, sorted slice of normalized flags (upper-case, trimmed). +func ParseFlags(raw string) []string { + if raw == "" { + return nil + } + parts := strings.Split(raw, ",") + set := make(map[string]struct{}, len(parts)) + for _, p := range parts { + f := NormalizeFlag(p) + if f == "" { + continue + } + set[f] = struct{}{} + } + if len(set) == 0 { + return nil + } + res := make([]string, 0, len(set)) + for k := range set { + res = append(res, k) + } + sort.Strings(res) + return res +} From 614da067f78bd9cbea4a61a5e459a9c396dd7773 Mon Sep 17 00:00:00 2001 From: ragilap Date: Tue, 28 Oct 2025 12:22:08 +0700 Subject: [PATCH 06/10] merge: ragil-before-sso from development-before-sso --- .../repository/common.exists.repository.go | 19 ++++ .../product_warehouse.controller.go | 7 +- .../product_warehouse.repository.go | 39 +++++--- .../services/product_warehouse.service.go | 28 ++++++ .../product_warehouse.validation.go | 9 +- .../areas/controllers/area.controller.go | 4 + .../banks/controllers/bank.controller.go | 4 + .../banks/repositories/bank.repository.go | 5 + .../master/banks/services/bank.service.go | 7 ++ .../controllers/customer.controller.go | 4 + .../master/fcrs/controllers/fcr.controller.go | 4 + .../flocks/controllers/flock.controller.go | 4 + .../controllers/kandang.controller.go | 4 + .../controllers/location.controller.go | 4 + .../controllers/nonstock.controller.go | 4 + .../product-category.controller.go | 4 + .../controllers/product.controller.go | 4 + .../controllers/supplier.controller.go | 4 + .../repositories/supplier.repository.go | 6 +- .../suppliers/services/supplier.service.go | 13 +++ .../master/uoms/controllers/uom.controller.go | 4 + .../controllers/warehouse.controller.go | 4 + .../chickins/services/chickin.service.go | 20 ++-- .../validations/chickin.validation.go | 2 + .../controllers/projectflock.controller.go | 30 +++++- .../dto/projectflock_kandang.dto.go | 41 ++++---- .../production/project_flocks/module.go | 6 +- .../services/projectflock.service.go | 93 +++++++++---------- internal/utils/strings.go | 38 +++++++- tools/templates/controller.tmpl | 4 + 30 files changed, 309 insertions(+), 110 deletions(-) diff --git a/internal/common/repository/common.exists.repository.go b/internal/common/repository/common.exists.repository.go index ef371330..c6bc11f0 100644 --- a/internal/common/repository/common.exists.repository.go +++ b/internal/common/repository/common.exists.repository.go @@ -2,6 +2,7 @@ package repository import ( "context" + "fmt" "gorm.io/gorm" ) @@ -32,3 +33,21 @@ func ExistsByName[T any](ctx context.Context, db *gorm.DB, name string, excludeI } return count > 0, nil } + +func ExistsByField[T any](ctx context.Context, db *gorm.DB, field string, value any, excludeID *uint) (bool, error) { + if field == "" { + return false, fmt.Errorf("field is required") + } + var count int64 + q := db.WithContext(ctx). + Model(new(T)). + Where(fmt.Sprintf("%s = ?", field), value). + 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/inventory/product-warehouses/controllers/product_warehouse.controller.go b/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller.go index a0b72a4d..26f23278 100644 --- a/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller.go +++ b/internal/modules/inventory/product-warehouses/controllers/product_warehouse.controller.go @@ -28,6 +28,11 @@ func (u *ProductWarehouseController) GetAll(c *fiber.Ctx) error { Limit: c.QueryInt("limit", 10), ProductId: uint(c.QueryInt("product_id", 0)), WarehouseId: uint(c.QueryInt("warehouse_id", 0)), + Flags: c.Query("flags", ""), + } + + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") } result, totalResults, err := u.ProductWarehouseService.GetAll(c, query) @@ -71,5 +76,3 @@ func (u *ProductWarehouseController) GetOne(c *fiber.Ctx) error { Data: dto.ToProductWarehouseListDTO(*result), }) } - - diff --git a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go index cc4adf64..f1f1fa57 100644 --- a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go +++ b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go @@ -16,6 +16,7 @@ type ProductWarehouseRepository interface { ProductWarehouseExistByProductAndWarehouseID(ctx context.Context, productId, warehouseId uint) (bool, error) ExistsByID(ctx context.Context, id uint) (bool, error) GetProductWarehouseByProductAndWarehouseID(ctx context.Context, productId, warehouseId uint) (*entity.ProductWarehouse, error) + GetByCategoryCodeAndWarehouseID(ctx context.Context, categoryCode string, warehouseId uint) ([]entity.ProductWarehouse, error) } type ProductWarehouseRepositoryImpl struct { @@ -30,6 +31,17 @@ func NewProductWarehouseRepository(db *gorm.DB) ProductWarehouseRepository { } } +func (r *ProductWarehouseRepositoryImpl) IsProductExist(ctx context.Context, productId uint) (bool, error) { + return repository.Exists[entity.Product](ctx, r.db, productId) +} +func (r *ProductWarehouseRepositoryImpl) IsWarehouseExist(ctx context.Context, warehouseId uint) (bool, error) { + return repository.Exists[entity.Warehouse](ctx, r.db, warehouseId) +} + +func (r *ProductWarehouseRepositoryImpl) ExistsByID(ctx context.Context, id uint) (bool, error) { + return repository.Exists[entity.ProductWarehouse](ctx, r.db, id) +} + func (r *ProductWarehouseRepositoryImpl) ProductWarehouseExists(ctx context.Context, productId, warehouseId uint, excludeID *uint) (bool, error) { var count int64 query := r.db.WithContext(ctx).Model(&entity.ProductWarehouse{}). @@ -43,17 +55,6 @@ func (r *ProductWarehouseRepositoryImpl) ProductWarehouseExists(ctx context.Cont return count > 0, nil } -func (r *ProductWarehouseRepositoryImpl) IsProductExist(ctx context.Context, productId uint) (bool, error) { - return repository.Exists[entity.Product](ctx, r.db, productId) -} -func (r *ProductWarehouseRepositoryImpl) IsWarehouseExist(ctx context.Context, warehouseId uint) (bool, error) { - return repository.Exists[entity.Warehouse](ctx, r.db, warehouseId) -} - -func (r *ProductWarehouseRepositoryImpl) ExistsByID(ctx context.Context, id uint) (bool, error) { - return repository.Exists[entity.ProductWarehouse](ctx, r.db, id) -} - func (r *ProductWarehouseRepositoryImpl) ProductWarehouseExistByProductAndWarehouseID(ctx context.Context, productId, warehouseId uint) (bool, error) { var count int64 if err := r.db.WithContext(ctx). @@ -72,3 +73,19 @@ func (r *ProductWarehouseRepositoryImpl) GetProductWarehouseByProductAndWarehous } return &productWarehouse, nil } + +func (r *ProductWarehouseRepositoryImpl) GetByCategoryCodeAndWarehouseID(ctx context.Context, categoryCode string, warehouseId uint) ([]entity.ProductWarehouse, error) { + var productWarehouses []entity.ProductWarehouse + err := r.db.WithContext(ctx). + Table("product_warehouses"). + Select("product_warehouses.*"). + Joins("JOIN products ON products.id = product_warehouses.product_id"). + Joins("JOIN product_categories ON product_categories.id = products.product_category_id"). + Where("product_categories.code = ? AND product_warehouses.warehouse_id = ?", categoryCode, warehouseId). + Order("product_warehouses.created_at DESC"). + Find(&productWarehouses).Error + if err != nil { + return nil, err + } + return productWarehouses, nil +} diff --git a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go index 4fad5dc5..3a0468ca 100644 --- a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go +++ b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go @@ -49,8 +49,30 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) return nil, 0, err } + if params.ProductId > 0 { + isProductExist, err := s.Repository.IsProductExist(c.Context(), params.ProductId) + if err != nil { + return nil, 0, err + } + if !isProductExist { + return nil, 0, fiber.NewError(fiber.StatusNotFound, "Product not found") + } + } + + if params.WarehouseId > 0 { + isWarehouseExist, err := s.Repository.IsWarehouseExist(c.Context(), params.WarehouseId) + if err != nil { + return nil, 0, err + } + if !isWarehouseExist { + return nil, 0, fiber.NewError(fiber.StatusNotFound, "Warehouse not found") + } + } + offset := (params.Page - 1) * params.Limit + cleanFlags := utils.ParseFlags(params.Flags) + productWarehouses, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) @@ -62,6 +84,12 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) db = db.Where("warehouse_id = ?", params.WarehouseId) } + if len(cleanFlags) > 0 { + db = db.Joins("JOIN products ON products.id = product_warehouses.product_id"). + Joins("JOIN flags ON flags.flagable_id = products.id AND flags.flagable_type = ?", "products"). + Where("flags.name IN ?", cleanFlags) + } + return db.Order("created_at DESC").Order("updated_at DESC") }) diff --git a/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go b/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go index 02648300..3a3acb28 100644 --- a/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go +++ b/internal/modules/inventory/product-warehouses/validations/product_warehouse.validation.go @@ -13,8 +13,9 @@ type Update struct { } type Query struct { - Page int `query:"page" validate:"omitempty,number,min=1"` - Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` - ProductId uint `query:"product_id" validate:"omitempty,number,min=1"` - WarehouseId uint `query:"warehouse_id" validate:"omitempty,number,min=1"` + Page int `query:"page" validate:"omitempty,number,min=1"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` + ProductId uint `query:"product_id" validate:"omitempty,number,min=1"` + WarehouseId uint `query:"warehouse_id" validate:"omitempty,number,min=1"` + Flags string `query:"flags" validate:"omitempty"` } diff --git a/internal/modules/master/areas/controllers/area.controller.go b/internal/modules/master/areas/controllers/area.controller.go index e08dba7d..252bc769 100644 --- a/internal/modules/master/areas/controllers/area.controller.go +++ b/internal/modules/master/areas/controllers/area.controller.go @@ -29,6 +29,10 @@ func (u *AreaController) GetAll(c *fiber.Ctx) error { Search: c.Query("search", ""), } + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + result, totalResults, err := u.AreaService.GetAll(c, query) if err != nil { return err diff --git a/internal/modules/master/banks/controllers/bank.controller.go b/internal/modules/master/banks/controllers/bank.controller.go index 7625d078..ffe61cea 100644 --- a/internal/modules/master/banks/controllers/bank.controller.go +++ b/internal/modules/master/banks/controllers/bank.controller.go @@ -29,6 +29,10 @@ func (u *BankController) GetAll(c *fiber.Ctx) error { Search: c.Query("search", ""), } + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + result, totalResults, err := u.BankService.GetAll(c, query) if err != nil { return err diff --git a/internal/modules/master/banks/repositories/bank.repository.go b/internal/modules/master/banks/repositories/bank.repository.go index 53d27713..d309d3c1 100644 --- a/internal/modules/master/banks/repositories/bank.repository.go +++ b/internal/modules/master/banks/repositories/bank.repository.go @@ -11,6 +11,7 @@ import ( type BankRepository interface { repository.BaseRepository[entity.Bank] NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) + AccountNumberExists(ctx context.Context, accountNumber string, excludeID *uint) (bool, error) } type BankRepositoryImpl struct { @@ -28,3 +29,7 @@ func NewBankRepository(db *gorm.DB) BankRepository { func (r *BankRepositoryImpl) NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) { return repository.ExistsByName[entity.Bank](ctx, r.db, name, excludeID) } + +func (r *BankRepositoryImpl) AccountNumberExists(ctx context.Context, accountNumber string, excludeID *uint) (bool, error) { + return repository.ExistsByField[entity.Bank](ctx, r.db, "account_number", accountNumber, excludeID) +} diff --git a/internal/modules/master/banks/services/bank.service.go b/internal/modules/master/banks/services/bank.service.go index b62bf864..83d3029d 100644 --- a/internal/modules/master/banks/services/bank.service.go +++ b/internal/modules/master/banks/services/bank.service.go @@ -87,6 +87,13 @@ func (s *bankService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.B return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Bank with name %s already exists", req.Name)) } + if exists, err := s.Repository.AccountNumberExists(c.Context(), req.AccountNumber, nil); err != nil { + s.Log.Errorf("Failed to check bank account number: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check bank account number") + } else if exists { + return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Bank with account number %s already exists", req.AccountNumber)) + } + createBody := &entity.Bank{ Name: req.Name, Alias: req.Alias, diff --git a/internal/modules/master/customers/controllers/customer.controller.go b/internal/modules/master/customers/controllers/customer.controller.go index 2f9c0ed4..02805f6f 100644 --- a/internal/modules/master/customers/controllers/customer.controller.go +++ b/internal/modules/master/customers/controllers/customer.controller.go @@ -29,6 +29,10 @@ func (u *CustomerController) GetAll(c *fiber.Ctx) error { Search: c.Query("search", ""), } + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + result, totalResults, err := u.CustomerService.GetAll(c, query) if err != nil { return err diff --git a/internal/modules/master/fcrs/controllers/fcr.controller.go b/internal/modules/master/fcrs/controllers/fcr.controller.go index 33353ffa..52db463d 100644 --- a/internal/modules/master/fcrs/controllers/fcr.controller.go +++ b/internal/modules/master/fcrs/controllers/fcr.controller.go @@ -29,6 +29,10 @@ func (u *FcrController) GetAll(c *fiber.Ctx) error { Search: c.Query("search", ""), } + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + result, totalResults, err := u.FcrService.GetAll(c, query) if err != nil { return err diff --git a/internal/modules/master/flocks/controllers/flock.controller.go b/internal/modules/master/flocks/controllers/flock.controller.go index 8265f3e4..f8df0587 100644 --- a/internal/modules/master/flocks/controllers/flock.controller.go +++ b/internal/modules/master/flocks/controllers/flock.controller.go @@ -29,6 +29,10 @@ func (u *FlockController) GetAll(c *fiber.Ctx) error { Search: c.Query("search", ""), } + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + result, totalResults, err := u.FlockService.GetAll(c, query) if err != nil { return err diff --git a/internal/modules/master/kandangs/controllers/kandang.controller.go b/internal/modules/master/kandangs/controllers/kandang.controller.go index 23d22334..b1d016df 100644 --- a/internal/modules/master/kandangs/controllers/kandang.controller.go +++ b/internal/modules/master/kandangs/controllers/kandang.controller.go @@ -31,6 +31,10 @@ func (u *KandangController) GetAll(c *fiber.Ctx) error { PicId: c.QueryInt("pic_id", 0), } + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + result, totalResults, err := u.KandangService.GetAll(c, query) if err != nil { return err diff --git a/internal/modules/master/locations/controllers/location.controller.go b/internal/modules/master/locations/controllers/location.controller.go index 8f8211d7..f360a9c9 100644 --- a/internal/modules/master/locations/controllers/location.controller.go +++ b/internal/modules/master/locations/controllers/location.controller.go @@ -30,6 +30,10 @@ func (u *LocationController) GetAll(c *fiber.Ctx) error { AreaId: c.QueryInt("area_id", 0), } + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + result, totalResults, err := u.LocationService.GetAll(c, query) if err != nil { return err diff --git a/internal/modules/master/nonstocks/controllers/nonstock.controller.go b/internal/modules/master/nonstocks/controllers/nonstock.controller.go index d8b688b7..d991c4da 100644 --- a/internal/modules/master/nonstocks/controllers/nonstock.controller.go +++ b/internal/modules/master/nonstocks/controllers/nonstock.controller.go @@ -29,6 +29,10 @@ func (u *NonstockController) GetAll(c *fiber.Ctx) error { Search: c.Query("search", ""), } + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + result, totalResults, err := u.NonstockService.GetAll(c, query) if err != nil { return err diff --git a/internal/modules/master/product-categories/controllers/product-category.controller.go b/internal/modules/master/product-categories/controllers/product-category.controller.go index 778a3188..e4531a1f 100644 --- a/internal/modules/master/product-categories/controllers/product-category.controller.go +++ b/internal/modules/master/product-categories/controllers/product-category.controller.go @@ -29,6 +29,10 @@ func (u *ProductCategoryController) GetAll(c *fiber.Ctx) error { Search: c.Query("search", ""), } + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + result, totalResults, err := u.ProductCategoryService.GetAll(c, query) if err != nil { return err diff --git a/internal/modules/master/products/controllers/product.controller.go b/internal/modules/master/products/controllers/product.controller.go index ee2c95f8..197a6b5f 100644 --- a/internal/modules/master/products/controllers/product.controller.go +++ b/internal/modules/master/products/controllers/product.controller.go @@ -30,6 +30,10 @@ func (u *ProductController) GetAll(c *fiber.Ctx) error { ProductCategoryID: c.QueryInt("product_category_id", 0), } + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + result, totalResults, err := u.ProductService.GetAll(c, query) if err != nil { return err diff --git a/internal/modules/master/suppliers/controllers/supplier.controller.go b/internal/modules/master/suppliers/controllers/supplier.controller.go index a76904a9..5d70e43e 100644 --- a/internal/modules/master/suppliers/controllers/supplier.controller.go +++ b/internal/modules/master/suppliers/controllers/supplier.controller.go @@ -29,6 +29,10 @@ func (u *SupplierController) GetAll(c *fiber.Ctx) error { Search: c.Query("search", ""), } + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + result, totalResults, err := u.SupplierService.GetAll(c, query) if err != nil { return err diff --git a/internal/modules/master/suppliers/repositories/supplier.repository.go b/internal/modules/master/suppliers/repositories/supplier.repository.go index 46fb2983..6b5a0ae2 100644 --- a/internal/modules/master/suppliers/repositories/supplier.repository.go +++ b/internal/modules/master/suppliers/repositories/supplier.repository.go @@ -11,7 +11,7 @@ import ( type SupplierRepository interface { repository.BaseRepository[entity.Supplier] NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) - + AliasExists(ctx context.Context, alias string, excludeID *uint) (bool, error) } type SupplierRepositoryImpl struct { @@ -29,3 +29,7 @@ func NewSupplierRepository(db *gorm.DB) SupplierRepository { func (r *SupplierRepositoryImpl) NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) { return repository.ExistsByName[entity.Supplier](ctx, r.db, name, excludeID) } + +func (r *SupplierRepositoryImpl) AliasExists(ctx context.Context, alias string, excludeID *uint) (bool, error) { + return repository.ExistsByField[entity.Supplier](ctx, r.db, "alias", alias, excludeID) +} diff --git a/internal/modules/master/suppliers/services/supplier.service.go b/internal/modules/master/suppliers/services/supplier.service.go index f8422350..99e15b29 100644 --- a/internal/modules/master/suppliers/services/supplier.service.go +++ b/internal/modules/master/suppliers/services/supplier.service.go @@ -88,6 +88,13 @@ func (s *supplierService) CreateOne(c *fiber.Ctx, req *validation.Create) (*enti return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Supplier with name %s already exists", req.Name)) } + if exists, err := s.Repository.AliasExists(c.Context(), strings.TrimSpace(strings.ToUpper(req.Alias)), nil); err != nil { + s.Log.Errorf("Failed to check supplier alias: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check supplier alias") + } else if exists { + return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Supplier with alias %s already exists", strings.TrimSpace(strings.ToUpper(req.Alias)))) + } + typ := strings.ToUpper(req.Type) if !utils.IsValidCustomerSupplierType(typ) { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid supplier type") @@ -143,6 +150,12 @@ func (s supplierService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint } if req.Alias != nil { + if exists, err := s.Repository.AliasExists(c.Context(), strings.TrimSpace(strings.ToUpper(*req.Alias)), &id); err != nil { + s.Log.Errorf("Failed to check supplier alias: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check supplier alias") + } else if exists { + return nil, fiber.NewError(fiber.StatusConflict, fmt.Sprintf("Supplier with alias %s already exists", strings.TrimSpace(strings.ToUpper(*req.Alias)))) + } updateBody["alias"] = strings.TrimSpace(strings.ToUpper(*req.Alias)) } diff --git a/internal/modules/master/uoms/controllers/uom.controller.go b/internal/modules/master/uoms/controllers/uom.controller.go index 0bd3a382..ecef1f69 100644 --- a/internal/modules/master/uoms/controllers/uom.controller.go +++ b/internal/modules/master/uoms/controllers/uom.controller.go @@ -29,6 +29,10 @@ func (u *UomController) GetAll(c *fiber.Ctx) error { Search: c.Query("search", ""), } + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + result, totalResults, err := u.UomService.GetAll(c, query) if err != nil { return err diff --git a/internal/modules/master/warehouses/controllers/warehouse.controller.go b/internal/modules/master/warehouses/controllers/warehouse.controller.go index b841d4ef..afa90660 100644 --- a/internal/modules/master/warehouses/controllers/warehouse.controller.go +++ b/internal/modules/master/warehouses/controllers/warehouse.controller.go @@ -30,6 +30,10 @@ func (u *WarehouseController) GetAll(c *fiber.Ctx) error { AreaId: c.QueryInt("area_id", 0), } + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + result, totalResults, err := u.WarehouseService.GetAll(c, query) if err != nil { return err diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index 0df1b6b5..f422666f 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -121,14 +121,8 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit return nil, err } - var productWarehouses []entity.ProductWarehouse - err = s.ProductWarehouseRepo.DB(). - WithContext(c.Context()). - Joins("JOIN products ON products.id = product_warehouses.product_id"). - Joins("JOIN product_categories ON product_categories.id = products.product_category_id"). - Where("product_categories.code = ? AND product_warehouses.warehouse_id = ?", "DOC", warehouse.Id). - Order("created_at DESC"). - Find(&productWarehouses).Error + // move complex DB query into repository for cleaner service + productWarehouses, err := s.ProductWarehouseRepo.GetByCategoryCodeAndWarehouseID(c.Context(), "DOC", warehouse.Id) if err != nil { s.Log.Errorf("Failed to get product warehouses: %+v", err) return nil, err @@ -136,8 +130,6 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit if len(productWarehouses) == 0 { return nil, fiber.NewError(fiber.StatusNotFound, "Product Warehouse not found for the given Project Flock and Warehouse") } - - // Jumlahkan semua quantity DOC totalQuantity := 0.0 for _, pw := range productWarehouses { totalQuantity += pw.Quantity @@ -147,7 +139,6 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit return nil, fiber.NewError(fiber.StatusBadRequest, "Insufficient quantity in Product Warehouses") } - // Buat satu chickin dengan total quantity chickinDate, err := utils.ParseDateString(req.ChickInDate) if err != nil { s.Log.Errorf("Failed to parse chickin date: %+v", err) @@ -157,7 +148,7 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit ProjectFlockKandangId: projectflockkandang.Id, ChickInDate: chickinDate, Quantity: totalQuantity, - Note: "", + Note: req.Note, CreatedBy: 1, //todo: ganti dengan user login } err = s.Repository.CreateOne(c.Context(), newChickin, nil) @@ -176,7 +167,6 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit return nil, err } - // add ke detail chickin newChickinDetail := &entity.ProjectChickinDetail{ ProjectChickinId: newChickin.Id, ProductWarehouseId: pw.Id, @@ -232,6 +222,9 @@ func (s chickinService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) if req.ChickInDate != "" { updateBody["chick_in_date"] = req.ChickInDate } + if req.Note != "" { + updateBody["note"] = req.Note + } if len(updateBody) == 0 { return s.GetOne(c, id) } @@ -293,7 +286,6 @@ func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error { return rollback(err) } - // helper: restore quantities from details; returns (restored bool, error) restoreFromDetails := func() (bool, error) { var details []entity.ProjectChickinDetail if err := tx.WithContext(c.Context()).Where("project_chickin_id = ?", chickin.Id).Find(&details).Error; err != nil { diff --git a/internal/modules/production/chickins/validations/chickin.validation.go b/internal/modules/production/chickins/validations/chickin.validation.go index c122c100..9747ee07 100644 --- a/internal/modules/production/chickins/validations/chickin.validation.go +++ b/internal/modules/production/chickins/validations/chickin.validation.go @@ -3,10 +3,12 @@ package validation type Create struct { ProjectFlockKandangId uint `json:"project_flock_kandang_id" validate:"required,number,min=1"` ChickInDate string `json:"chick_in_date" validate:"required,datetime=2006-01-02"` + Note string `json:"note" validate:"omitempty` } type Update struct { ChickInDate string `json:"chick_in_date" validate:"required,datetime=2006-01-02"` + Note string `json:"note" validate:"omitempty"` } type Query struct { diff --git a/internal/modules/production/project_flocks/controllers/projectflock.controller.go b/internal/modules/production/project_flocks/controllers/projectflock.controller.go index ca60d5df..668743b3 100644 --- a/internal/modules/production/project_flocks/controllers/projectflock.controller.go +++ b/internal/modules/production/project_flocks/controllers/projectflock.controller.go @@ -246,17 +246,39 @@ func (u *ProjectflockController) GetFlockPeriodSummary(c *fiber.Ctx) error { } func (u *ProjectflockController) LookupProjectFlockKandang(c *fiber.Ctx) error { - projectFlockIdStr := c.Query("project_flock_id", "") - kandangIdStr := c.Query("kandang_id", "") + projectFlockId := c.QueryInt("project_flock_id", 0) + kandangId := c.QueryInt("kandang_id", 0) - result, err := u.ProjectflockService.GetProjectFlockKandangByParams(c, "", projectFlockIdStr, kandangIdStr) + if projectFlockId == 0 || kandangId == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_id or kandang_id") + } + + result, availableStock, err := u.ProjectflockService.GetProjectFlockKandangByProjectAndKandang(c, uint(projectFlockId), uint(kandangId)) if err != nil { return err } + dtoResult := dto.ToProjectFlockKandangDTO(*result) + dtoResult.AvailableQuantity = float64(availableStock) + + // populate available quantity for each kandang inside project_flock + if dtoResult.ProjectFlock != nil { + for i := range dtoResult.ProjectFlock.Kandangs { + kand := &dtoResult.ProjectFlock.Kandangs[i] + if kand.Id == 0 { + continue + } + if q, qerr := u.ProjectflockService.GetAvailableDocQuantity(c, kand.Id); qerr == nil { + kand.AvailableQuantity = q + } + } + // remove inner kandangs from project_flock to avoid duplication + dtoResult.ProjectFlock.Kandangs = nil + } + return c.Status(fiber.StatusOK). JSON(response.Success{Code: fiber.StatusOK, Status: "success", Message: "Get projectflock kandang successfully", - Data: dto.ToProjectFlockKandangDTO(*result)}) + Data: dtoResult}) } diff --git a/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go b/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go index ff82fba9..27a68011 100644 --- a/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go +++ b/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go @@ -10,10 +10,9 @@ import ( userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" ) -// internal DTO used only for lookup response: project flock with kandangs carrying pivot ids type KandangWithPivotDTO struct { kandangDTO.KandangBaseDTO - ProjectFlockKandangId *uint `json:"project_flock_kandang_id,omitempty"` + AvailableQuantity float64 `json:"available_quantity"` } type ProjectFlockWithPivotDTO struct { @@ -28,11 +27,13 @@ type ProjectFlockWithPivotDTO struct { } type ProjectFlockKandangDTO struct { - Id uint `json:"id"` - ProjectFlockId uint `json:"project_flock_id"` - KandangId uint `json:"kandang_id"` - Kandang *kandangDTO.KandangBaseDTO `json:"kandang,omitempty"` - ProjectFlock *ProjectFlockWithPivotDTO `json:"project_flock,omitempty"` + Id uint `json:"id"` + ProjectFlockKandangId uint `json:"project_flock_kandang_id"` + ProjectFlockId uint `json:"project_flock_id"` + KandangId uint `json:"kandang_id"` + Kandang *kandangDTO.KandangBaseDTO `json:"kandang,omitempty"` + ProjectFlock *ProjectFlockWithPivotDTO `json:"project_flock,omitempty"` + AvailableQuantity float64 `json:"available_quantity"` } func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangDTO { @@ -44,7 +45,7 @@ func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangD var pf *ProjectFlockWithPivotDTO if e.ProjectFlock.Id != 0 { - // build project flock with kandangs that include pivot ids + pfLocal := ProjectFlockWithPivotDTO{ ProjectFlockBaseDTO: ProjectFlockBaseDTO{ Id: e.ProjectFlock.Id, @@ -53,7 +54,6 @@ func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangD Category: e.ProjectFlock.Category, } - // fill related small summaries if e.ProjectFlock.Flock.Id != 0 { mapped := ToFlockSummaryDTO(e.ProjectFlock.Flock) pfLocal.Flock = &mapped @@ -75,23 +75,16 @@ func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangD pfLocal.CreatedUser = &mapped } - // build pivot map pivotMap := make(map[uint]uint) for _, ph := range e.ProjectFlock.KandangHistory { pivotMap[ph.KandangId] = ph.Id } - // populate kandangs with pivot ids for _, k := range e.ProjectFlock.Kandangs { kb := kandangDTO.ToKandangBaseDTO(k) - var pid *uint - if v, ok := pivotMap[k.Id]; ok { - vv := v - pid = &vv - } pfLocal.Kandangs = append(pfLocal.Kandangs, KandangWithPivotDTO{ - KandangBaseDTO: kb, - ProjectFlockKandangId: pid, + KandangBaseDTO: kb, + AvailableQuantity: 0, }) } @@ -99,10 +92,12 @@ func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangD } return ProjectFlockKandangDTO{ - Id: e.Id, - ProjectFlockId: e.ProjectFlockId, - KandangId: e.KandangId, - Kandang: kandang, - ProjectFlock: pf, + Id: e.Id, + ProjectFlockKandangId: e.Id, + ProjectFlockId: e.ProjectFlockId, + KandangId: e.KandangId, + Kandang: kandang, + ProjectFlock: pf, + AvailableQuantity: 0, } } diff --git a/internal/modules/production/project_flocks/module.go b/internal/modules/production/project_flocks/module.go index 994eb4a4..4fd932a4 100644 --- a/internal/modules/production/project_flocks/module.go +++ b/internal/modules/production/project_flocks/module.go @@ -9,8 +9,10 @@ import ( commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" "gorm.io/gorm" + rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" rFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/repositories" rKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" + rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" rProjectflock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" sProjectflock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/services" @@ -27,6 +29,8 @@ func (ProjectflockModule) RegisterRoutes(router fiber.Router, db *gorm.DB, valid kandangRepo := rKandang.NewKandangRepository(db) projectflockRepo := rProjectflock.NewProjectflockRepository(db) projectflockKandangRepo := rProjectflock.NewProjectFlockKandangRepository(db) + warehouseRepo := rWarehouse.NewWarehouseRepository(db) + productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) userRepo := rUser.NewUserRepository(db) approvalRepo := commonRepo.NewApprovalRepository(db) @@ -35,7 +39,7 @@ func (ProjectflockModule) RegisterRoutes(router fiber.Router, db *gorm.DB, valid panic(fmt.Sprintf("failed to register project flock approval workflow: %v", err)) } - projectflockService := sProjectflock.NewProjectflockService(projectflockRepo, flockRepo, kandangRepo, projectflockKandangRepo, approvalService, validate) + projectflockService := sProjectflock.NewProjectflockService(projectflockRepo, flockRepo, kandangRepo, projectflockKandangRepo, warehouseRepo, productWarehouseRepo, approvalService, validate) userService := sUser.NewUserService(userRepo, validate) ProjectflockRoutes(router, userService, projectflockService) diff --git a/internal/modules/production/project_flocks/services/projectflock.service.go b/internal/modules/production/project_flocks/services/projectflock.service.go index aeef6474..6193a90a 100644 --- a/internal/modules/production/project_flocks/services/projectflock.service.go +++ b/internal/modules/production/project_flocks/services/projectflock.service.go @@ -4,14 +4,15 @@ import ( "context" "errors" "fmt" - "strconv" "strings" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + productWarehouseRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" flockRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/repositories" kandangRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" + warehouseRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/validations" utils "gitlab.com/mbugroup/lti-api.git/internal/utils" @@ -29,20 +30,23 @@ type ProjectflockService interface { CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.ProjectFlock, error) UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.ProjectFlock, error) DeleteOne(ctx *fiber.Ctx, id uint) error - GetProjectFlockKandangByParams(ctx *fiber.Ctx, idStr string, projectFlockIdStr string, kandangIdStr string) (*entity.ProjectFlockKandang, error) + GetProjectFlockKandangByProjectAndKandang(ctx *fiber.Ctx, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, error) + GetAvailableDocQuantity(ctx *fiber.Ctx, kandangID uint) (float64, error) GetFlockPeriodSummary(ctx *fiber.Ctx, flockID uint) (*FlockPeriodSummary, error) Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.ProjectFlock, error) } type projectflockService struct { - Log *logrus.Logger - Validate *validator.Validate - Repository repository.ProjectflockRepository - FlockRepo flockRepository.FlockRepository - KandangRepo kandangRepository.KandangRepository + Log *logrus.Logger + Validate *validator.Validate + Repository repository.ProjectflockRepository + FlockRepo flockRepository.FlockRepository + KandangRepo kandangRepository.KandangRepository + WarehouseRepo warehouseRepository.WarehouseRepository + ProductWarehouseRepo productWarehouseRepository.ProductWarehouseRepository PivotRepo repository.ProjectFlockKandangRepository - ApprovalSvc commonSvc.ApprovalService - approvalWorkflow approvalutils.ApprovalWorkflowKey + ApprovalSvc commonSvc.ApprovalService + approvalWorkflow approvalutils.ApprovalWorkflowKey } type FlockPeriodSummary struct { @@ -55,18 +59,22 @@ func NewProjectflockService( flockRepo flockRepository.FlockRepository, kandangRepo kandangRepository.KandangRepository, pivotRepo repository.ProjectFlockKandangRepository, + warehouseRepo warehouseRepository.WarehouseRepository, + productWarehouseRepo productWarehouseRepository.ProductWarehouseRepository, approvalSvc commonSvc.ApprovalService, validate *validator.Validate, ) ProjectflockService { return &projectflockService{ - Log: utils.Log, - Validate: validate, - Repository: repo, - FlockRepo: flockRepo, - KandangRepo: kandangRepo, + Log: utils.Log, + Validate: validate, + Repository: repo, + FlockRepo: flockRepo, + KandangRepo: kandangRepo, + WarehouseRepo: warehouseRepo, + ProductWarehouseRepo: productWarehouseRepo, PivotRepo: pivotRepo, - ApprovalSvc: approvalSvc, - approvalWorkflow: utils.ApprovalWorkflowProjectFlock, + ApprovalSvc: approvalSvc, + approvalWorkflow: utils.ApprovalWorkflowProjectFlock, } } @@ -648,11 +656,6 @@ func (s projectflockService) DeleteOne(c *fiber.Ctx, id uint) error { return nil } -func (s projectflockService) GetProjectFlockKandang(ctx *fiber.Ctx, id uint) (*entity.ProjectFlockKandang, error) { - // keep for backward compatibility; delegate to new consolidated method - return s.GetProjectFlockKandangByParams(ctx, fmt.Sprintf("%d", id), "", "") -} - func (s projectflockService) GetProjectFlockKandangByProjectAndKandang(ctx *fiber.Ctx, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, error) { pfk, err := s.PivotRepo.GetByProjectFlockAndKandang(ctx.Context(), projectFlockID, kandangID) @@ -665,38 +668,30 @@ func (s projectflockService) GetProjectFlockKandangByProjectAndKandang(ctx *fibe return pfk, nil } -func (s projectflockService) GetProjectFlockKandangByParams(ctx *fiber.Ctx, idStr string, projectFlockIdStr string, kandangIdStr string) (*entity.ProjectFlockKandang, error) { - idStr = strings.TrimSpace(idStr) - projectFlockIdStr = strings.TrimSpace(projectFlockIdStr) - kandangIdStr = strings.TrimSpace(kandangIdStr) +func (s projectflockService) GetAvailableDocQuantity(ctx *fiber.Ctx, kandangID uint) (float64, error) { - if idStr != "" { - id, err := strconv.Atoi(idStr) - if err != nil || id <= 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid Id") - } - pfk, err := s.PivotRepo.GetByID(ctx.Context(), uint(id)) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "ProjectFlockKandang not found") - } - return nil, err - } - return pfk, nil + wh, err := s.WarehouseRepo.GetByKandangID(ctx.Context(), kandangID) + if err != nil { + return 0, err } - if projectFlockIdStr == "" || kandangIdStr == "" { - return nil, fiber.NewError(fiber.StatusBadRequest, "Missing lookup parameters") + var productWarehouses []entity.ProductWarehouse + err = s.ProductWarehouseRepo.DB(). + WithContext(ctx.Context()). + Joins("JOIN products ON products.id = product_warehouses.product_id"). + Joins("JOIN product_categories ON product_categories.id = products.product_category_id"). + Where("product_categories.code = ? AND product_warehouses.warehouse_id = ?", "DOC", wh.Id). + Order("created_at DESC"). + Find(&productWarehouses).Error + if err != nil { + return 0, err } - pfid, err := strconv.Atoi(projectFlockIdStr) - if err != nil || pfid <= 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_id") + + total := 0.0 + for _, pw := range productWarehouses { + total += pw.Quantity } - kid, err := strconv.Atoi(kandangIdStr) - if err != nil || kid <= 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid kandang_id") - } - return s.GetProjectFlockKandangByProjectAndKandang(ctx, uint(pfid), uint(kid)) + return total, nil } func (s projectflockService) GetFlockPeriodSummary(c *fiber.Ctx, flockID uint) (*FlockPeriodSummary, error) { @@ -853,7 +848,6 @@ func (s projectflockService) detachKandangs(ctx context.Context, dbTransaction * return nil } - func (s projectflockService) pivotRepoWithTx(dbTransaction *gorm.DB) repository.ProjectFlockKandangRepository { if s.PivotRepo == nil { return repository.NewProjectFlockKandangRepository(dbTransaction) @@ -874,3 +868,4 @@ func (s projectflockService) anyKandangLinkedToOtherProject(ctx context.Context, } return count > 0, nil } + diff --git a/internal/utils/strings.go b/internal/utils/strings.go index f6560191..a58ba1ac 100644 --- a/internal/utils/strings.go +++ b/internal/utils/strings.go @@ -1,6 +1,9 @@ package utils -import "strings" +import ( + "sort" + "strings" +) // NormalizeTrim returns the input string without leading/trailing whitespace. func NormalizeTrim(value string) string { @@ -11,3 +14,36 @@ func NormalizeTrim(value string) string { func NormalizeUpper(value string) string { return strings.ToUpper(NormalizeTrim(value)) } + +// NormalizeFlag trims whitespace, removes surrounding brackets/quotes and returns upper-case flag +func NormalizeFlag(value string) string { + v := NormalizeTrim(value) + v = strings.Trim(v, "[]\"'") + return strings.ToUpper(v) +} + +// ParseFlags parses a raw flags string like "[DOC, PAKAN]" or "DOC,PAKAN" +// and returns a deduplicated, sorted slice of normalized flags (upper-case, trimmed). +func ParseFlags(raw string) []string { + if raw == "" { + return nil + } + parts := strings.Split(raw, ",") + set := make(map[string]struct{}, len(parts)) + for _, p := range parts { + f := NormalizeFlag(p) + if f == "" { + continue + } + set[f] = struct{}{} + } + if len(set) == 0 { + return nil + } + res := make([]string, 0, len(set)) + for k := range set { + res = append(res, k) + } + sort.Strings(res) + return res +} diff --git a/tools/templates/controller.tmpl b/tools/templates/controller.tmpl index 9fcf6d9b..f2eb615e 100644 --- a/tools/templates/controller.tmpl +++ b/tools/templates/controller.tmpl @@ -29,6 +29,10 @@ func (u *{{Pascal .Entity}}Controller) GetAll(c *fiber.Ctx) error { Search: c.Query("search", ""), } + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + result, totalResults, err := u.{{Pascal .Entity}}Service.GetAll(c, query) if err != nil { return err From f869943573cee963d8d310aedf0b32842be0109c Mon Sep 17 00:00:00 2001 From: ragilap Date: Fri, 31 Oct 2025 16:03:05 +0700 Subject: [PATCH 07/10] feat/BE/US-76/US-78/US-79/TASK-112,120,133,121-Recording growing/TASK-187,189,202,190-Recording Laying/TASK-191,192,194,197,203-Grading Telur --- .DS_Store | Bin 8196 -> 6148 bytes ...9_adjustments_flock_project_table.down.sql | 98 +++ ...309_adjustments_flock_project_table.up.sql | 55 ++ ...029070455_update_recording_schema.down.sql | 143 +++ ...51029070455_update_recording_schema.up.sql | 168 ++++ internal/database/seed/seeder.go | 338 ++------ internal/entities/projectflock.go | 5 +- internal/entities/recording.go | 15 +- internal/entities/recording_bw.go | 17 +- internal/entities/recording_depletion.go | 14 +- internal/entities/recording_egg.go | 30 + internal/entities/recording_stock.go | 16 +- .../services/adjustment.service.go | 16 +- .../product_warehouse.repository.go | 73 +- .../services/product_warehouse.service.go | 6 +- .../flocks/repositories/flock.repository.go | 13 + .../repositories/kandang.repository.go | 100 ++- .../production/chickins/dto/chickin.dto.go | 7 +- .../chickins/services/chickin.service.go | 16 +- .../project_flocks/dto/projectflock.dto.go | 17 +- .../dto/projectflock_kandang.dto.go | 17 +- .../repositories/projectflock.repository.go | 196 ++++- .../projectflock_kandang.repository.go | 51 +- .../services/projectflock.service.go | 467 +++++----- .../project_flocks/utils/base_name.go | 25 + .../validations/projectflock.validation.go | 4 +- .../controllers/recording.controller.go | 54 ++ .../recordings/dto/recording.dto.go | 198 +++-- .../modules/production/recordings/module.go | 26 +- .../repositories/recording.repository.go | 145 +++- .../modules/production/recordings/route.go | 2 + .../recordings/services/recording.service.go | 819 ++++++++++++++---- .../validations/recording.validation.go | 38 +- .../repositories/stock-logs.repository.go | 18 + internal/utils/constant.go | 19 + internal/utils/recording/util.recording.go | 96 ++ test/integration/master_data/kandang_test.go | 3 +- .../master_data/project_flock_test.go | 742 ++++++++-------- 38 files changed, 2808 insertions(+), 1259 deletions(-) create mode 100644 internal/database/migrations/20251028110309_adjustments_flock_project_table.down.sql create mode 100644 internal/database/migrations/20251028110309_adjustments_flock_project_table.up.sql create mode 100644 internal/database/migrations/20251029070455_update_recording_schema.down.sql create mode 100644 internal/database/migrations/20251029070455_update_recording_schema.up.sql create mode 100644 internal/entities/recording_egg.go create mode 100644 internal/modules/production/project_flocks/utils/base_name.go create mode 100644 internal/utils/recording/util.recording.go diff --git a/.DS_Store b/.DS_Store index 762745b833fd0736d8845c0cbbaadc601e56427b..4c14efd89e4d913a63e6242a245ab626c5fffe6d 100644 GIT binary patch delta 108 zcmZp1XfcprU|?W$DortDU=RQ@Ie-{Mvv5r;6q~50$SAonU^g?Pv1qd=xJY>wxd!wdlODG{vz delta 250 zcmZoMXmOBWU|?W$DortDU;r^WfEYvza8E20o2aMAD7`UYH}hr%jz7$c**Q2SHn1>C zZ{}e!W2zTpNMXolNM^`pNM$Hu&;_#c8FCp4fMhXHL=PyN%1{QRD}kapo;mr+NjdpR zAd`W3JrL{t2Lm9Bfq{>~1<9;5hD?SWgc(@$&ins=aw?nPW-qoWjGOaCrZP`#;1Og7 m8^s{O4WwN`KG-bC@tt`xzli5#e;y7FMu_JaHplbKVFmz**EW;@ diff --git a/internal/database/migrations/20251028110309_adjustments_flock_project_table.down.sql b/internal/database/migrations/20251028110309_adjustments_flock_project_table.down.sql new file mode 100644 index 00000000..fb46f61e --- /dev/null +++ b/internal/database/migrations/20251028110309_adjustments_flock_project_table.down.sql @@ -0,0 +1,98 @@ +BEGIN; + +DROP INDEX IF EXISTS project_flocks_base_period_unique; + +ALTER TABLE project_flocks + ADD COLUMN IF NOT EXISTS flock_id BIGINT; + +WITH normalized AS ( + SELECT + pf.id, + COALESCE( + NULLIF(TRIM(regexp_replace(pf.flock_name, '\\s+\\d+(\\s+\\d+)*$', '', 'g')), ''), + CONCAT('Project Flock ', pf.id) + ) AS normalized_name, + COALESCE(NULLIF(pf.created_by, 0), 1) AS created_by + FROM project_flocks pf +), +seed_flocks AS ( + SELECT DISTINCT + n.normalized_name, + MIN(n.created_by) AS created_by + FROM normalized n + GROUP BY n.normalized_name +) +INSERT INTO flocks (name, created_by, created_at, updated_at) +SELECT sf.normalized_name, sf.created_by, NOW(), NOW() +FROM seed_flocks sf +ON CONFLICT DO NOTHING; + +WITH normalized AS ( + SELECT + pf.id, + COALESCE( + NULLIF(TRIM(regexp_replace(pf.flock_name, '\\s+\\d+(\\s+\\d+)*$', '', 'g')), ''), + CONCAT('Project Flock ', pf.id) + ) AS normalized_name + FROM project_flocks pf +), +resolved AS ( + SELECT + n.id, + f.id AS flock_id + FROM normalized n + JOIN flocks f ON LOWER(f.name) = LOWER(n.normalized_name) +) +UPDATE project_flocks pf +SET flock_id = resolved.flock_id +FROM resolved +WHERE pf.id = resolved.id; + +WITH missing AS ( + SELECT + pf.id, + COALESCE( + NULLIF(TRIM(regexp_replace(pf.flock_name, '\\s+\\d+(\\s+\\d+)*$', '', 'g')), ''), + CONCAT('Project Flock ', pf.id) + ) AS normalized_name, + COALESCE(NULLIF(pf.created_by, 0), 1) AS created_by + FROM project_flocks pf + WHERE pf.flock_id IS NULL +), +seed_missing AS ( + SELECT DISTINCT normalized_name, created_by FROM missing +) +INSERT INTO flocks (name, created_by, created_at, updated_at) +SELECT sm.normalized_name, sm.created_by, NOW(), NOW() +FROM seed_missing sm +ON CONFLICT DO NOTHING; + +WITH missing AS ( + SELECT + pf.id, + COALESCE( + NULLIF(TRIM(regexp_replace(pf.flock_name, '\\s+\\d+(\\s+\\d+)*$', '', 'g')), ''), + CONCAT('Project Flock ', pf.id) + ) AS normalized_name + FROM project_flocks pf + WHERE pf.flock_id IS NULL +) +UPDATE project_flocks pf +SET flock_id = f.id +FROM missing m +JOIN flocks f ON LOWER(f.name) = LOWER(m.normalized_name) +WHERE pf.id = m.id; + +ALTER TABLE project_flocks + ALTER COLUMN flock_id SET NOT NULL; + +DROP INDEX IF EXISTS project_flocks_flock_name_unique; + +ALTER TABLE project_flocks + DROP COLUMN IF EXISTS flock_name; + +CREATE UNIQUE INDEX IF NOT EXISTS project_flocks_flock_period_unique + ON project_flocks (flock_id, period) + WHERE deleted_at IS NULL; + +COMMIT; diff --git a/internal/database/migrations/20251028110309_adjustments_flock_project_table.up.sql b/internal/database/migrations/20251028110309_adjustments_flock_project_table.up.sql new file mode 100644 index 00000000..febc92d2 --- /dev/null +++ b/internal/database/migrations/20251028110309_adjustments_flock_project_table.up.sql @@ -0,0 +1,55 @@ +BEGIN; + +ALTER TABLE project_flocks + ADD COLUMN IF NOT EXISTS flock_name VARCHAR(255); + +WITH generated_names AS ( + SELECT + pf.id, + COALESCE(f.name, CONCAT('Project Flock ', pf.id)) AS base_name, + pf.period, + ROW_NUMBER() OVER (PARTITION BY COALESCE(f.name, CONCAT('Project Flock ', pf.id)) ORDER BY pf.id) AS rn + FROM project_flocks pf + LEFT JOIN flocks f ON f.id = pf.flock_id +) +UPDATE project_flocks pf +SET flock_name = CASE + WHEN gn.period IS NOT NULL THEN + CASE + WHEN gn.rn = 1 THEN CONCAT(gn.base_name, ' ', gn.period) + ELSE CONCAT(gn.base_name, ' ', gn.period, ' ', gn.rn) + END + ELSE + CASE + WHEN gn.rn = 1 THEN gn.base_name + ELSE CONCAT(gn.base_name, ' ', gn.rn) + END + END +FROM generated_names gn +WHERE pf.id = gn.id + AND (pf.flock_name IS NULL OR pf.flock_name = ''); + +UPDATE project_flocks +SET flock_name = CONCAT('Project Flock ', id) +WHERE flock_name IS NULL OR flock_name = ''; + +ALTER TABLE project_flocks + ALTER COLUMN flock_name SET NOT NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS project_flocks_flock_name_unique + ON project_flocks (flock_name) + WHERE deleted_at IS NULL; + +DROP INDEX IF EXISTS project_flocks_flock_period_unique; + +CREATE UNIQUE INDEX IF NOT EXISTS project_flocks_base_period_unique + ON project_flocks ( + LOWER(TRIM(regexp_replace(flock_name, '\\s+\\d+(\\s+\\d+)*$', '', 'g'))), + period + ) + WHERE deleted_at IS NULL; + +ALTER TABLE project_flocks + DROP COLUMN IF EXISTS flock_id; + +COMMIT; diff --git a/internal/database/migrations/20251029070455_update_recording_schema.down.sql b/internal/database/migrations/20251029070455_update_recording_schema.down.sql new file mode 100644 index 00000000..2c7b558f --- /dev/null +++ b/internal/database/migrations/20251029070455_update_recording_schema.down.sql @@ -0,0 +1,143 @@ +BEGIN; + +-- Drop newly introduced egg tables +DROP TABLE IF EXISTS grading_eggs; +DROP TABLE IF EXISTS recording_eggs; + +-- Revert recording_stocks structure +ALTER TABLE recording_stocks + DROP CONSTRAINT IF EXISTS chk_recording_stocks_nonneg; + +ALTER TABLE recording_stocks + DROP COLUMN IF EXISTS usage_qty, + DROP COLUMN IF EXISTS pending_qty; + +ALTER TABLE recording_stocks + ADD COLUMN increase NUMERIC(10,3), + ADD COLUMN decrease NUMERIC(10,3), + ADD COLUMN usage_amount BIGINT, + ADD COLUMN notes VARCHAR; + +ALTER TABLE recording_stocks + ADD CONSTRAINT chk_recording_stocks_nonneg CHECK ( + (increase IS NULL OR increase >= 0) AND + (decrease IS NULL OR decrease >= 0) AND + (usage_amount IS NULL OR usage_amount >= 0) + ); + +-- Revert recording_depletions structure +ALTER TABLE recording_depletions + DROP CONSTRAINT IF EXISTS chk_recording_depl_qty; + +ALTER TABLE recording_depletions + ALTER COLUMN qty TYPE BIGINT USING COALESCE(qty, 0)::BIGINT; + +ALTER TABLE recording_depletions + RENAME COLUMN qty TO total; + +ALTER TABLE recording_depletions + ADD COLUMN notes VARCHAR; + +ALTER TABLE recording_depletions + ADD CONSTRAINT chk_recording_depl_total CHECK (total >= 0); + +-- Revert recording_bws structure +ALTER TABLE recording_bws + DROP CONSTRAINT IF EXISTS chk_recording_bws_nonneg; + +ALTER TABLE recording_bws + ALTER COLUMN qty TYPE INT USING COALESCE(qty, 0)::INT; + +ALTER TABLE recording_bws + DROP COLUMN IF EXISTS total_weight; + +ALTER TABLE recording_bws + ALTER COLUMN avg_weight TYPE NUMERIC(8,2) USING COALESCE(avg_weight, 0)::NUMERIC(8,2); + +ALTER TABLE recording_bws + RENAME COLUMN avg_weight TO weight; + +ALTER TABLE recording_bws + ADD COLUMN notes VARCHAR; + +UPDATE recording_bws +SET qty = GREATEST(qty, 1); + +ALTER TABLE recording_bws + ADD CONSTRAINT chk_recording_bws_nonneg CHECK (weight >= 0 AND qty >= 1); + +-- Revert recordings header +DROP INDEX IF EXISTS idx_recordings_flock_datetime; + +ALTER TABLE recordings + DROP CONSTRAINT IF EXISTS fk_recordings_project_flock_kandang, + DROP CONSTRAINT IF EXISTS chk_recordings_nonnegatives_v2; + +ALTER TABLE recordings + ALTER COLUMN total_depletion_qty TYPE INT USING COALESCE(total_depletion_qty, 0)::INT, + ALTER COLUMN total_chick_qty TYPE BIGINT USING COALESCE(total_chick_qty, 0)::BIGINT; + +ALTER TABLE recordings + RENAME COLUMN total_depletion_qty TO total_depletion; + +ALTER TABLE recordings + RENAME COLUMN total_chick_qty TO total_chick; + +ALTER TABLE recordings + ADD COLUMN record_date DATE, + ADD COLUMN status INT NOT NULL DEFAULT 0, + ADD COLUMN ontime INT NOT NULL DEFAULT 0, + ADD COLUMN daily_depletion_rate NUMERIC(7,3), + ADD COLUMN cum_depletion INT; + +ALTER TABLE recordings + RENAME COLUMN project_flock_kandangs_id TO project_flock_id; + +ALTER TABLE recordings + ADD CONSTRAINT fk_recordings_project_flock + FOREIGN KEY (project_flock_id) REFERENCES project_flock_kandangs(id); + +ALTER TABLE recordings + ADD CONSTRAINT chk_recordings_status CHECK (status IN (0,1,2,3)); + +ALTER TABLE recordings + ADD CONSTRAINT chk_recordings_ontime CHECK (ontime IN (0,1)); + +ALTER TABLE recordings + ADD CONSTRAINT chk_recordings_nonnegatives CHECK ( + (total_depletion IS NULL OR total_depletion >= 0) AND + (cum_depletion IS NULL OR cum_depletion >= 0) AND + (total_chick IS NULL OR total_chick >= 0) AND + (cum_intake IS NULL OR cum_intake >= 0) AND + (daily_gain IS NULL OR daily_gain >= 0) AND + (avg_daily_gain IS NULL OR avg_daily_gain >= 0) AND + (fcr_value IS NULL OR fcr_value > 0) AND + (daily_depletion_rate IS NULL OR daily_depletion_rate >= 0) AND + (cum_depletion_rate IS NULL OR cum_depletion_rate >= 0) + ); + +-- Ensure new columns carry derived data +UPDATE recordings +SET record_date = (record_datetime AT TIME ZONE 'Asia/Jakarta')::date +WHERE record_date IS NULL; + +-- Restore helper trigger/function and indexes +CREATE OR REPLACE FUNCTION trg_set_record_date() RETURNS trigger AS $$ +BEGIN + NEW.record_date := (NEW.record_datetime AT TIME ZONE 'Asia/Jakarta')::date; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER recordings_set_record_date_trg +BEFORE INSERT OR UPDATE OF record_datetime ON recordings +FOR EACH ROW EXECUTE FUNCTION trg_set_record_date(); + +CREATE INDEX idx_recordings_flock_datetime + ON recordings (project_flock_id, record_datetime); + +CREATE UNIQUE INDEX uq_recordings_flock_record_date + ON recordings (project_flock_id, record_date) + WHERE deleted_at IS NULL; + +COMMIT; diff --git a/internal/database/migrations/20251029070455_update_recording_schema.up.sql b/internal/database/migrations/20251029070455_update_recording_schema.up.sql new file mode 100644 index 00000000..89bcd511 --- /dev/null +++ b/internal/database/migrations/20251029070455_update_recording_schema.up.sql @@ -0,0 +1,168 @@ +BEGIN; + +-- Drop trigger & helper function tied to record_date before removing the column +DROP TRIGGER IF EXISTS recordings_set_record_date_trg ON recordings; +DROP FUNCTION IF EXISTS trg_set_record_date(); + +-- Drop indexes and constraints that reference legacy columns +DROP INDEX IF EXISTS uq_recordings_flock_record_date; +DROP INDEX IF EXISTS idx_recordings_flock_datetime; + +ALTER TABLE recordings + DROP CONSTRAINT IF EXISTS fk_recordings_project_flock, + DROP CONSTRAINT IF EXISTS chk_recordings_status, + DROP CONSTRAINT IF EXISTS chk_recordings_ontime, + DROP CONSTRAINT IF EXISTS chk_recordings_nonnegatives; + +-- Align recordings header with the new schema +ALTER TABLE recordings + RENAME COLUMN project_flock_id TO project_flock_kandangs_id; + +ALTER TABLE recordings + DROP COLUMN IF EXISTS record_date, + DROP COLUMN IF EXISTS status, + DROP COLUMN IF EXISTS ontime, + DROP COLUMN IF EXISTS daily_depletion_rate, + DROP COLUMN IF EXISTS cum_depletion; + +ALTER TABLE recordings + RENAME COLUMN total_depletion TO total_depletion_qty; + +ALTER TABLE recordings + RENAME COLUMN total_chick TO total_chick_qty; + +ALTER TABLE recordings + ALTER COLUMN total_depletion_qty TYPE NUMERIC(15,3) USING COALESCE(total_depletion_qty, 0)::NUMERIC(15,3), + ALTER COLUMN total_chick_qty TYPE NUMERIC(15,3) USING COALESCE(total_chick_qty, 0)::NUMERIC(15,3), + ALTER COLUMN cum_intake TYPE INT USING COALESCE(cum_intake, 0)::INT; + +ALTER TABLE recordings + ADD CONSTRAINT fk_recordings_project_flock_kandang + FOREIGN KEY (project_flock_kandangs_id) REFERENCES project_flock_kandangs(id); + +ALTER TABLE recordings + ADD CONSTRAINT chk_recordings_nonnegatives_v2 CHECK ( + (total_depletion_qty IS NULL OR total_depletion_qty >= 0) AND + (cum_depletion_rate IS NULL OR cum_depletion_rate >= 0) AND + (daily_gain IS NULL OR daily_gain >= 0) AND + (avg_daily_gain IS NULL OR avg_daily_gain >= 0) AND + (cum_intake IS NULL OR cum_intake >= 0) AND + (fcr_value IS NULL OR fcr_value >= 0) AND + (total_chick_qty IS NULL OR total_chick_qty >= 0) + ); + +CREATE INDEX idx_recordings_flock_datetime + ON recordings (project_flock_kandangs_id, record_datetime); + +-- recording_bws reshape +ALTER TABLE recording_bws + RENAME COLUMN weight TO avg_weight; + +ALTER TABLE recording_bws + ALTER COLUMN avg_weight TYPE NUMERIC(8,2) USING COALESCE(avg_weight, 0)::NUMERIC(8,2); + +ALTER TABLE recording_bws + ADD COLUMN total_weight NUMERIC(10,3); + +UPDATE recording_bws +SET total_weight = COALESCE(avg_weight, 0) * COALESCE(qty, 0); + +ALTER TABLE recording_bws + ALTER COLUMN total_weight SET NOT NULL; + +ALTER TABLE recording_bws + ALTER COLUMN qty TYPE NUMERIC(15,3) USING COALESCE(qty, 0)::NUMERIC(15,3); + +ALTER TABLE recording_bws + DROP COLUMN IF EXISTS notes; + +ALTER TABLE recording_bws + DROP CONSTRAINT IF EXISTS chk_recording_bws_nonneg; + +ALTER TABLE recording_bws + ADD CONSTRAINT chk_recording_bws_nonneg CHECK ( + avg_weight >= 0 AND qty >= 0 AND total_weight >= 0 + ); + +-- recording_depletions reshape +ALTER TABLE recording_depletions + RENAME COLUMN total TO qty; + +ALTER TABLE recording_depletions + ALTER COLUMN qty TYPE NUMERIC(15,3) USING COALESCE(qty, 0)::NUMERIC(15,3); + +ALTER TABLE recording_depletions + DROP COLUMN IF EXISTS notes; + +ALTER TABLE recording_depletions + DROP CONSTRAINT IF EXISTS chk_recording_depl_total; + +ALTER TABLE recording_depletions + ADD CONSTRAINT chk_recording_depl_qty CHECK (qty >= 0); + +-- recording_stocks reshape +ALTER TABLE recording_stocks + DROP CONSTRAINT IF EXISTS chk_recording_stocks_nonneg; + +ALTER TABLE recording_stocks + DROP COLUMN IF EXISTS increase, + DROP COLUMN IF EXISTS decrease, + DROP COLUMN IF EXISTS usage_amount, + DROP COLUMN IF EXISTS notes; + +ALTER TABLE recording_stocks + ADD COLUMN usage_qty NUMERIC(15,3), + ADD COLUMN pending_qty NUMERIC(15,3); + +ALTER TABLE recording_stocks + ADD CONSTRAINT chk_recording_stocks_nonneg CHECK ( + (usage_qty IS NULL OR usage_qty >= 0) AND + (pending_qty IS NULL OR pending_qty >= 0) + ); + +-- recording_eggs table +CREATE TABLE recording_eggs ( + id BIGSERIAL PRIMARY KEY, + recording_id BIGINT NOT NULL, + product_warehouse_id BIGINT NOT NULL, + qty INT NOT NULL, + created_by BIGINT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + + CONSTRAINT fk_recording_eggs_recording + FOREIGN KEY (recording_id) REFERENCES recordings(id) ON DELETE CASCADE, + CONSTRAINT fk_recording_eggs_product_warehouse + FOREIGN KEY (product_warehouse_id) REFERENCES product_warehouses(id), + CONSTRAINT fk_recording_eggs_created_by + FOREIGN KEY (created_by) REFERENCES users(id), + CONSTRAINT chk_recording_eggs_qty CHECK (qty >= 0) +); + +CREATE INDEX idx_recording_eggs_recording + ON recording_eggs (recording_id); + +CREATE INDEX idx_recording_eggs_product + ON recording_eggs (product_warehouse_id); + +-- grading_eggs table +CREATE TABLE grading_eggs ( + id BIGSERIAL PRIMARY KEY, + recording_egg_id BIGINT NOT NULL, + qty NUMERIC(15,3) NOT NULL, + grade VARCHAR, + created_by BIGINT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + + CONSTRAINT fk_grading_eggs_recording_egg + FOREIGN KEY (recording_egg_id) REFERENCES recording_eggs(id) ON DELETE CASCADE, + CONSTRAINT fk_grading_eggs_created_by + FOREIGN KEY (created_by) REFERENCES users(id), + CONSTRAINT chk_grading_eggs_qty CHECK (qty >= 0) +); + +CREATE INDEX idx_grading_eggs_recording_egg + ON grading_eggs (recording_egg_id); + +COMMIT; diff --git a/internal/database/seed/seeder.go b/internal/database/seed/seeder.go index 0ce6452b..99188e73 100644 --- a/internal/database/seed/seeder.go +++ b/internal/database/seed/seeder.go @@ -8,7 +8,6 @@ import ( entity "gitlab.com/mbugroup/lti-api.git/internal/entities" "gitlab.com/mbugroup/lti-api.git/internal/utils" - approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" "gorm.io/gorm" ) @@ -41,21 +40,14 @@ func Run(db *gorm.DB) error { return err } - flocks, err := seedFlocks(tx, adminID) - if err != nil { + if _, err := seedFlocks(tx, adminID); err != nil { return err } - fcrs, err := seedFcr(tx, adminID) - if err != nil { + if _, err := seedFcr(tx, adminID); err != nil { return err } - - if err := seedProjectFlocks(tx, adminID, flocks, areas, fcrs, locations); err != nil { - return err - } - kandangs, err := seedKandangs(tx, adminID, locations, users) if err != nil { return err @@ -93,10 +85,6 @@ func Run(db *gorm.DB) error { if err := seedTransferStock(tx, adminID); err != nil { return err } - if err := seedChickin(tx, adminID); err != nil { - return err - } - fmt.Println("✅ Master data seeding completed") return nil }) @@ -243,158 +231,12 @@ func seedFlocks(tx *gorm.DB, createdBy uint) (map[string]uint, error) { return result, nil } -func seedProjectFlocks( - tx *gorm.DB, - createdBy uint, - flocks, areas, fcrs, locations map[string]uint, -) error { - seeds := []struct { - Key string - Flock string - Area string - Category utils.ProjectFlockCategory - Fcr string - Location string - Period int - }{ - { - Key: "Singaparna Period 1", - Flock: "Flock Priangan", - Area: "Priangan", - Category: utils.ProjectFlockCategoryGrowing, - Fcr: "FCR Layer", - Location: "Singaparna", - Period: 1, - }, - { - Key: "Cikaum Period 1", - Flock: "Flock Banten", - Area: "Banten", - Category: utils.ProjectFlockCategoryGrowing, - Fcr: "FCR Layer", - Location: "Cikaum", - Period: 1, - }, - } - - for _, seed := range seeds { - flockID, ok := flocks[seed.Flock] - if !ok { - return fmt.Errorf("floc %s not seeded", seed.Flock) - } - areaID, ok := areas[seed.Area] - if !ok { - return fmt.Errorf("area %s not seeded", seed.Area) - } - fcrID, ok := fcrs[seed.Fcr] - if !ok { - return fmt.Errorf("fcr %s not seeded", seed.Fcr) - } - locationID, ok := locations[seed.Location] - if !ok { - return fmt.Errorf("location %s not seeded", seed.Location) - } - - var projectFlock entity.ProjectFlock - err := tx.Where( - "flock_id = ? AND area_id = ? AND category = ? AND fcr_id = ? AND location_id = ? AND period = ?", - flockID, areaID, seed.Category, fcrID, locationID, seed.Period, - ).First(&projectFlock).Error - - if errors.Is(err, gorm.ErrRecordNotFound) { - projectFlock = entity.ProjectFlock{ - FlockId: flockID, - AreaId: areaID, - Category: string(seed.Category), - FcrId: fcrID, - LocationId: locationID, - Period: seed.Period, - CreatedBy: createdBy, - } - if err := tx.Create(&projectFlock).Error; err != nil { - return err - } - } else if err != nil { - return err - } else { - if err := tx.Model(&entity.ProjectFlock{}).Where("id = ?", projectFlock.Id).Updates(map[string]any{ - "flock_id": flockID, - "area_id": areaID, - "category": string(seed.Category), - "fcr_id": fcrID, - "location_id": locationID, - "period": seed.Period, - }).Error; err != nil { - return err - } - } - - if err := ensureProjectFlockApprovals(tx, projectFlock.Id, createdBy); err != nil { - return err - } - } - - return nil -} - -func ensureProjectFlockApprovals(tx *gorm.DB, projectFlockID uint, actorID uint) error { - if projectFlockID == 0 || actorID == 0 { - return nil - } - - workflow := utils.ApprovalWorkflowProjectFlock.String() - - steps := []struct { - step approvalutils.ApprovalStep - action entity.ApprovalAction - }{ - {step: utils.ProjectFlockStepPengajuan, action: entity.ApprovalActionCreated}, - {step: utils.ProjectFlockStepAktif, action: entity.ApprovalActionApproved}, - } - - for _, cfg := range steps { - var count int64 - if err := tx.Model(&entity.Approval{}). - Where("approvable_type = ? AND approvable_id = ? AND step_number = ?", workflow, projectFlockID, uint16(cfg.step)). - Count(&count).Error; err != nil { - return err - } - if count > 0 { - continue - } - - stepName, ok := utils.ProjectFlockApprovalSteps[cfg.step] - if !ok || strings.TrimSpace(stepName) == "" { - stepName = fmt.Sprintf("Step %d", cfg.step) - } - - var actionPtr *entity.ApprovalAction - action := cfg.action - actionPtr = &action - - record := entity.Approval{ - ApprovableType: workflow, - ApprovableId: projectFlockID, - StepNumber: uint16(cfg.step), - StepName: stepName, - Action: actionPtr, - ActionBy: uintPtr(actorID), - } - - if err := tx.Create(&record).Error; err != nil { - return err - } - } - - return nil -} - func seedKandangs(tx *gorm.DB, createdBy uint, locations map[string]uint, users map[string]uint) (map[string]uint, error) { seeds := []struct { - Name string - Status utils.KandangStatus - Location string - PicKey string + Name string + Status utils.KandangStatus + Location string + PicKey string }{ {Name: "Singaparna 1", Status: utils.KandangStatusNonActive, Location: "Singaparna", PicKey: "admin"}, {Name: "Singaparna 2", Status: utils.KandangStatusNonActive, Location: "Singaparna", PicKey: "admin"}, @@ -414,16 +256,15 @@ func seedKandangs(tx *gorm.DB, createdBy uint, locations map[string]uint, users 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, - Status: string(seed.Status), - LocationId: locID, - PicId: picID, - CreatedBy: createdBy, + Name: seed.Name, + Status: string(seed.Status), + LocationId: locID, + PicId: picID, + CreatedBy: createdBy, } if err := tx.Create(&kandang).Error; err != nil { return nil, err @@ -446,7 +287,6 @@ func seedKandangs(tx *gorm.DB, createdBy uint, locations map[string]uint, users 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 @@ -525,6 +365,7 @@ func seedProductCategories(tx *gorm.DB, createdBy uint) (map[string]uint, error) }{ {"Bahan Baku", "RAW"}, {"Day Old Chick", "DOC"}, + {"Telur", "EGG"}, } result := make(map[string]uint, len(seeds)) @@ -739,6 +580,22 @@ func seedProducts(tx *gorm.DB, createdBy uint, uoms map[string]uint, categories Suppliers: []string{"PT CHAROEN POKPHAND INDONESIA Tbk"}, Flags: []utils.FlagType{utils.FlagPakan, utils.FlagStarter}, }, + { + Name: "Telur Konsumsi Baik", + Brand: "Layer Farm", + Sku: "EGG-GOOD", + Uom: "Unit", + Category: "Telur", + Price: 1800, + }, + { + Name: "Telur Pecah", + Brand: "Layer Farm", + Sku: "EGG-CRACK", + Uom: "Unit", + Category: "Telur", + Price: 900, + }, } for _, seed := range seeds { @@ -978,25 +835,44 @@ func seedBanks(tx *gorm.DB, createdBy uint) error { } func seedProductWarehouse(tx *gorm.DB, createdBy uint) error { - seeds := []struct { - ProductID uint - WarehouseID uint - Quantity float64 + ProductName string + WarehouseName string + Quantity float64 }{ - {ProductID: 1, WarehouseID: 1, Quantity: 100}, - {ProductID: 2, WarehouseID: 2, Quantity: 200}, - {ProductID: 2, WarehouseID: 1, Quantity: 300}, - {ProductID: 1, WarehouseID: 3, Quantity: 5000}, + {ProductName: "DOC Broiler", WarehouseName: "Gudang Priangan", Quantity: 100}, + {ProductName: "281 SPECIAL STARTER", WarehouseName: "Gudang Singaparna", Quantity: 200}, + {ProductName: "281 SPECIAL STARTER", WarehouseName: "Gudang Banten", Quantity: 300}, + {ProductName: "DOC Broiler", WarehouseName: "Gudang Singaparna 1", Quantity: 5000}, + {ProductName: "Telur Konsumsi Baik", WarehouseName: "Gudang Singaparna 1", Quantity: 600}, + {ProductName: "Telur Pecah", WarehouseName: "Gudang Singaparna 1", Quantity: 80}, + {ProductName: "Telur Konsumsi Baik", WarehouseName: "Gudang Cikaum 1", Quantity: 450}, + {ProductName: "Telur Pecah", WarehouseName: "Gudang Cikaum 1", Quantity: 60}, } for _, seed := range seeds { + var product entity.Product + if err := tx.Where("name = ?", seed.ProductName).First(&product).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("product %q not found for product warehouse seeding", seed.ProductName) + } + return err + } + + var warehouse entity.Warehouse + if err := tx.Where("name = ?", seed.WarehouseName).First(&warehouse).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("warehouse %q not found for product warehouse seeding", seed.WarehouseName) + } + return err + } + var productWarehouse entity.ProductWarehouse - err := tx.Where("product_id = ? AND warehouse_id = ?", seed.ProductID, seed.WarehouseID).First(&productWarehouse).Error + err := tx.Where("product_id = ? AND warehouse_id = ?", product.Id, warehouse.Id).First(&productWarehouse).Error if errors.Is(err, gorm.ErrRecordNotFound) { productWarehouse = entity.ProductWarehouse{ - ProductId: seed.ProductID, - WarehouseId: seed.WarehouseID, + ProductId: product.Id, + WarehouseId: warehouse.Id, Quantity: seed.Quantity, CreatedBy: createdBy, } @@ -1005,6 +881,12 @@ func seedProductWarehouse(tx *gorm.DB, createdBy uint) error { } } else if err != nil { return err + } else { + if err := tx.Model(&productWarehouse).Updates(map[string]any{ + "quantity": seed.Quantity, + }).Error; err != nil { + return err + } } } @@ -1085,71 +967,6 @@ func seedTransferStock(tx *gorm.DB, createdBy uint) error { return nil } -func seedChickin(tx *gorm.DB, createdBy uint) error { - // gunakan identitas yang stabil, bukan ID pivot - seeds := []struct { - KandangName string - LocationName string - Period int - ChickInDate string - Quantity float64 - Note string - }{ - {"Singaparna 1", "Singaparna", 1, "2025-10-20", 100, "Seeder chickin 1"}, - {"Cikaum 1", "Cikaum", 1, "2025-10-21", 200, "Seeder chickin 2"}, - } - - for _, s := range seeds { - pfkID, err := ensurePFK(tx, s.KandangName, s.LocationName, s.Period) - if err != nil { return err } - - date, err := time.Parse("2006-01-02", s.ChickInDate) - if err != nil { return err } - - // upsert project_chickin (idempotent) - var chickin entity.ProjectChickin - err = tx.Where("project_flock_kandang_id = ? AND chick_in_date = ?", pfkID, date).First(&chickin).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - chickin = entity.ProjectChickin{ - ProjectFlockKandangId: pfkID, - ChickInDate: date, - Quantity: s.Quantity, - Note: s.Note, - CreatedBy: createdBy, - } - if err := tx.Create(&chickin).Error; err != nil { return err } - } else if err != nil { - return err - } - - // upsert population - var pop entity.ProjectFlockPopulation - err = tx.Where("project_flock_kandang_id = ?", pfkID).First(&pop).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - pop = entity.ProjectFlockPopulation{ - ProjectFlockKandangId: pfkID, - InitialQuantity: s.Quantity, - CurrentQuantity: s.Quantity, - ReservedQuantity: 0, - CreatedBy: createdBy, - } - if err := tx.Create(&pop).Error; err != nil { return err } - } else if err != nil { - return err - } else { - if err := tx.Model(&entity.ProjectFlockPopulation{}). - Where("id = ?", pop.Id). - Updates(map[string]any{ - "initial_quantity": pop.InitialQuantity + s.Quantity, - "current_quantity": pop.CurrentQuantity + s.Quantity, - "reserved_quantity": 0, - }).Error; err != nil { return err } - } - } - return nil -} - - func ptr[T any](v T) *T { return &v } @@ -1165,30 +982,3 @@ func intPtr(v int) *int { func uintPtr(v uint) *uint { return &v } - -func ensurePFK(tx *gorm.DB, kandangName, locationName string, period int) (uint, error) { - var kandang entity.Kandang - if err := tx.Where("name = ?", kandangName).First(&kandang).Error; err != nil { - return 0, fmt.Errorf("kandang %q not found: %w", kandangName, err) - } - var loc entity.Location - if err := tx.Where("name = ?", locationName).First(&loc).Error; err != nil { - return 0, fmt.Errorf("location %q not found: %w", locationName, err) - } - var pf entity.ProjectFlock - if err := tx.Where("location_id = ? AND period = ?", loc.Id, period).First(&pf).Error; err != nil { - return 0, fmt.Errorf("project_flock for %s period %d not found: %w", locationName, period, err) - } - var pfk entity.ProjectFlockKandang - if err := tx.Where("project_flock_id = ? AND kandang_id = ?", pf.Id, kandang.Id).First(&pfk).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - pfk = entity.ProjectFlockKandang{ ProjectFlockId: pf.Id, KandangId: kandang.Id } - if err := tx.Create(&pfk).Error; err != nil { - return 0, fmt.Errorf("create pivot pfk(%d,%d) failed: %w", pf.Id, kandang.Id, err) - } - } else { - return 0, err - } - } - return pfk.Id, nil -} diff --git a/internal/entities/projectflock.go b/internal/entities/projectflock.go index e734743c..0507d9f3 100644 --- a/internal/entities/projectflock.go +++ b/internal/entities/projectflock.go @@ -8,18 +8,17 @@ import ( type ProjectFlock struct { Id uint `gorm:"primaryKey"` - FlockId uint `gorm:"not null;uniqueIndex:project_flocks_flock_period_unique"` + FlockName string `gorm:"type:varchar(255);not null;uniqueIndex"` AreaId uint `gorm:"not null"` Category string `gorm:"type:varchar(20);not null"` FcrId uint `gorm:"not null"` LocationId uint `gorm:"not null"` - Period int `gorm:"not null;uniqueIndex:project_flocks_flock_period_unique"` + Period 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:"-"` - Flock Flock `gorm:"foreignKey:FlockId;references:Id"` Area Area `gorm:"foreignKey:AreaId;references:Id"` Fcr Fcr `gorm:"foreignKey:FcrId;references:Id"` Location Location `gorm:"foreignKey:LocationId;references:Id"` diff --git a/internal/entities/recording.go b/internal/entities/recording.go index a3142e1d..42535365 100644 --- a/internal/entities/recording.go +++ b/internal/entities/recording.go @@ -8,20 +8,16 @@ import ( type Recording struct { Id uint `gorm:"primaryKey"` - ProjectFlockKandangId uint `gorm:"column:project_flock_id;not null;index"` + ProjectFlockKandangId uint `gorm:"column:project_flock_kandangs_id;not null;index"` RecordDatetime time.Time `gorm:"column:record_datetime;not null"` - RecordDate *time.Time `gorm:"column:record_date"` - Ontime int `gorm:"column:ontime;not null;default:0"` Day *int `gorm:"column:day"` - TotalDepletion *int `gorm:"column:total_depletion"` + TotalDepletionQty *float64 `gorm:"column:total_depletion_qty"` CumDepletionRate *float64 `gorm:"column:cum_depletion_rate"` DailyGain *float64 `gorm:"column:daily_gain"` AvgDailyGain *float64 `gorm:"column:avg_daily_gain"` - CumIntake *int64 `gorm:"column:cum_intake"` + CumIntake *int `gorm:"column:cum_intake"` FcrValue *float64 `gorm:"column:fcr_value"` - TotalChick *int64 `gorm:"column:total_chick"` - DailyDepletionRate *float64 `gorm:"column:daily_depletion_rate"` - CumDepletion *int `gorm:"column:cum_depletion"` + TotalChickQty *float64 `gorm:"column:total_chick_qty"` CreatedBy uint `gorm:"column:created_by"` CreatedAt time.Time `gorm:"autoCreateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"` @@ -32,4 +28,7 @@ type Recording struct { BodyWeights []RecordingBW `gorm:"foreignKey:RecordingId;references:Id"` Depletions []RecordingDepletion `gorm:"foreignKey:RecordingId;references:Id"` Stocks []RecordingStock `gorm:"foreignKey:RecordingId;references:Id"` + Eggs []RecordingEgg `gorm:"foreignKey:RecordingId;references:Id"` + + LatestApproval *Approval `gorm:"-" json:"-"` } diff --git a/internal/entities/recording_bw.go b/internal/entities/recording_bw.go index a385e86e..041df0f6 100644 --- a/internal/entities/recording_bw.go +++ b/internal/entities/recording_bw.go @@ -1,16 +1,15 @@ - package entities import "time" type RecordingBW struct { - Id uint `gorm:"primaryKey"` - RecordingId uint `gorm:"column:recording_id;not null;index"` - Weight float64 `gorm:"column:weight;not null"` - Qty int `gorm:"column:qty;not null;default:1"` - Notes *string `gorm:"column:notes"` - CreatedAt time.Time `gorm:"autoCreateTime"` - UpdatedAt time.Time `gorm:"autoUpdateTime"` + Id uint `gorm:"primaryKey"` + RecordingId uint `gorm:"column:recording_id;not null;index"` + AvgWeight float64 `gorm:"column:avg_weight;not null"` + Qty float64 `gorm:"column:qty;not null"` + TotalWeight float64 `gorm:"column:total_weight;not null"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` - Recording Recording `gorm:"foreignKey:RecordingId;references:Id"` + Recording Recording `gorm:"foreignKey:RecordingId;references:Id"` } diff --git a/internal/entities/recording_depletion.go b/internal/entities/recording_depletion.go index 39a63cc3..53af300d 100644 --- a/internal/entities/recording_depletion.go +++ b/internal/entities/recording_depletion.go @@ -1,13 +1,11 @@ package entities type RecordingDepletion struct { - Id uint `gorm:"primaryKey"` - RecordingId uint `gorm:"column:recording_id;not null;index"` - ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` - Total int64 `gorm:"column:total;not null"` - Notes *string `gorm:"column:notes"` + Id uint `gorm:"primaryKey"` + RecordingId uint `gorm:"column:recording_id;not null;index"` + ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` + Qty float64 `gorm:"column:qty;not null"` - Recording Recording `gorm:"foreignKey:RecordingId;references:Id"` - ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` + Recording Recording `gorm:"foreignKey:RecordingId;references:Id"` + ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` } - diff --git a/internal/entities/recording_egg.go b/internal/entities/recording_egg.go new file mode 100644 index 00000000..28eafeb7 --- /dev/null +++ b/internal/entities/recording_egg.go @@ -0,0 +1,30 @@ +package entities + +import "time" + +type RecordingEgg struct { + Id uint `gorm:"primaryKey"` + RecordingId uint `gorm:"column:recording_id;not null;index"` + ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` + Qty int `gorm:"column:qty;not null"` + CreatedBy uint `gorm:"column:created_by"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + GradingEggs []GradingEgg `gorm:"foreignKey:RecordingEggId;references:Id"` + ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` + CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"` + Recording Recording `gorm:"foreignKey:RecordingId;references:Id"` +} + +type GradingEgg struct { + Id uint `gorm:"primaryKey"` + RecordingEggId uint `gorm:"column:recording_egg_id;not null;index"` + Qty float64 `gorm:"column:qty;not null"` + Grade string `gorm:"column:grade;type:varchar(50)"` + CreatedBy uint `gorm:"column:created_by"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + + RecordingEgg RecordingEgg `gorm:"foreignKey:RecordingEggId;references:Id"` + CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"` +} diff --git a/internal/entities/recording_stock.go b/internal/entities/recording_stock.go index de19885a..982bba37 100644 --- a/internal/entities/recording_stock.go +++ b/internal/entities/recording_stock.go @@ -1,14 +1,12 @@ package entities type RecordingStock struct { - Id uint `gorm:"primaryKey"` - RecordingId uint `gorm:"column:recording_id;not null;index"` - ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` - Increase *float64 `gorm:"column:increase"` - Decrease *float64 `gorm:"column:decrease"` - UsageAmount *int64 `gorm:"column:usage_amount"` - Notes *string `gorm:"column:notes"` + Id uint `gorm:"primaryKey"` + RecordingId uint `gorm:"column:recording_id;not null;index"` + ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` + UsageQty *float64 `gorm:"column:usage_qty"` + PendingQty *float64 `gorm:"column:pending_qty"` - Recording Recording `gorm:"foreignKey:RecordingId;references:Id"` - ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` + Recording Recording `gorm:"foreignKey:RecordingId;references:Id"` + ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` } diff --git a/internal/modules/inventory/adjustments/services/adjustment.service.go b/internal/modules/inventory/adjustments/services/adjustment.service.go index 7a2d06bc..e1c4166d 100644 --- a/internal/modules/inventory/adjustments/services/adjustment.service.go +++ b/internal/modules/inventory/adjustments/services/adjustment.service.go @@ -202,21 +202,7 @@ func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Qu if query.TransactionType != "" { db = db.Where("transaction_type = ?", strings.ToUpper(query.TransactionType)) } - if query.ProductID > 0 { - db = db.Joins("JOIN product_warehouses ON product_warehouses.id = stock_logs.product_warehouse_id"). - Where("product_warehouses.product_id = ?", query.ProductID) - } - - if query.WarehouseID > 0 { - if query.ProductID > 0 { - - db = db.Where("product_warehouses.warehouse_id = ?", query.WarehouseID) - } else { - - db = db.Joins("JOIN product_warehouses ON product_warehouses.id = stock_logs.product_warehouse_id"). - Where("product_warehouses.warehouse_id = ?", query.WarehouseID) - } - } + db = s.StockLogsRepository.ApplyProductWarehouseFilters(db, uint(query.ProductID), uint(query.WarehouseID)) return db.Order("created_at DESC") }) diff --git a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go index f1f1fa57..23cabb68 100644 --- a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go +++ b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go @@ -2,6 +2,7 @@ package repository import ( "context" + "fmt" "gitlab.com/mbugroup/lti-api.git/internal/common/repository" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" @@ -17,34 +18,35 @@ type ProductWarehouseRepository interface { ExistsByID(ctx context.Context, id uint) (bool, error) GetProductWarehouseByProductAndWarehouseID(ctx context.Context, productId, warehouseId uint) (*entity.ProductWarehouse, error) GetByCategoryCodeAndWarehouseID(ctx context.Context, categoryCode string, warehouseId uint) ([]entity.ProductWarehouse, error) + GetLatestByCategoryCodeAndWarehouseID(ctx context.Context, categoryCode string, warehouseId uint, db *gorm.DB) (*entity.ProductWarehouse, error) + ApplyFlagsFilter(db *gorm.DB, flags []string) *gorm.DB + AdjustQuantities(ctx context.Context, deltas map[uint]float64, modifier func(*gorm.DB) *gorm.DB) error } type ProductWarehouseRepositoryImpl struct { *repository.BaseRepositoryImpl[entity.ProductWarehouse] - db *gorm.DB } func NewProductWarehouseRepository(db *gorm.DB) ProductWarehouseRepository { return &ProductWarehouseRepositoryImpl{ BaseRepositoryImpl: repository.NewBaseRepository[entity.ProductWarehouse](db), - db: db, } } func (r *ProductWarehouseRepositoryImpl) IsProductExist(ctx context.Context, productId uint) (bool, error) { - return repository.Exists[entity.Product](ctx, r.db, productId) + return repository.Exists[entity.Product](ctx, r.DB(), productId) } func (r *ProductWarehouseRepositoryImpl) IsWarehouseExist(ctx context.Context, warehouseId uint) (bool, error) { - return repository.Exists[entity.Warehouse](ctx, r.db, warehouseId) + return repository.Exists[entity.Warehouse](ctx, r.DB(), warehouseId) } func (r *ProductWarehouseRepositoryImpl) ExistsByID(ctx context.Context, id uint) (bool, error) { - return repository.Exists[entity.ProductWarehouse](ctx, r.db, id) + return repository.Exists[entity.ProductWarehouse](ctx, r.DB(), id) } func (r *ProductWarehouseRepositoryImpl) ProductWarehouseExists(ctx context.Context, productId, warehouseId uint, excludeID *uint) (bool, error) { var count int64 - query := r.db.WithContext(ctx).Model(&entity.ProductWarehouse{}). + query := r.DB().WithContext(ctx).Model(&entity.ProductWarehouse{}). Where("product_id = ? AND warehouse_id = ?", productId, warehouseId) if excludeID != nil { query = query.Where("id != ?", *excludeID) @@ -57,7 +59,7 @@ func (r *ProductWarehouseRepositoryImpl) ProductWarehouseExists(ctx context.Cont func (r *ProductWarehouseRepositoryImpl) ProductWarehouseExistByProductAndWarehouseID(ctx context.Context, productId, warehouseId uint) (bool, error) { var count int64 - if err := r.db.WithContext(ctx). + if err := r.DB().WithContext(ctx). Model(&entity.ProductWarehouse{}). Where("product_id = ? AND warehouse_id = ?", productId, warehouseId). Count(&count).Error; err != nil { @@ -76,7 +78,7 @@ func (r *ProductWarehouseRepositoryImpl) GetProductWarehouseByProductAndWarehous func (r *ProductWarehouseRepositoryImpl) GetByCategoryCodeAndWarehouseID(ctx context.Context, categoryCode string, warehouseId uint) ([]entity.ProductWarehouse, error) { var productWarehouses []entity.ProductWarehouse - err := r.db.WithContext(ctx). + err := r.DB().WithContext(ctx). Table("product_warehouses"). Select("product_warehouses.*"). Joins("JOIN products ON products.id = product_warehouses.product_id"). @@ -89,3 +91,58 @@ func (r *ProductWarehouseRepositoryImpl) GetByCategoryCodeAndWarehouseID(ctx con } return productWarehouses, nil } + +func (r *ProductWarehouseRepositoryImpl) GetLatestByCategoryCodeAndWarehouseID(ctx context.Context, categoryCode string, warehouseId uint, db *gorm.DB) (*entity.ProductWarehouse, error) { + var productWarehouse entity.ProductWarehouse + query := r.DB() + if db != nil { + query = db + } + fmt.Println(warehouseId) + err := query.WithContext(ctx). + Table("product_warehouses"). + Select("product_warehouses.*"). + Joins("JOIN products ON products.id = product_warehouses.product_id"). + Joins("JOIN product_categories ON product_categories.id = products.product_category_id"). + Where("product_categories.code = ? AND product_warehouses.warehouse_id = ?", categoryCode, warehouseId). + Order("product_warehouses.created_at DESC"). + First(&productWarehouse).Error + if err != nil { + return nil, err + } + return &productWarehouse, nil +} + +func (r *ProductWarehouseRepositoryImpl) ApplyFlagsFilter(db *gorm.DB, flags []string) *gorm.DB { + if len(flags) == 0 { + return db + } + + return db. + Joins("JOIN products ON products.id = product_warehouses.product_id"). + Joins("JOIN flags ON flags.flagable_id = products.id AND flags.flagable_type = ?", "products"). + Where("flags.name IN ?", flags) +} + +func (r *ProductWarehouseRepositoryImpl) AdjustQuantities(ctx context.Context, deltas map[uint]float64, modifier func(*gorm.DB) *gorm.DB) error { + if len(deltas) == 0 { + return nil + } + + base := r.DB().WithContext(ctx) + if modifier != nil { + base = modifier(base) + } + + for id, delta := range deltas { + if delta == 0 { + continue + } + if err := base.Model(&entity.ProductWarehouse{}). + Where("id = ?", id). + Update("quantity", gorm.Expr("COALESCE(quantity,0) + ?", delta)).Error; err != nil { + return err + } + } + return nil +} diff --git a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go index 3a0468ca..cc925970 100644 --- a/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go +++ b/internal/modules/inventory/product-warehouses/services/product_warehouse.service.go @@ -84,11 +84,7 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) db = db.Where("warehouse_id = ?", params.WarehouseId) } - if len(cleanFlags) > 0 { - db = db.Joins("JOIN products ON products.id = product_warehouses.product_id"). - Joins("JOIN flags ON flags.flagable_id = products.id AND flags.flagable_type = ?", "products"). - Where("flags.name IN ?", cleanFlags) - } + db = s.Repository.ApplyFlagsFilter(db, cleanFlags) return db.Order("created_at DESC").Order("updated_at DESC") }) diff --git a/internal/modules/master/flocks/repositories/flock.repository.go b/internal/modules/master/flocks/repositories/flock.repository.go index 006fe541..5c7e7ca8 100644 --- a/internal/modules/master/flocks/repositories/flock.repository.go +++ b/internal/modules/master/flocks/repositories/flock.repository.go @@ -11,6 +11,7 @@ import ( type FlockRepository interface { repository.BaseRepository[entity.Flock] NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) + GetByName(ctx context.Context, name string) (*entity.Flock, error) } type FlockRepositoryImpl struct { @@ -28,3 +29,15 @@ func NewFlockRepository(db *gorm.DB) FlockRepository { func (r *FlockRepositoryImpl) NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) { return repository.ExistsByName[entity.Flock](ctx, r.db, name, excludeID) } + +func (r *FlockRepositoryImpl) GetByName(ctx context.Context, name string) (*entity.Flock, error) { + var flock entity.Flock + err := r.db.WithContext(ctx). + Where("LOWER(name) = LOWER(?)", name). + Where("deleted_at IS NULL"). + First(&flock).Error + if err != nil { + return nil, err + } + return &flock, nil +} diff --git a/internal/modules/master/kandangs/repositories/kandang.repository.go b/internal/modules/master/kandangs/repositories/kandang.repository.go index b4351397..8f32a7b2 100644 --- a/internal/modules/master/kandangs/repositories/kandang.repository.go +++ b/internal/modules/master/kandangs/repositories/kandang.repository.go @@ -20,7 +20,7 @@ type KandangRepository interface { HasActiveKandangForProjectFlock(ctx context.Context, projectFlockID uint, excludeID *uint) (bool, error) UpdateStatusByProjectFlockID(ctx context.Context, projectFlockID uint, status utils.KandangStatus) error UpsertProjectFlockKandang(ctx context.Context, projectFlockID, kandangID uint) error - + UpdateStatusByIDs(ctx context.Context, kandangIDs []uint, status utils.KandangStatus) error } type KandangRepositoryImpl struct { @@ -61,15 +61,15 @@ func (r *KandangRepositoryImpl) ProjectFlockExists(ctx context.Context, projectF func (r *KandangRepositoryImpl) HasActiveKandangForProjectFlock(ctx context.Context, projectFlockID uint, excludeID *uint) (bool, error) { var count int64 - q := r.db.WithContext(ctx). - Table("kandangs k"). - Joins("JOIN project_flock_kandangs pfk ON pfk.kandang_id = k.id"). - Where("pfk.project_flock_id = ?", projectFlockID). - Where("k.status = ?", utils.KandangStatusActive). - Where("k.deleted_at IS NULL") - if excludeID != nil { - q = q.Where("k.id <> ?", *excludeID) - } + q := r.db.WithContext(ctx). + Table("kandangs k"). + Joins("JOIN project_flock_kandangs pfk ON pfk.kandang_id = k.id"). + Where("pfk.project_flock_id = ?", projectFlockID). + Where("k.status = ?", utils.KandangStatusActive). + Where("k.deleted_at IS NULL") + if excludeID != nil { + q = q.Where("k.id <> ?", *excludeID) + } if err := q.Count(&count).Error; err != nil { return false, err } @@ -78,49 +78,59 @@ func (r *KandangRepositoryImpl) HasActiveKandangForProjectFlock(ctx context.Cont func (r *KandangRepositoryImpl) GetFirstByProjectFlockID(ctx context.Context, projectFlockID uint) (*entity.Kandang, error) { kandang := new(entity.Kandang) - err := r.db.WithContext(ctx). - Table("kandangs k"). - Joins("JOIN project_flock_kandangs pfk ON pfk.kandang_id = k.id"). - Where("pfk.project_flock_id = ?", projectFlockID). - Where("k.deleted_at IS NULL"). - Order("k.id ASC"). - Limit(1). - Find(kandang).Error - if err != nil { - return nil, err - } - if kandang.Id == 0 { - return nil, gorm.ErrRecordNotFound - } + err := r.db.WithContext(ctx). + Table("kandangs k"). + Joins("JOIN project_flock_kandangs pfk ON pfk.kandang_id = k.id"). + Where("pfk.project_flock_id = ?", projectFlockID). + Where("k.deleted_at IS NULL"). + Order("k.id ASC"). + Limit(1). + Find(kandang).Error + if err != nil { + return nil, err + } + if kandang.Id == 0 { + return nil, gorm.ErrRecordNotFound + } return kandang, nil } func (r *KandangRepositoryImpl) UpdateStatusByProjectFlockID(ctx context.Context, projectFlockID uint, status utils.KandangStatus) error { - sub := r.db.WithContext(ctx). - Table("project_flock_kandangs"). - Select("kandang_id"). - Where("project_flock_id = ?", projectFlockID) + sub := r.db.WithContext(ctx). + Table("project_flock_kandangs"). + Select("kandang_id"). + Where("project_flock_id = ?", projectFlockID) - return r.db.WithContext(ctx). - Model(&entity.Kandang{}). - Where("id IN (?)", sub). - Where("deleted_at IS NULL"). - Update("status", string(status)).Error + return r.db.WithContext(ctx). + Model(&entity.Kandang{}). + Where("id IN (?)", sub). + Where("deleted_at IS NULL"). + Update("status", string(status)).Error } func (r *KandangRepositoryImpl) UpsertProjectFlockKandang(ctx context.Context, projectFlockID, kandangID uint) error { - var link entity.ProjectFlockKandang - err := r.db.WithContext(ctx). - Where("project_flock_id = ? AND kandang_id = ?", projectFlockID, kandangID). - First(&link).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - link = entity.ProjectFlockKandang{ - ProjectFlockId: projectFlockID, - KandangId: kandangID, - } - return r.db.WithContext(ctx).Create(&link).Error - } - return err + var link entity.ProjectFlockKandang + err := r.db.WithContext(ctx). + Where("project_flock_id = ? AND kandang_id = ?", projectFlockID, kandangID). + First(&link).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + link = entity.ProjectFlockKandang{ + ProjectFlockId: projectFlockID, + KandangId: kandangID, + } + return r.db.WithContext(ctx).Create(&link).Error + } + return err } +func (r *KandangRepositoryImpl) UpdateStatusByIDs(ctx context.Context, kandangIDs []uint, status utils.KandangStatus) error { + if len(kandangIDs) == 0 { + return nil + } + return r.db.WithContext(ctx). + Model(&entity.Kandang{}). + Where("id IN ?", kandangIDs). + Where("deleted_at IS NULL"). + Update("status", string(status)).Error +} diff --git a/internal/modules/production/chickins/dto/chickin.dto.go b/internal/modules/production/chickins/dto/chickin.dto.go index 193257b6..3b69d4d4 100644 --- a/internal/modules/production/chickins/dto/chickin.dto.go +++ b/internal/modules/production/chickins/dto/chickin.dto.go @@ -9,6 +9,7 @@ import ( flockBaseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/dto" kandangBaseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto" locationBaseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto" + pfutils "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/utils" userBaseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" ) @@ -88,9 +89,9 @@ func ToUserBaseDTO(e entity.User) userBaseDTO.UserBaseDTO { func ToProjectFlockDTO(e entity.ProjectFlock) ProjectFlockDTO { var flock *flockBaseDTO.FlockBaseDTO - if e.Flock.Id != 0 { - mapped := flockBaseDTO.ToFlockBaseDTO(e.Flock) - flock = &mapped + if base := pfutils.DeriveBaseName(e.FlockName); base != "" { + summary := flockBaseDTO.FlockBaseDTO{Id: 0, Name: base} + flock = &summary } var area *areaBaseDTO.AreaBaseDTO if e.Area.Id != 0 { diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index f422666f..5a6f4e71 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -63,7 +63,6 @@ func (s chickinService) withRelations(db *gorm.DB) *gorm.DB { Preload("ProjectFlockKandang.Kandang.Location.Area"). Preload("ProjectFlockKandang.Kandang.Pic"). Preload("ProjectFlockKandang.ProjectFlock"). - Preload("ProjectFlockKandang.ProjectFlock.Flock"). Preload("ProjectFlockKandang.ProjectFlock.Area"). Preload("ProjectFlockKandang.ProjectFlock.Fcr"). Preload("ProjectFlockKandang.ProjectFlock.Location"). @@ -340,15 +339,12 @@ func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error { return rollback(err) } - var productWarehouse entity.ProductWarehouse - err = tx.WithContext(c.Context()).Table("product_warehouses"). - Select("product_warehouses.*"). - Joins("JOIN products ON products.id = product_warehouses.product_id"). - Joins("JOIN product_categories ON product_categories.id = products.product_category_id"). - Where("product_categories.code = ? AND product_warehouses.warehouse_id = ?", "DOC", warehouse.Id). - Order("product_warehouses.created_at DESC"). - First(&productWarehouse).Error - + productWarehouse, err := s.ProductWarehouseRepo.GetLatestByCategoryCodeAndWarehouseID( + c.Context(), + "DOC", + warehouse.Id, + tx, + ) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return rollback(fiber.NewError(fiber.StatusNotFound, "Product Warehouse not found for the given Project Flock and Warehouse")) diff --git a/internal/modules/production/project_flocks/dto/projectflock.dto.go b/internal/modules/production/project_flocks/dto/projectflock.dto.go index dff3bc61..3929d7f8 100644 --- a/internal/modules/production/project_flocks/dto/projectflock.dto.go +++ b/internal/modules/production/project_flocks/dto/projectflock.dto.go @@ -10,14 +10,16 @@ import ( flockDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/dto" kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto" locationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto" + pfutils "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/utils" userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" "gitlab.com/mbugroup/lti-api.git/internal/utils" approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" ) type ProjectFlockBaseDTO struct { - Id uint `json:"id"` - Period int `json:"period"` + Id uint `json:"id"` + Period int `json:"period"` + FlockName string `json:"flock_name"` } type ProjectFlockListDTO struct { @@ -59,9 +61,9 @@ func ToProjectFlockListDTO(e entity.ProjectFlock) ProjectFlockListDTO { } var flockSummary *flockDTO.FlockBaseDTO - if e.Flock.Id != 0 { - mapped := flockDTO.ToFlockBaseDTO(e.Flock) - flockSummary = &mapped + if baseName := pfutils.DeriveBaseName(e.FlockName); baseName != "" { + summary := flockDTO.FlockBaseDTO{Id: 0, Name: baseName} + flockSummary = &summary } var areaSummary *areaDTO.AreaBaseDTO @@ -144,8 +146,9 @@ func defaultProjectFlockLatestApproval(e entity.ProjectFlock) approvalDTO.Approv func createProjectFlockBaseDTO(e entity.ProjectFlock) ProjectFlockBaseDTO { return ProjectFlockBaseDTO{ - Id: e.Id, - Period: e.Period, + Id: e.Id, + Period: e.Period, + FlockName: e.FlockName, } } diff --git a/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go b/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go index 27a68011..24e53d28 100644 --- a/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go +++ b/internal/modules/production/project_flocks/dto/projectflock_kandang.dto.go @@ -7,6 +7,7 @@ import ( flockDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/dto" kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto" locationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto" + pfutils "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/utils" userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" ) @@ -48,15 +49,16 @@ func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangD pfLocal := ProjectFlockWithPivotDTO{ ProjectFlockBaseDTO: ProjectFlockBaseDTO{ - Id: e.ProjectFlock.Id, - Period: e.ProjectFlock.Period, + Id: e.ProjectFlock.Id, + Period: e.ProjectFlock.Period, + FlockName: e.ProjectFlock.FlockName, }, Category: e.ProjectFlock.Category, } - if e.ProjectFlock.Flock.Id != 0 { - mapped := ToFlockSummaryDTO(e.ProjectFlock.Flock) - pfLocal.Flock = &mapped + if base := pfutils.DeriveBaseName(e.ProjectFlock.FlockName); base != "" { + summary := flockDTO.FlockBaseDTO{Id: 0, Name: base} + pfLocal.Flock = &summary } if e.ProjectFlock.Area.Id != 0 { mapped := areaDTO.ToAreaBaseDTO(e.ProjectFlock.Area) @@ -75,11 +77,6 @@ func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangD pfLocal.CreatedUser = &mapped } - pivotMap := make(map[uint]uint) - for _, ph := range e.ProjectFlock.KandangHistory { - pivotMap[ph.KandangId] = ph.Id - } - for _, k := range e.ProjectFlock.Kandangs { kb := kandangDTO.ToKandangBaseDTO(k) pfLocal.Kandangs = append(pfLocal.Kandangs, KandangWithPivotDTO{ diff --git a/internal/modules/production/project_flocks/repositories/projectflock.repository.go b/internal/modules/production/project_flocks/repositories/projectflock.repository.go index 476b061b..bb653fe9 100644 --- a/internal/modules/production/project_flocks/repositories/projectflock.repository.go +++ b/internal/modules/production/project_flocks/repositories/projectflock.repository.go @@ -3,19 +3,30 @@ package repository import ( "context" "errors" + "fmt" + "strings" "gitlab.com/mbugroup/lti-api.git/internal/common/repository" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/validations" "gorm.io/gorm" "gorm.io/gorm/clause" ) +const baseNameExpression = "LOWER(TRIM(regexp_replace(flock_name, '\\\\s+\\\\d+(\\\\s+\\\\d+)*$', '', 'g')))" + type ProjectflockRepository interface { repository.BaseRepository[entity.ProjectFlock] - GetAllByFlock(ctx context.Context, flockID uint) ([]entity.ProjectFlock, error) - GetActiveByFlock(ctx context.Context, flockID uint) (*entity.ProjectFlock, error) - GetMaxPeriodByFlock(ctx context.Context, flockID uint) (int, error) - GetNextPeriodForFlock(ctx context.Context, flockID uint) (int, error) + GetAllByBaseName(ctx context.Context, baseName string) ([]entity.ProjectFlock, error) + GetActiveByBaseName(ctx context.Context, baseName string) (*entity.ProjectFlock, error) + GetMaxPeriodByBaseName(ctx context.Context, baseName string) (int, error) + GetNextSequenceForBase(ctx context.Context, baseName string) (int, error) + GetAllWithFilters(ctx context.Context, offset, limit int, params *validation.Query) ([]entity.ProjectFlock, int64, error) + WithDefaultRelations() func(*gorm.DB) *gorm.DB + ExistsByFlockName(ctx context.Context, flockName string, excludeID *uint) (bool, error) + AreaExists(ctx context.Context, id uint) (bool, error) + FcrExists(ctx context.Context, id uint) (bool, error) + LocationExists(ctx context.Context, id uint) (bool, error) } type ProjectflockRepositoryImpl struct { @@ -28,11 +39,11 @@ func NewProjectflockRepository(db *gorm.DB) ProjectflockRepository { } } -func (r *ProjectflockRepositoryImpl) GetAllByFlock(ctx context.Context, flockID uint) ([]entity.ProjectFlock, error) { +func (r *ProjectflockRepositoryImpl) GetAllByBaseName(ctx context.Context, baseName string) ([]entity.ProjectFlock, error) { var records []entity.ProjectFlock if err := r.DB().WithContext(ctx). Unscoped(). - Where("flock_id = ?", flockID). + Where(baseNameExpression+" = LOWER(?)", baseName). Order("period ASC"). Find(&records).Error; err != nil { return nil, err @@ -40,10 +51,10 @@ func (r *ProjectflockRepositoryImpl) GetAllByFlock(ctx context.Context, flockID return records, nil } -func (r *ProjectflockRepositoryImpl) GetActiveByFlock(ctx context.Context, flockID uint) (*entity.ProjectFlock, error) { +func (r *ProjectflockRepositoryImpl) GetActiveByBaseName(ctx context.Context, baseName string) (*entity.ProjectFlock, error) { var record entity.ProjectFlock err := r.DB().WithContext(ctx). - Where("flock_id = ?", flockID). + Where(baseNameExpression+" = LOWER(?)", baseName). Order("period DESC"). First(&record).Error if errors.Is(err, gorm.ErrRecordNotFound) { @@ -55,11 +66,11 @@ func (r *ProjectflockRepositoryImpl) GetActiveByFlock(ctx context.Context, flock return &record, nil } -func (r *ProjectflockRepositoryImpl) GetMaxPeriodByFlock(ctx context.Context, flockID uint) (int, error) { +func (r *ProjectflockRepositoryImpl) GetMaxPeriodByBaseName(ctx context.Context, baseName string) (int, error) { var max int if err := r.DB().WithContext(ctx). Model(&entity.ProjectFlock{}). - Where("flock_id = ?", flockID). + Where(baseNameExpression+" = LOWER(?)", baseName). Select("COALESCE(MAX(period), 0)"). Scan(&max).Error; err != nil { return 0, err @@ -67,13 +78,13 @@ func (r *ProjectflockRepositoryImpl) GetMaxPeriodByFlock(ctx context.Context, fl return max, nil } -func (r *ProjectflockRepositoryImpl) GetNextPeriodForFlock(ctx context.Context, flockID uint) (int, error) { +func (r *ProjectflockRepositoryImpl) GetNextSequenceForBase(ctx context.Context, baseName string) (int, error) { var payload struct { Period int } if err := r.DB().WithContext(ctx). Model(&entity.ProjectFlock{}). - Where("flock_id = ?", flockID). + Where(baseNameExpression+" = LOWER(?)", baseName). Clauses(clause.Locking{Strength: "UPDATE"}). Order("period DESC"). Limit(1). @@ -86,3 +97,164 @@ func (r *ProjectflockRepositoryImpl) GetNextPeriodForFlock(ctx context.Context, } return payload.Period + 1, nil } + +func (r *ProjectflockRepositoryImpl) GetAllWithFilters(ctx context.Context, offset, limit int, params *validation.Query) ([]entity.ProjectFlock, int64, error) { + return r.GetAll(ctx, offset, limit, func(db *gorm.DB) *gorm.DB { + db = r.withDefaultRelations(db) + return r.applyQueryFilters(db, params) + }) +} + +func (r *ProjectflockRepositoryImpl) WithDefaultRelations() func(*gorm.DB) *gorm.DB { + return func(db *gorm.DB) *gorm.DB { + return r.withDefaultRelations(db) + } +} + +func (r *ProjectflockRepositoryImpl) withDefaultRelations(db *gorm.DB) *gorm.DB { + return db. + Preload("CreatedUser"). + Preload("Area"). + Preload("Fcr"). + Preload("Location"). + Preload("Kandangs") +} + +func (r *ProjectflockRepositoryImpl) applyQueryFilters(db *gorm.DB, params *validation.Query) *gorm.DB { + if params == nil { + return db + } + + if params.AreaId > 0 { + db = db.Where("project_flocks.area_id = ?", params.AreaId) + } + if params.LocationId > 0 { + db = db.Where("project_flocks.location_id = ?", params.LocationId) + } + if params.Period > 0 { + db = db.Where("project_flocks.period = ?", params.Period) + } + if len(params.KandangIds) > 0 { + db = db.Where(` + EXISTS ( + SELECT 1 + FROM project_flock_kandangs pfk + WHERE pfk.project_flock_id = project_flocks.id + AND pfk.kandang_id IN ? + )`, params.KandangIds) + } + + db = r.applySearchFilters(db, params.Search) + + for _, expr := range r.buildOrderExpressions(params.SortBy, params.SortOrder) { + db = db.Order(expr) + } + + return db +} + +func (r *ProjectflockRepositoryImpl) applySearchFilters(db *gorm.DB, rawSearch string) *gorm.DB { + if rawSearch == "" { + return db + } + + normalized := strings.ToLower(strings.TrimSpace(rawSearch)) + if normalized == "" { + return db + } + + likeQuery := "%" + normalized + "%" + return db. + Joins("LEFT JOIN areas ON areas.id = project_flocks.area_id"). + Joins("LEFT JOIN fcrs ON fcrs.id = project_flocks.fcr_id"). + Joins("LEFT JOIN locations ON locations.id = project_flocks.location_id"). + Joins("LEFT JOIN users AS created_users ON created_users.id = project_flocks.created_by"). + Where(` + LOWER(areas.name) LIKE ? + OR LOWER(project_flocks.category) LIKE ? + OR LOWER(fcrs.name) LIKE ? + OR LOWER(locations.name) LIKE ? + OR LOWER(locations.address) LIKE ? + OR LOWER(created_users.name) LIKE ? + OR LOWER(created_users.email) LIKE ? + OR LOWER(project_flocks.flock_name) LIKE ? + OR LOWER(TRIM(regexp_replace(project_flocks.flock_name, '\\s+\\d+(\\s+\\d+)*$', '', 'g'))) LIKE ? + OR LOWER(CAST(project_flocks.period AS TEXT)) LIKE ? + OR EXISTS ( + SELECT 1 FROM kandangs + WHERE kandangs.project_flock_id = project_flocks.id + AND LOWER(kandangs.name) LIKE ? + ) + `, + likeQuery, + likeQuery, + likeQuery, + likeQuery, + likeQuery, + likeQuery, + likeQuery, + likeQuery, + likeQuery, + likeQuery, + likeQuery, + ) +} + +func (r *ProjectflockRepositoryImpl) AreaExists(ctx context.Context, id uint) (bool, error) { + return repository.Exists[entity.Area](ctx, r.DB(), id) +} + +func (r *ProjectflockRepositoryImpl) FcrExists(ctx context.Context, id uint) (bool, error) { + return repository.Exists[entity.Fcr](ctx, r.DB(), id) +} + +func (r *ProjectflockRepositoryImpl) LocationExists(ctx context.Context, id uint) (bool, error) { + return repository.Exists[entity.Location](ctx, r.DB(), id) +} + +func (r *ProjectflockRepositoryImpl) buildOrderExpressions(sortBy, sortOrder string) []string { + direction := "ASC" + if strings.ToLower(sortOrder) == "desc" { + direction = "DESC" + } + + switch sortBy { + case "area": + return []string{ + fmt.Sprintf("(SELECT name FROM areas WHERE areas.id = project_flocks.area_id) %s", direction), + fmt.Sprintf("project_flocks.id %s", direction), + } + case "location": + return []string{ + fmt.Sprintf("(SELECT name FROM locations WHERE locations.id = project_flocks.location_id) %s", direction), + fmt.Sprintf("project_flocks.id %s", direction), + } + case "kandangs": + return []string{ + fmt.Sprintf("(SELECT COUNT(*) FROM project_flock_kandangs pfk WHERE pfk.project_flock_id = project_flocks.id) %s", direction), + fmt.Sprintf("project_flocks.id %s", direction), + } + case "period": + return []string{ + fmt.Sprintf("project_flocks.period %s", direction), + fmt.Sprintf("project_flocks.id %s", direction), + } + default: + return []string{ + "project_flocks.created_at DESC", + "project_flocks.updated_at DESC", + } + } +} + +func (r *ProjectflockRepositoryImpl) ExistsByFlockName(ctx context.Context, flockName string, excludeID *uint) (bool, error) { + var count int64 + q := r.DB().WithContext(ctx).Model(&entity.ProjectFlock{}).Where("flock_name = ?", flockName) + if excludeID != nil && *excludeID != 0 { + 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/production/project_flocks/repositories/projectflock_kandang.repository.go b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go index 5c78f830..f18d0654 100644 --- a/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go +++ b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go @@ -13,6 +13,9 @@ type ProjectFlockKandangRepository interface { CreateMany(ctx context.Context, records []*entity.ProjectFlockKandang) error DeleteMany(ctx context.Context, projectFlockID uint, kandangIDs []uint) error GetAll(ctx context.Context) ([]entity.ProjectFlockKandang, error) + ListExistingKandangIDs(ctx context.Context, projectFlockID uint, kandangIDs []uint) ([]uint, error) + HasKandangsLinkedToOtherProject(ctx context.Context, kandangIDs []uint, exceptProjectID *uint) (bool, error) + FindKandangsWithRecordings(ctx context.Context, projectFlockID uint, kandangIDs []uint) ([]entity.Kandang, error) WithTx(tx *gorm.DB) ProjectFlockKandangRepository DB() *gorm.DB } @@ -45,7 +48,6 @@ func (r *projectFlockKandangRepositoryImpl) GetAll(ctx context.Context) ([]entit var records []entity.ProjectFlockKandang if err := r.db.WithContext(ctx). Preload("ProjectFlock"). - Preload("ProjectFlock.Flock"). Preload("ProjectFlock.Fcr"). Preload("ProjectFlock.Area"). Preload("ProjectFlock.Location"). @@ -72,7 +74,6 @@ func (r *projectFlockKandangRepositoryImpl) GetByID(ctx context.Context, id uint record := new(entity.ProjectFlockKandang) if err := r.db.WithContext(ctx). Preload("ProjectFlock"). - Preload("ProjectFlock.Flock"). Preload("ProjectFlock.Fcr"). Preload("ProjectFlock.Area"). Preload("ProjectFlock.Location"). @@ -91,7 +92,6 @@ func (r *projectFlockKandangRepositoryImpl) GetByProjectFlockAndKandang(ctx cont if err := r.db.WithContext(ctx). Where("project_flock_id = ? AND kandang_id = ?", projectFlockID, kandangID). Preload("ProjectFlock"). - Preload("ProjectFlock.Flock"). Preload("ProjectFlock.Fcr"). Preload("ProjectFlock.Area"). Preload("ProjectFlock.Location"). @@ -104,3 +104,48 @@ func (r *projectFlockKandangRepositoryImpl) GetByProjectFlockAndKandang(ctx cont } return record, nil } + +func (r *projectFlockKandangRepositoryImpl) ListExistingKandangIDs(ctx context.Context, projectFlockID uint, kandangIDs []uint) ([]uint, error) { + if len(kandangIDs) == 0 { + return nil, nil + } + var existing []uint + err := r.db.WithContext(ctx). + Table("project_flock_kandangs"). + Where("project_flock_id = ? AND kandang_id IN ?", projectFlockID, kandangIDs). + Pluck("kandang_id", &existing).Error + return existing, err +} + +func (r *projectFlockKandangRepositoryImpl) HasKandangsLinkedToOtherProject(ctx context.Context, kandangIDs []uint, exceptProjectID *uint) (bool, error) { + if len(kandangIDs) == 0 { + return false, nil + } + q := r.db.WithContext(ctx). + Table("project_flock_kandangs"). + Where("kandang_id IN ?", kandangIDs) + if exceptProjectID != nil { + q = q.Where("project_flock_id <> ?", *exceptProjectID) + } + var count int64 + if err := q.Count(&count).Error; err != nil { + return false, err + } + return count > 0, nil +} + +func (r *projectFlockKandangRepositoryImpl) FindKandangsWithRecordings(ctx context.Context, projectFlockID uint, kandangIDs []uint) ([]entity.Kandang, error) { + if len(kandangIDs) == 0 { + return nil, nil + } + var kandangs []entity.Kandang + err := r.db.WithContext(ctx). + Table("recordings AS r"). + Select("pfk.kandang_id AS id, COALESCE(k.name, '') AS name"). + Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id"). + Joins("LEFT JOIN kandangs AS k ON k.id = pfk.kandang_id"). + Where("pfk.project_flock_id = ? AND pfk.kandang_id IN ?", projectFlockID, kandangIDs). + Group("pfk.kandang_id, k.name"). + Scan(&kandangs).Error + return kandangs, err +} diff --git a/internal/modules/production/project_flocks/services/projectflock.service.go b/internal/modules/production/project_flocks/services/projectflock.service.go index 6193a90a..47589f08 100644 --- a/internal/modules/production/project_flocks/services/projectflock.service.go +++ b/internal/modules/production/project_flocks/services/projectflock.service.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "strconv" "strings" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" @@ -14,6 +15,7 @@ import ( kandangRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" warehouseRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + pfutils "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/utils" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/validations" utils "gitlab.com/mbugroup/lti-api.git/internal/utils" approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" @@ -29,24 +31,24 @@ type ProjectflockService interface { GetOne(ctx *fiber.Ctx, id uint) (*entity.ProjectFlock, error) CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.ProjectFlock, error) UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.ProjectFlock, error) - DeleteOne(ctx *fiber.Ctx, id uint) error - GetProjectFlockKandangByProjectAndKandang(ctx *fiber.Ctx, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, error) GetAvailableDocQuantity(ctx *fiber.Ctx, kandangID uint) (float64, error) + DeleteOne(ctx *fiber.Ctx, id uint) error + GetProjectFlockKandangByProjectAndKandang(ctx *fiber.Ctx, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, float64, error) GetFlockPeriodSummary(ctx *fiber.Ctx, flockID uint) (*FlockPeriodSummary, error) Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.ProjectFlock, error) } type projectflockService struct { - Log *logrus.Logger - Validate *validator.Validate - Repository repository.ProjectflockRepository - FlockRepo flockRepository.FlockRepository - KandangRepo kandangRepository.KandangRepository - WarehouseRepo warehouseRepository.WarehouseRepository - ProductWarehouseRepo productWarehouseRepository.ProductWarehouseRepository - PivotRepo repository.ProjectFlockKandangRepository - ApprovalSvc commonSvc.ApprovalService - approvalWorkflow approvalutils.ApprovalWorkflowKey + Log *logrus.Logger + Validate *validator.Validate + Repository repository.ProjectflockRepository + FlockRepo flockRepository.FlockRepository + KandangRepo kandangRepository.KandangRepository + WarehouseRepo warehouseRepository.WarehouseRepository + ProductWarehouseRepo productWarehouseRepository.ProductWarehouseRepository + PivotRepo repository.ProjectFlockKandangRepository + ApprovalSvc commonSvc.ApprovalService + approvalWorkflow approvalutils.ApprovalWorkflowKey } type FlockPeriodSummary struct { @@ -65,29 +67,19 @@ func NewProjectflockService( validate *validator.Validate, ) ProjectflockService { return &projectflockService{ - Log: utils.Log, - Validate: validate, - Repository: repo, - FlockRepo: flockRepo, - KandangRepo: kandangRepo, - WarehouseRepo: warehouseRepo, - ProductWarehouseRepo: productWarehouseRepo, - PivotRepo: pivotRepo, - ApprovalSvc: approvalSvc, - approvalWorkflow: utils.ApprovalWorkflowProjectFlock, + Log: utils.Log, + Validate: validate, + Repository: repo, + FlockRepo: flockRepo, + KandangRepo: kandangRepo, + WarehouseRepo: warehouseRepo, + ProductWarehouseRepo: productWarehouseRepo, + PivotRepo: pivotRepo, + ApprovalSvc: approvalSvc, + approvalWorkflow: utils.ApprovalWorkflowProjectFlock, } } -func (s projectflockService) withRelations(db *gorm.DB) *gorm.DB { - return db. - Preload("CreatedUser"). - Preload("Flock"). - Preload("Area"). - Preload("Fcr"). - Preload("Location"). - Preload("Kandangs") -} - func (s projectflockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlock, int64, error) { if err := s.Validate.Struct(params); err != nil { return nil, 0, err @@ -102,79 +94,11 @@ func (s projectflockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]e offset := (params.Page - 1) * params.Limit - projectflocks, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { - db = s.withRelations(db) - - if params.AreaId > 0 { - db = db.Where("project_flocks.area_id = ?", params.AreaId) - } - if params.LocationId > 0 { - db = db.Where("project_flocks.location_id = ?", params.LocationId) - } - if params.Period > 0 { - db = db.Where("project_flocks.period = ?", params.Period) - } - if len(params.KandangIds) > 0 { - db = db.Where(` - EXISTS ( - SELECT 1 - FROM project_flock_kandangs pfk - WHERE pfk.project_flock_id = project_flocks.id - AND pfk.kandang_id IN ? - )`, params.KandangIds) - } - if params.Search != "" { - normalizedSearch := strings.ToLower(strings.TrimSpace(params.Search)) - if normalizedSearch == "" { - for _, expr := range s.buildOrderExpressions(params.SortBy, params.SortOrder) { - db = db.Order(expr) - } - return db - } - likeQuery := "%" + normalizedSearch + "%" - db = db. - Joins("LEFT JOIN flocks ON flocks.id = project_flocks.flock_id"). - Joins("LEFT JOIN areas ON areas.id = project_flocks.area_id"). - Joins("LEFT JOIN fcrs ON fcrs.id = project_flocks.fcr_id"). - Joins("LEFT JOIN locations ON locations.id = project_flocks.location_id"). - Joins("LEFT JOIN users AS created_users ON created_users.id = project_flocks.created_by"). - Where(` - LOWER(flocks.name) LIKE ? - OR LOWER(areas.name) LIKE ? - OR LOWER(project_flocks.category) LIKE ? - OR LOWER(fcrs.name) LIKE ? - OR LOWER(locations.name) LIKE ? - OR LOWER(locations.address) LIKE ? - OR LOWER(created_users.name) LIKE ? - OR LOWER(created_users.email) LIKE ? - OR LOWER(CAST(project_flocks.period AS TEXT)) LIKE ? - OR EXISTS ( - SELECT 1 FROM kandangs - WHERE kandangs.project_flock_id = project_flocks.id - AND LOWER(kandangs.name) LIKE ? - ) - `, - likeQuery, - likeQuery, - likeQuery, - likeQuery, - likeQuery, - likeQuery, - likeQuery, - likeQuery, - likeQuery, - likeQuery, - ) - } - for _, expr := range s.buildOrderExpressions(params.SortBy, params.SortOrder) { - db = db.Order(expr) - } - return db - }) + projectflocks, total, err := s.Repository.GetAllWithFilters(c.Context(), offset, params.Limit, params) if err != nil { s.Log.Errorf("Failed to get projectflocks: %+v", err) - return nil, 0, err + return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flocks") } if s.ApprovalSvc != nil && len(projectflocks) > 0 { @@ -201,13 +125,13 @@ func (s projectflockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]e } func (s projectflockService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectFlock, error) { - projectflock, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + projectflock, err := s.Repository.GetByID(c.Context(), id, s.Repository.WithDefaultRelations()) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Projectflock not found") } if err != nil { s.Log.Errorf("Failed get projectflock by id: %+v", err) - return nil, err + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock") } if s.ApprovalSvc != nil { @@ -243,15 +167,28 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (* return nil, fiber.NewError(fiber.StatusBadRequest, "kandang_ids is required") } + baseName := strings.TrimSpace(req.FlockName) + if baseName == "" { + return nil, fiber.NewError(fiber.StatusBadRequest, "Flock name cannot be empty") + } + if err := commonSvc.EnsureRelations(c.Context(), - commonSvc.RelationCheck{Name: "Flock", ID: &req.FlockId, Exists: relationExistsChecker[entity.Flock](s.Repository.DB())}, - commonSvc.RelationCheck{Name: "Area", ID: &req.AreaId, Exists: relationExistsChecker[entity.Area](s.Repository.DB())}, - commonSvc.RelationCheck{Name: "FCR", ID: &req.FcrId, Exists: relationExistsChecker[entity.Fcr](s.Repository.DB())}, - commonSvc.RelationCheck{Name: "Location", ID: &req.LocationId, Exists: relationExistsChecker[entity.Location](s.Repository.DB())}, + commonSvc.RelationCheck{Name: "Area", ID: &req.AreaId, Exists: s.Repository.AreaExists}, + commonSvc.RelationCheck{Name: "FCR", ID: &req.FcrId, Exists: s.Repository.FcrExists}, + commonSvc.RelationCheck{Name: "Location", ID: &req.LocationId, Exists: s.Repository.LocationExists}, ); err != nil { return nil, err } + canonicalBase := baseName + if s.FlockRepo != nil { + baseFlock, err := s.ensureFlockByName(c.Context(), baseName) + if err != nil { + return nil, err + } + canonicalBase = baseFlock.Name + } + kandangIDs := uniqueUintSlice(req.KandangIds) kandangs, err := s.KandangRepo.GetByIDs(c.Context(), kandangIDs, nil) if err != nil { @@ -264,14 +201,14 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (* return nil, fiber.NewError(fiber.StatusNotFound, "Some kandangs not found") } // larang kalau ada yg sudah terikat ke project lain - if linked, err := s.anyKandangLinkedToOtherProject(c.Context(), s.Repository.DB(), kandangIDs, nil); err != nil { + if linked, err := s.pivotRepo().HasKandangsLinkedToOtherProject(c.Context(), kandangIDs, nil); err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate kandangs linkage") } else if linked { return nil, fiber.NewError(fiber.StatusConflict, "Beberapa kandang sudah terikat dengan project flock lain") } createBody := &entity.ProjectFlock{ - FlockId: req.FlockId, + FlockName: "", AreaId: req.AreaId, Category: cat, FcrId: req.FcrId, @@ -282,11 +219,16 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (* err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { projectRepo := repository.NewProjectflockRepository(dbTransaction) - period, err := projectRepo.GetNextPeriodForFlock(c.Context(), req.FlockId) + nextSeq, err := projectRepo.GetNextSequenceForBase(c.Context(), canonicalBase) if err != nil { return err } - createBody.Period = period + generatedName, seq, err := s.generateSequentialFlockName(c.Context(), projectRepo, canonicalBase, nextSeq, nil) + if err != nil { + return err + } + createBody.FlockName = generatedName + createBody.Period = seq if err := projectRepo.CreateOne(c.Context(), createBody, nil); err != nil { return err @@ -312,11 +254,14 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (* }) if err != nil { + if fiberErr, ok := err.(*fiber.Error); ok { + return nil, fiberErr + } if errors.Is(err, gorm.ErrDuplicatedKey) { return nil, fiber.NewError(fiber.StatusConflict, "Project flock period already exists") } s.Log.Errorf("Failed to create projectflock: %+v", err) - return nil, err + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to create project flock") } return s.GetOne(c, createBody.Id) @@ -327,7 +272,7 @@ func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id return nil, err } - existing, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + existing, err := s.Repository.GetByID(c.Context(), id, s.Repository.WithDefaultRelations()) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Projectflock not found") } @@ -338,15 +283,28 @@ func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id updateBody := make(map[string]any) hasBodyChanges := false var relationChecks []commonSvc.RelationCheck + existingBase := pfutils.DeriveBaseName(existing.FlockName) + targetBaseName := existingBase + needFlockNameRegenerate := false - if req.FlockId != nil { - updateBody["flock_id"] = *req.FlockId - hasBodyChanges = true - relationChecks = append(relationChecks, commonSvc.RelationCheck{ - Name: "Flock", - ID: req.FlockId, - Exists: relationExistsChecker[entity.Flock](s.Repository.DB()), - }) + if req.FlockName != nil { + trimmed := strings.TrimSpace(*req.FlockName) + if trimmed == "" { + return nil, fiber.NewError(fiber.StatusBadRequest, "Flock name cannot be empty") + } + canonicalBase := trimmed + if s.FlockRepo != nil { + flockEntity, err := s.ensureFlockByName(c.Context(), trimmed) + if err != nil { + return nil, err + } + canonicalBase = flockEntity.Name + } + if !strings.EqualFold(canonicalBase, existingBase) { + needFlockNameRegenerate = true + targetBaseName = canonicalBase + hasBodyChanges = true + } } if req.AreaId != nil { updateBody["area_id"] = *req.AreaId @@ -354,7 +312,7 @@ func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id relationChecks = append(relationChecks, commonSvc.RelationCheck{ Name: "Area", ID: req.AreaId, - Exists: relationExistsChecker[entity.Area](s.Repository.DB()), + Exists: s.Repository.AreaExists, }) } if req.Category != nil { @@ -371,7 +329,7 @@ func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id relationChecks = append(relationChecks, commonSvc.RelationCheck{ Name: "FCR", ID: req.FcrId, - Exists: relationExistsChecker[entity.Fcr](s.Repository.DB()), + Exists: s.Repository.FcrExists, }) } if req.LocationId != nil { @@ -380,7 +338,7 @@ func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id relationChecks = append(relationChecks, commonSvc.RelationCheck{ Name: "Location", ID: req.LocationId, - Exists: relationExistsChecker[entity.Location](s.Repository.DB()), + Exists: s.Repository.LocationExists, }) } @@ -408,7 +366,7 @@ func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id if len(kandangs) != len(newKandangIDs) { return nil, fiber.NewError(fiber.StatusNotFound, "Some kandangs not found") } - if linked, err := s.anyKandangLinkedToOtherProject(c.Context(), s.Repository.DB(), newKandangIDs, &id); err != nil { + if linked, err := s.pivotRepo().HasKandangsLinkedToOtherProject(c.Context(), newKandangIDs, &id); err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate kandangs linkage") } else if linked { return nil, fiber.NewError(fiber.StatusConflict, "Beberapa kandang sudah terikat dengan project flock lain") @@ -424,6 +382,29 @@ func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { projectRepo := repository.NewProjectflockRepository(dbTransaction) + baseForGeneration := targetBaseName + if strings.TrimSpace(baseForGeneration) == "" { + baseForGeneration = existingBase + } + if strings.TrimSpace(baseForGeneration) == "" { + baseForGeneration = strings.TrimSpace(existing.FlockName) + } + + if needFlockNameRegenerate { + nextSeq, err := projectRepo.GetNextSequenceForBase(c.Context(), baseForGeneration) + if err != nil { + return err + } + newName, seq, err := s.generateSequentialFlockName(c.Context(), projectRepo, baseForGeneration, nextSeq, &id) + if err != nil { + return err + } + updateBody["flock_name"] = newName + if seq != existing.Period { + updateBody["period"] = seq + } + } + if len(updateBody) > 0 { if err := projectRepo.PatchOne(c.Context(), id, updateBody, nil); err != nil { return err @@ -512,7 +493,10 @@ func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id return nil, fiber.NewError(fiber.StatusNotFound, "Projectflock not found") } s.Log.Errorf("Failed to update projectflock %d: %+v", id, err) - return nil, err + if errors.Is(err, gorm.ErrDuplicatedKey) { + return nil, fiber.NewError(fiber.StatusConflict, "Project flock period already exists") + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to update project flock") } return s.GetOne(c, id) @@ -616,7 +600,7 @@ func (s projectflockService) Approval(c *fiber.Ctx, req *validation.Approve) ([] } func (s projectflockService) DeleteOne(c *fiber.Ctx, id uint) error { - existing, err := s.Repository.GetByID(c.Context(), id, s.withRelations) + existing, err := s.Repository.GetByID(c.Context(), id, s.Repository.WithDefaultRelations()) if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusNotFound, "Projectflock not found") } @@ -650,22 +634,70 @@ func (s projectflockService) DeleteOne(c *fiber.Ctx, id uint) error { return fiberErr } s.Log.Errorf("Failed to delete projectflock %d: %+v", id, err) - return err + return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete project flock") } return nil } -func (s projectflockService) GetProjectFlockKandangByProjectAndKandang(ctx *fiber.Ctx, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, error) { +func (s projectflockService) GetProjectFlockKandangByProjectAndKandang(ctx *fiber.Ctx, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, float64, error) { pfk, err := s.PivotRepo.GetByProjectFlockAndKandang(ctx.Context(), projectFlockID, kandangID) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "ProjectFlockKandang not found") + return nil, 0, fiber.NewError(fiber.StatusNotFound, "ProjectFlockKandang not found") } - return nil, err + s.Log.Errorf("Failed to fetch project_flock_kandang by project %d and kandang %d: %+v", projectFlockID, kandangID, err) + return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandang") } - return pfk, nil + + availableQuantity, err := s.GetAvailableDocQuantity(ctx, pfk.KandangId) + if err != nil { + return nil, 0, err + } + + return pfk, availableQuantity, nil +} + +func (s projectflockService) GetProjectFlockKandangByParams(ctx *fiber.Ctx, idStr string, projectFlockIdStr string, kandangIdStr string) (*entity.ProjectFlockKandang, float64, error) { + idStr = strings.TrimSpace(idStr) + projectFlockIdStr = strings.TrimSpace(projectFlockIdStr) + kandangIdStr = strings.TrimSpace(kandangIdStr) + + if idStr != "" { + id, err := strconv.Atoi(idStr) + if err != nil || id <= 0 { + return nil, 0, fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + pfk, err := s.PivotRepo.GetByID(ctx.Context(), uint(id)) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, 0, fiber.NewError(fiber.StatusNotFound, "ProjectFlockKandang not found") + } + s.Log.Errorf("Failed to fetch project_flock_kandang %d: %+v", id, err) + return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandang") + } + + availableQuantity, err := s.GetAvailableDocQuantity(ctx, pfk.KandangId) + if err != nil { + return nil, 0, err + } + + return pfk, availableQuantity, nil + } + + if projectFlockIdStr == "" || kandangIdStr == "" { + return nil, 0, fiber.NewError(fiber.StatusBadRequest, "Missing lookup parameters") + } + pfid, err := strconv.Atoi(projectFlockIdStr) + if err != nil || pfid <= 0 { + return nil, 0, fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_id") + } + kid, err := strconv.Atoi(kandangIdStr) + if err != nil || kid <= 0 { + return nil, 0, fiber.NewError(fiber.StatusBadRequest, "Invalid kandang_id") + } + return s.GetProjectFlockKandangByProjectAndKandang(ctx, uint(pfid), uint(kid)) } func (s projectflockService) GetAvailableDocQuantity(ctx *fiber.Ctx, kandangID uint) (float64, error) { @@ -675,14 +707,7 @@ func (s projectflockService) GetAvailableDocQuantity(ctx *fiber.Ctx, kandangID u return 0, err } - var productWarehouses []entity.ProductWarehouse - err = s.ProductWarehouseRepo.DB(). - WithContext(ctx.Context()). - Joins("JOIN products ON products.id = product_warehouses.product_id"). - Joins("JOIN product_categories ON product_categories.id = products.product_category_id"). - Where("product_categories.code = ? AND product_warehouses.warehouse_id = ?", "DOC", wh.Id). - Order("created_at DESC"). - Find(&productWarehouses).Error + productWarehouses, err := s.ProductWarehouseRepo.GetByCategoryCodeAndWarehouseID(ctx.Context(), "DOC", wh.Id) if err != nil { return 0, err } @@ -706,7 +731,7 @@ func (s projectflockService) GetFlockPeriodSummary(c *fiber.Ctx, flockID uint) ( return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch flock") } - maxPeriod, err := s.Repository.GetMaxPeriodByFlock(c.Context(), flockID) + maxPeriod, err := s.Repository.GetMaxPeriodByBaseName(c.Context(), flock.Name) if err != nil { s.Log.Errorf("Failed to compute next period for flock %d: %+v", flockID, err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to compute next period") @@ -731,45 +756,64 @@ func uniqueUintSlice(values []uint) []uint { return result } -func relationExistsChecker[T any](db *gorm.DB) func(context.Context, uint) (bool, error) { - return func(ctx context.Context, id uint) (bool, error) { - return commonRepo.Exists[T](ctx, db, id) +func (s projectflockService) generateSequentialFlockName(ctx context.Context, repo repository.ProjectflockRepository, baseName string, startNumber int, excludeID *uint) (string, int, error) { + name := strings.TrimSpace(baseName) + if name == "" { + return "", 0, fiber.NewError(fiber.StatusBadRequest, "Base flock name cannot be empty") + } + + number := startNumber + if number <= 0 { + number = 1 + } + + attempts := 0 + for { + candidate := fmt.Sprintf("%s %03d", name, number) + exists, err := repo.ExistsByFlockName(ctx, candidate, excludeID) + if err != nil { + s.Log.Errorf("Failed checking project flock name uniqueness for %q: %+v", candidate, err) + return "", 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to generate flock name") + } + if !exists { + return candidate, number, nil + } + number++ + attempts++ + if attempts > 9999 { + return "", 0, fiber.NewError(fiber.StatusInternalServerError, "Unable to generate unique flock name") + } } } -func (s projectflockService) buildOrderExpressions(sortBy, sortOrder string) []string { - direction := "ASC" - if strings.ToLower(sortOrder) == "desc" { - direction = "DESC" +func (s projectflockService) ensureFlockByName(ctx context.Context, name string) (*entity.Flock, error) { + trimmed := strings.TrimSpace(name) + if trimmed == "" { + return nil, fiber.NewError(fiber.StatusBadRequest, "Flock name cannot be empty") } - switch sortBy { - case "area": - return []string{ - fmt.Sprintf("(SELECT name FROM areas WHERE areas.id = project_flocks.area_id) %s", direction), - fmt.Sprintf("project_flocks.id %s", direction), - } - case "location": - return []string{ - fmt.Sprintf("(SELECT name FROM locations WHERE locations.id = project_flocks.location_id) %s", direction), - fmt.Sprintf("project_flocks.id %s", direction), - } - case "kandangs": - return []string{ - fmt.Sprintf("(SELECT COUNT(*) FROM project_flock_kandangs pfk WHERE pfk.project_flock_id = project_flocks.id) %s", direction), - fmt.Sprintf("project_flocks.id %s", direction), - } - case "period": - return []string{ - fmt.Sprintf("project_flocks.period %s", direction), - fmt.Sprintf("project_flocks.id %s", direction), - } - default: - return []string{ - "project_flocks.created_at DESC", - "project_flocks.updated_at DESC", - } + flock, err := s.FlockRepo.GetByName(ctx, trimmed) + if err == nil { + return flock, nil } + if !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Failed to fetch flock by name %q: %+v", trimmed, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to prepare flock data") + } + + newFlock := &entity.Flock{ + Name: trimmed, + CreatedBy: 1, // TODO: replace with authenticated user + } + if err := s.FlockRepo.CreateOne(ctx, newFlock, nil); err != nil { + if errors.Is(err, gorm.ErrDuplicatedKey) { + return s.FlockRepo.GetByName(ctx, trimmed) + } + s.Log.Errorf("Failed to create flock %q: %+v", trimmed, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to prepare flock data") + } + + return newFlock, nil } func (s projectflockService) attachKandangs(ctx context.Context, dbTransaction *gorm.DB, projectFlockID uint, kandangIDs []uint) error { @@ -777,20 +821,12 @@ func (s projectflockService) attachKandangs(ctx context.Context, dbTransaction * return nil } - if err := dbTransaction. - Model(&entity.Kandang{}). - Where("id IN ?", kandangIDs). - Updates(map[string]any{ - "status": string(utils.KandangStatusPengajuan), - }).Error; err != nil { + if err := s.kandangRepoWithTx(dbTransaction).UpdateStatusByIDs(ctx, kandangIDs, utils.KandangStatusPengajuan); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to update kandangs status") } - var already []uint - if err := dbTransaction. - Table("project_flock_kandangs"). - Where("project_flock_id = ? AND kandang_id IN ?", projectFlockID, kandangIDs). - Pluck("kandang_id", &already).Error; err != nil { + already, err := s.pivotRepoWithTx(dbTransaction).ListExistingKandangIDs(ctx, projectFlockID, kandangIDs) + if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to check existing pivot") } exists := make(map[uint]struct{}, len(already)) @@ -799,7 +835,7 @@ func (s projectflockService) attachKandangs(ctx context.Context, dbTransaction * } var toAttach []uint - seen := make(map[uint]struct{}, len(kandangIDs)) + seen := make(map[uint]struct{}, len(kandangIDs)) for _, id := range kandangIDs { if _, ok := seen[id]; ok { continue @@ -821,6 +857,9 @@ func (s projectflockService) attachKandangs(ctx context.Context, dbTransaction * }) } if err := s.pivotRepoWithTx(dbTransaction).CreateMany(ctx, records); err != nil { + if errors.Is(err, gorm.ErrDuplicatedKey) { + return fiber.NewError(fiber.StatusConflict, "Beberapa kandang sudah terhubung dengan project flock ini") + } return fiber.NewError(fiber.StatusInternalServerError, "Failed to persist project flock history") } return nil @@ -831,13 +870,25 @@ func (s projectflockService) detachKandangs(ctx context.Context, dbTransaction * return nil } + blocked, err := s.pivotRepoWithTx(dbTransaction).FindKandangsWithRecordings(ctx, projectFlockID, kandangIDs) + if err != nil { + s.Log.Errorf("Failed to check recordings before detaching kandangs: %+v", err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate kandang detachment") + } + if len(blocked) > 0 { + names := make([]string, 0, len(blocked)) + for _, item := range blocked { + label := fmt.Sprintf("ID %d", item.Id) + if strings.TrimSpace(item.Name) != "" { + label = fmt.Sprintf("%s (%s)", label, item.Name) + } + names = append(names, label) + } + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Tidak dapat melepas kandang karena sudah memiliki recording: %s", strings.Join(names, ", "))) + } + if resetStatus { - if err := dbTransaction. - Model(&entity.Kandang{}). - Where("id IN ?", kandangIDs). - Updates(map[string]any{ - "status": string(utils.KandangStatusNonActive), - }).Error; err != nil { + if err := s.kandangRepoWithTx(dbTransaction).UpdateStatusByIDs(ctx, kandangIDs, utils.KandangStatusNonActive); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to update kandangs status") } } @@ -849,23 +900,25 @@ func (s projectflockService) detachKandangs(ctx context.Context, dbTransaction * } func (s projectflockService) pivotRepoWithTx(dbTransaction *gorm.DB) repository.ProjectFlockKandangRepository { - if s.PivotRepo == nil { - return repository.NewProjectFlockKandangRepository(dbTransaction) + if dbTransaction == nil { + return s.pivotRepo() } - return s.PivotRepo.WithTx(dbTransaction) + return s.pivotRepo().WithTx(dbTransaction) } -func (s projectflockService) anyKandangLinkedToOtherProject(ctx context.Context, db *gorm.DB, kandangIDs []uint, exceptProjectID *uint) (bool, error) { - q := db.WithContext(ctx). - Table("project_flock_kandangs"). - Where("kandang_id IN ?", kandangIDs) - if exceptProjectID != nil { - q = q.Where("project_flock_id <> ?", *exceptProjectID) +func (s projectflockService) pivotRepo() repository.ProjectFlockKandangRepository { + if s.PivotRepo != nil { + return s.PivotRepo } - var count int64 - if err := q.Count(&count).Error; err != nil { - return false, err - } - return count > 0, nil + return repository.NewProjectFlockKandangRepository(s.Repository.DB()) } +func (s projectflockService) kandangRepoWithTx(tx *gorm.DB) kandangRepository.KandangRepository { + if tx != nil { + return kandangRepository.NewKandangRepository(tx) + } + if s.KandangRepo != nil { + return s.KandangRepo + } + return kandangRepository.NewKandangRepository(s.Repository.DB()) +} diff --git a/internal/modules/production/project_flocks/utils/base_name.go b/internal/modules/production/project_flocks/utils/base_name.go new file mode 100644 index 00000000..93e8af53 --- /dev/null +++ b/internal/modules/production/project_flocks/utils/base_name.go @@ -0,0 +1,25 @@ +package utils + +import ( + "strconv" + "strings" +) + +// DeriveBaseName removes trailing numeric tokens from the flock name. +func DeriveBaseName(name string) string { + trimmed := strings.TrimSpace(name) + if trimmed == "" { + return "" + } + + parts := strings.Fields(trimmed) + for len(parts) > 0 { + if _, err := strconv.Atoi(parts[len(parts)-1]); err == nil { + parts = parts[:len(parts)-1] + continue + } + break + } + + return strings.TrimSpace(strings.Join(parts, " ")) +} diff --git a/internal/modules/production/project_flocks/validations/projectflock.validation.go b/internal/modules/production/project_flocks/validations/projectflock.validation.go index f853c883..7932e07e 100644 --- a/internal/modules/production/project_flocks/validations/projectflock.validation.go +++ b/internal/modules/production/project_flocks/validations/projectflock.validation.go @@ -1,7 +1,7 @@ package validation type Create struct { - FlockId uint `json:"flock_id" validate:"required_strict,number,gt=0"` + FlockName string `json:"flock_name" validate:"required_strict"` AreaId uint `json:"area_id" validate:"required_strict,number,gt=0"` Category string `json:"category" validate:"required_strict"` FcrId uint `json:"fcr_id" validate:"required_strict,number,gt=0"` @@ -10,7 +10,7 @@ type Create struct { } type Update struct { - FlockId *uint `json:"flock_id,omitempty" validate:"omitempty,number,gt=0"` + FlockName *string `json:"flock_name,omitempty" validate:"omitempty"` AreaId *uint `json:"area_id,omitempty" validate:"omitempty,number,gt=0"` Category *string `json:"category,omitempty" validate:"omitempty"` FcrId *uint `json:"fcr_id,omitempty" validate:"omitempty,number,gt=0"` diff --git a/internal/modules/production/recordings/controllers/recording.controller.go b/internal/modules/production/recordings/controllers/recording.controller.go index a924eb18..c348a454 100644 --- a/internal/modules/production/recordings/controllers/recording.controller.go +++ b/internal/modules/production/recordings/controllers/recording.controller.go @@ -146,6 +146,60 @@ func (u *RecordingController) UpdateOne(c *fiber.Ctx) error { }) } +func (u *RecordingController) SubmitGrading(c *fiber.Ctx) error { + req := new(validation.SubmitGrading) + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.RecordingService.SubmitGrading(c, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Submit grading eggs successfully", + Data: dto.ToRecordingDetailDTO(*result), + }) +} + +func (u *RecordingController) Approve(c *fiber.Ctx) error { + req := new(validation.Approve) + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + results, err := u.RecordingService.Approval(c, req) + if err != nil { + return err + } + + var ( + data interface{} + message = "Submit recording approvals successfully" + ) + + if len(results) == 1 { + message = "Submit recording approval successfully" + data = dto.ToRecordingDetailDTO(results[0]) + } else { + data = dto.ToRecordingListDTOs(results) + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: message, + Data: data, + }) +} + func (u *RecordingController) DeleteOne(c *fiber.Ctx) error { param := c.Params("id") diff --git a/internal/modules/production/recordings/dto/recording.dto.go b/internal/modules/production/recordings/dto/recording.dto.go index 52c5fb56..07135e1d 100644 --- a/internal/modules/production/recordings/dto/recording.dto.go +++ b/internal/modules/production/recordings/dto/recording.dto.go @@ -1,30 +1,34 @@ package dto import ( + "math" "time" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" ) // === DTO Structs === type RecordingBaseDTO struct { - Id uint `json:"id"` - ProjectFlockKandangId uint `json:"project_flock_kandang_id"` - RecordDatetime time.Time `json:"record_datetime"` - RecordDate *time.Time `json:"record_date,omitempty"` - Ontime bool `json:"ontime"` - Day *int `json:"day,omitempty"` - TotalDepletion *int `json:"total_depletion,omitempty"` - CumDepletionRate *float64 `json:"cum_depletion_rate,omitempty"` - DailyGain *float64 `json:"daily_gain,omitempty"` - AvgDailyGain *float64 `json:"avg_daily_gain,omitempty"` - CumIntake *int64 `json:"cum_intake,omitempty"` - FcrValue *float64 `json:"fcr_value,omitempty"` - TotalChick *int64 `json:"total_chick,omitempty"` - DailyDepletionRate *float64 `json:"daily_depletion_rate,omitempty"` - CumDepletion *int `json:"cum_depletion,omitempty"` + Id uint `json:"id"` + ProjectFlockKandangId uint `json:"project_flock_kandang_id"` + RecordDatetime time.Time `json:"record_datetime"` + Day *int `json:"day,omitempty"` + ProjectFlockCategory *string `json:"project_flock_category,omitempty"` + TotalDepletionQty *float64 `json:"total_depletion_qty,omitempty"` + CumDepletionRate *float64 `json:"cum_depletion_rate,omitempty"` + DailyGain *float64 `json:"daily_gain,omitempty"` + AvgDailyGain *float64 `json:"avg_daily_gain,omitempty"` + CumIntake *int `json:"cum_intake,omitempty"` + FcrValue *float64 `json:"fcr_value,omitempty"` + TotalChickQty *float64 `json:"total_chick_qty,omitempty"` + Approval approvalDTO.ApprovalBaseDTO `json:"approval"` + EggGradingStatus *string `json:"egg_grading_status,omitempty"` + EggGradingPendingQty *int `json:"egg_grading_pending_qty,omitempty"` } type RecordingListDTO struct { @@ -39,30 +43,35 @@ type RecordingDetailDTO struct { BodyWeights []RecordingBodyWeightDTO `json:"body_weights"` Depletions []RecordingDepletionDTO `json:"depletions"` Stocks []RecordingStockDTO `json:"stocks"` + Eggs []RecordingEggDTO `json:"eggs"` } type RecordingBodyWeightDTO struct { - Weight float64 `json:"weight"` - Qty int `json:"qty"` - Notes *string `json:"notes,omitempty"` + AvgWeight float64 `json:"avg_weight"` + Qty float64 `json:"qty"` + TotalWeight float64 `json:"total_weight"` } type RecordingDepletionDTO struct { ProductWarehouseId uint `json:"product_warehouse_id"` - Total int64 `json:"total"` - Notes *string `json:"notes,omitempty"` + Qty float64 `json:"qty"` ProductWarehouse *RecordingProductWarehouseDTO `json:"product_warehouse,omitempty"` } type RecordingStockDTO struct { ProductWarehouseId uint `json:"product_warehouse_id"` - Increase *float64 `json:"increase,omitempty"` - Decrease *float64 `json:"decrease,omitempty"` - UsageAmount *int64 `json:"usage_amount,omitempty"` - Notes *string `json:"notes,omitempty"` + UsageAmount *float64 `json:"usage_amount,omitempty"` + PendingQty *float64 `json:"pending_qty,omitempty"` ProductWarehouse *RecordingProductWarehouseDTO `json:"product_warehouse,omitempty"` } +type RecordingEggDTO struct { + ProductWarehouseId uint `json:"product_warehouse_id"` + Qty int `json:"qty"` + ProductWarehouse *RecordingProductWarehouseDTO `json:"product_warehouse,omitempty"` + Gradings []RecordingEggGradingDTO `json:"gradings,omitempty"` +} + type RecordingProductWarehouseDTO struct { Id uint `json:"id"` ProductId uint `json:"product_id"` @@ -71,36 +80,46 @@ type RecordingProductWarehouseDTO struct { WarehouseName string `json:"warehouse_name"` } +type RecordingEggGradingDTO struct { + Grade string `json:"grade,omitempty"` + Qty float64 `json:"qty"` +} + // === Mapper Functions === func ToRecordingBaseDTO(e entity.Recording) RecordingBaseDTO { - recordDate := e.RecordDate - if recordDate == nil { - rd := time.Date( - e.RecordDatetime.Year(), - e.RecordDatetime.Month(), - e.RecordDatetime.Day(), - 0, 0, 0, 0, - e.RecordDatetime.Location(), - ) - recordDate = &rd + var projectFlockCategory *string + if e.ProjectFlockKandang != nil && e.ProjectFlockKandang.ProjectFlock.Id != 0 { + category := e.ProjectFlockKandang.ProjectFlock.Category + if category != "" { + projectFlockCategory = &category + } } + + latestApproval := defaultRecordingLatestApproval(e) + if e.LatestApproval != nil { + snapshot := approvalDTO.ToApprovalDTO(*e.LatestApproval) + latestApproval = snapshot + } + + gradingStatus, gradingPending := computeEggGradingStatus(e) + return RecordingBaseDTO{ Id: e.Id, ProjectFlockKandangId: e.ProjectFlockKandangId, RecordDatetime: e.RecordDatetime, - RecordDate: recordDate, - Ontime: e.Ontime == 1, Day: e.Day, - TotalDepletion: e.TotalDepletion, + ProjectFlockCategory: projectFlockCategory, + TotalDepletionQty: e.TotalDepletionQty, CumDepletionRate: e.CumDepletionRate, DailyGain: e.DailyGain, AvgDailyGain: e.AvgDailyGain, CumIntake: e.CumIntake, FcrValue: e.FcrValue, - TotalChick: e.TotalChick, - DailyDepletionRate: e.DailyDepletionRate, - CumDepletion: e.CumDepletion, + TotalChickQty: e.TotalChickQty, + Approval: latestApproval, + EggGradingStatus: gradingStatus, + EggGradingPendingQty: gradingPending, } } @@ -133,6 +152,7 @@ func ToRecordingDetailDTO(e entity.Recording) RecordingDetailDTO { BodyWeights: ToRecordingBodyWeightDTOs(e.BodyWeights), Depletions: ToRecordingDepletionDTOs(e.Depletions), Stocks: ToRecordingStockDTOs(e.Stocks), + Eggs: ToRecordingEggDTOs(e.Eggs), } } @@ -140,9 +160,9 @@ func ToRecordingBodyWeightDTOs(bodyWeights []entity.RecordingBW) []RecordingBody result := make([]RecordingBodyWeightDTO, len(bodyWeights)) for i, bw := range bodyWeights { result[i] = RecordingBodyWeightDTO{ - Weight: bw.Weight, - Qty: bw.Qty, - Notes: bw.Notes, + AvgWeight: bw.AvgWeight, + Qty: bw.Qty, + TotalWeight: bw.TotalWeight, } } return result @@ -153,8 +173,7 @@ func ToRecordingDepletionDTOs(depletions []entity.RecordingDepletion) []Recordin for i, d := range depletions { result[i] = RecordingDepletionDTO{ ProductWarehouseId: d.ProductWarehouseId, - Total: d.Total, - Notes: d.Notes, + Qty: d.Qty, ProductWarehouse: toRecordingProductWarehouseDTO(&d.ProductWarehouse), } } @@ -166,16 +185,43 @@ func ToRecordingStockDTOs(stocks []entity.RecordingStock) []RecordingStockDTO { for i, s := range stocks { result[i] = RecordingStockDTO{ ProductWarehouseId: s.ProductWarehouseId, - Increase: s.Increase, - Decrease: s.Decrease, - UsageAmount: s.UsageAmount, - Notes: s.Notes, + UsageAmount: s.UsageQty, + PendingQty: s.PendingQty, ProductWarehouse: toRecordingProductWarehouseDTO(&s.ProductWarehouse), } } return result } +func ToRecordingEggDTOs(eggs []entity.RecordingEgg) []RecordingEggDTO { + result := make([]RecordingEggDTO, len(eggs)) + for i, egg := range eggs { + result[i] = RecordingEggDTO{ + ProductWarehouseId: egg.ProductWarehouseId, + Qty: egg.Qty, + ProductWarehouse: toRecordingProductWarehouseDTO(&egg.ProductWarehouse), + Gradings: ToRecordingEggGradingDTOs(egg.GradingEggs), + } + } + return result +} + +func ToRecordingEggGradingDTOs(gradings []entity.GradingEgg) []RecordingEggGradingDTO { + if len(gradings) == 0 { + return nil + } + + result := make([]RecordingEggGradingDTO, len(gradings)) + for i, grading := range gradings { + result[i] = RecordingEggGradingDTO{ + Grade: grading.Grade, + Qty: grading.Qty, + } + } + + return result +} + func toRecordingProductWarehouseDTO(pw *entity.ProductWarehouse) *RecordingProductWarehouseDTO { if pw == nil || pw.Id == 0 { return nil @@ -196,3 +242,57 @@ func toRecordingProductWarehouseDTO(pw *entity.ProductWarehouse) *RecordingProdu return &dto } + +func computeEggGradingStatus(e entity.Recording) (*string, *int) { + if len(e.Eggs) == 0 { + return nil, nil + } + + totalEggs := 0 + totalGraded := 0.0 + for _, egg := range e.Eggs { + totalEggs += egg.Qty + for _, grading := range egg.GradingEggs { + totalGraded += grading.Qty + } + } + + if totalEggs == 0 { + return nil, nil + } + + pending := float64(totalEggs) - totalGraded + + if pending > 0.5 { + status := "GRADING_TELUR" + pendingInt := int(math.Round(pending)) + return &status, &pendingInt + } + + status := "GRADING_SELESAI" + zero := 0 + return &status, &zero +} + +func defaultRecordingLatestApproval(e entity.Recording) approvalDTO.ApprovalBaseDTO { + result := approvalDTO.ApprovalBaseDTO{} + + step := utils.RecordingStepPengajuan + result.StepNumber = uint16(step) + if label, ok := approvalutils.ApprovalStepName(utils.ApprovalWorkflowRecording, step); ok { + result.StepName = label + } else if label, ok := utils.RecordingApprovalSteps[step]; ok { + result.StepName = label + } + + if e.CreatedUser != nil && e.CreatedUser.Id != 0 { + result.ActionBy = userDTO.ToUserBaseDTO(*e.CreatedUser) + } else if e.CreatedBy != 0 { + result.ActionBy = userDTO.UserBaseDTO{ + Id: e.CreatedBy, + IdUser: int64(e.CreatedBy), + } + } + + return result +} diff --git a/internal/modules/production/recordings/module.go b/internal/modules/production/recordings/module.go index 91151a9c..ff6b4ea0 100644 --- a/internal/modules/production/recordings/module.go +++ b/internal/modules/production/recordings/module.go @@ -1,14 +1,19 @@ package recordings import ( + "fmt" + "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" "gorm.io/gorm" + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" - rProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" sRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services" + "gitlab.com/mbugroup/lti-api.git/internal/utils" rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -18,11 +23,26 @@ type RecordingModule struct{} func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { recordingRepo := rRecording.NewRecordingRepository(db) - projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) + projectFlockKandangRepo := rProjectFlock.NewProjectFlockKandangRepository(db) + projectFlockPopulationRepo := rProjectFlock.NewProjectFlockPopulationRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + approvalRepo := commonRepo.NewApprovalRepository(db) + approvalService := commonSvc.NewApprovalService(approvalRepo) + if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowRecording, utils.RecordingApprovalSteps); err != nil { + panic(fmt.Sprintf("failed to register recording approval workflow: %v", err)) + } + userRepo := rUser.NewUserRepository(db) - recordingService := sRecording.NewRecordingService(recordingRepo, projectFlockKandangRepo, productWarehouseRepo, validate) + recordingService := sRecording.NewRecordingService( + recordingRepo, + projectFlockKandangRepo, + productWarehouseRepo, + projectFlockPopulationRepo, + approvalRepo, + approvalService, + validate, + ) userService := sUser.NewUserService(userRepo, validate) RecordingRoutes(router, userService, recordingService) diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index 85f79011..832c9ce0 100644 --- a/internal/modules/production/recordings/repositories/recording.repository.go +++ b/internal/modules/production/recordings/repositories/recording.repository.go @@ -1,10 +1,12 @@ package repository import ( + "context" "errors" "math" "sort" "strings" + "time" "gitlab.com/mbugroup/lti-api.git/internal/common/repository" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" @@ -22,11 +24,22 @@ type RecordingRepository interface { CreateStocks(tx *gorm.DB, stocks []entity.RecordingStock) error DeleteStocks(tx *gorm.DB, recordingID uint) error + ListStocks(tx *gorm.DB, recordingID uint) ([]entity.RecordingStock, error) CreateDepletions(tx *gorm.DB, depletions []entity.RecordingDepletion) error DeleteDepletions(tx *gorm.DB, recordingID uint) error + ListDepletions(tx *gorm.DB, recordingID uint) ([]entity.RecordingDepletion, error) - SumRecordingDepletions(tx *gorm.DB, recordingID uint) (int64, error) + CreateEggs(tx *gorm.DB, eggs []entity.RecordingEgg) error + DeleteEggs(tx *gorm.DB, recordingID uint) error + ListEggs(tx *gorm.DB, recordingID uint) ([]entity.RecordingEgg, error) + GetRecordingEggByID(ctx context.Context, id uint, modifier func(*gorm.DB) *gorm.DB) (*entity.RecordingEgg, error) + CreateGradingEggs(tx *gorm.DB, gradings []entity.GradingEgg) error + DeleteGradingEggs(tx *gorm.DB, recordingEggID uint) error + + ExistsOnDate(ctx context.Context, projectFlockKandangId uint, recordTime time.Time) (bool, error) + + SumRecordingDepletions(tx *gorm.DB, recordingID uint) (float64, error) FindPreviousRecording(tx *gorm.DB, projectFlockKandangId uint, currentDay int) (*entity.Recording, error) GetTotalChick(tx *gorm.DB, projectFlockKandangId uint) (int64, error) GetAverageBodyWeight(tx *gorm.DB, recordingID uint) (float64, error) @@ -58,13 +71,18 @@ func (r *RecordingRepositoryImpl) WithRelations(db *gorm.DB) *gorm.DB { Preload("Stocks"). Preload("Stocks.ProductWarehouse"). Preload("Stocks.ProductWarehouse.Product"). - Preload("Stocks.ProductWarehouse.Warehouse") + Preload("Stocks.ProductWarehouse.Warehouse"). + Preload("Eggs"). + Preload("Eggs.ProductWarehouse"). + Preload("Eggs.ProductWarehouse.Product"). + Preload("Eggs.ProductWarehouse.Warehouse"). + Preload("Eggs.GradingEggs") } func (r *RecordingRepositoryImpl) GenerateNextDay(tx *gorm.DB, projectFlockKandangId uint) (int, error) { var days []int if err := tx.Model(&entity.Recording{}). - Where("project_flock_id = ?", projectFlockKandangId). + Where("project_flock_kandangs_id = ?", projectFlockKandangId). Where("day IS NOT NULL"). Pluck("day", &days).Error; err != nil { return 0, err @@ -94,6 +112,14 @@ func (r *RecordingRepositoryImpl) DeleteStocks(tx *gorm.DB, recordingID uint) er return tx.Where("recording_id = ?", recordingID).Delete(&entity.RecordingStock{}).Error } +func (r *RecordingRepositoryImpl) ListStocks(tx *gorm.DB, recordingID uint) ([]entity.RecordingStock, error) { + var items []entity.RecordingStock + if err := tx.Where("recording_id = ?", recordingID).Find(&items).Error; err != nil { + return nil, err + } + return items, nil +} + func (r *RecordingRepositoryImpl) CreateDepletions(tx *gorm.DB, depletions []entity.RecordingDepletion) error { if len(depletions) == 0 { return nil @@ -105,11 +131,100 @@ func (r *RecordingRepositoryImpl) DeleteDepletions(tx *gorm.DB, recordingID uint return tx.Where("recording_id = ?", recordingID).Delete(&entity.RecordingDepletion{}).Error } -func (r *RecordingRepositoryImpl) SumRecordingDepletions(tx *gorm.DB, recordingID uint) (int64, error) { - var result int64 +func (r *RecordingRepositoryImpl) ListDepletions(tx *gorm.DB, recordingID uint) ([]entity.RecordingDepletion, error) { + var items []entity.RecordingDepletion + if err := tx.Where("recording_id = ?", recordingID).Find(&items).Error; err != nil { + return nil, err + } + return items, nil +} + +func (r *RecordingRepositoryImpl) CreateEggs(tx *gorm.DB, eggs []entity.RecordingEgg) error { + if len(eggs) == 0 { + return nil + } + return tx.Create(&eggs).Error +} + +func (r *RecordingRepositoryImpl) DeleteEggs(tx *gorm.DB, recordingID uint) error { + return tx.Where("recording_id = ?", recordingID).Delete(&entity.RecordingEgg{}).Error +} + +func (r *RecordingRepositoryImpl) ListEggs(tx *gorm.DB, recordingID uint) ([]entity.RecordingEgg, error) { + var items []entity.RecordingEgg + if err := tx.Where("recording_id = ?", recordingID).Find(&items).Error; err != nil { + return nil, err + } + return items, nil +} + +func (r *RecordingRepositoryImpl) GetRecordingEggByID( + ctx context.Context, + id uint, + modifier func(*gorm.DB) *gorm.DB, +) (*entity.RecordingEgg, error) { + if id == 0 { + return nil, gorm.ErrRecordNotFound + } + + db := r.DB() + if modifier != nil { + db = modifier(db) + } + + var egg entity.RecordingEgg + query := db.WithContext(ctx). + Preload("Recording"). + Preload("Recording.ProjectFlockKandang"). + Preload("Recording.ProjectFlockKandang.ProjectFlock"). + Preload("ProductWarehouse"). + Preload("GradingEggs"). + Where("id = ?", id) + + if err := query.First(&egg).Error; err != nil { + return nil, err + } + return &egg, nil +} + +func (r *RecordingRepositoryImpl) CreateGradingEggs(tx *gorm.DB, gradings []entity.GradingEgg) error { + if len(gradings) == 0 { + return nil + } + return tx.Create(&gradings).Error +} + +func (r *RecordingRepositoryImpl) DeleteGradingEggs(tx *gorm.DB, recordingEggID uint) error { + return tx.Where("recording_egg_id = ?", recordingEggID).Delete(&entity.GradingEgg{}).Error +} + +func (r *RecordingRepositoryImpl) ExistsOnDate(ctx context.Context, projectFlockKandangId uint, recordTime time.Time) (bool, error) { + if projectFlockKandangId == 0 { + return false, nil + } + + ref := recordTime.In(time.UTC) + startOfDay := time.Date(ref.Year(), ref.Month(), ref.Day(), 0, 0, 0, 0, time.UTC) + endOfDay := startOfDay.Add(24 * time.Hour) + + var count int64 + err := r.DB(). + WithContext(ctx). + Model(&entity.Recording{}). + Where("project_flock_kandangs_id = ?", projectFlockKandangId). + Where("record_datetime >= ? AND record_datetime < ?", startOfDay, endOfDay). + Count(&count).Error + if err != nil { + return false, err + } + return count > 0, nil +} + +func (r *RecordingRepositoryImpl) SumRecordingDepletions(tx *gorm.DB, recordingID uint) (float64, error) { + var result float64 if err := tx.Model(&entity.RecordingDepletion{}). Where("recording_id = ?", recordingID). - Select("COALESCE(SUM(total), 0)"). + Select("COALESCE(SUM(qty), 0)"). Scan(&result).Error; err != nil { return 0, err } @@ -123,7 +238,7 @@ func (r *RecordingRepositoryImpl) FindPreviousRecording(tx *gorm.DB, projectFloc var prev entity.Recording err := tx. - Where("project_flock_id = ? AND day < ?", projectFlockKandangId, currentDay). + Where("project_flock_kandangs_id = ? AND day < ?", projectFlockKandangId, currentDay). Where("day IS NOT NULL"). Order("day DESC"). Limit(1). @@ -159,7 +274,7 @@ func (r *RecordingRepositoryImpl) GetAverageBodyWeight(tx *gorm.DB, recordingID TotalQty float64 } if err := tx.Model(&entity.RecordingBW{}). - Select("COALESCE(SUM(weight * qty), 0) AS total_weight, COALESCE(SUM(qty), 0) AS total_qty"). + Select("COALESCE(SUM(total_weight), 0) AS total_weight, COALESCE(SUM(qty), 0) AS total_qty"). Where("recording_id = ?", recordingID). Scan(&result).Error; err != nil { return 0, err @@ -172,13 +287,13 @@ func (r *RecordingRepositoryImpl) GetAverageBodyWeight(tx *gorm.DB, recordingID func (r *RecordingRepositoryImpl) GetFeedUsageInGrams(tx *gorm.DB, recordingID uint) (float64, error) { var rows []struct { - UsageAmount float64 - UomName string + UsageQty float64 + UomName string } if err := tx. Table("recording_stocks"). - Select("COALESCE(recording_stocks.usage_amount, 0) AS usage_amount, LOWER(uoms.name) AS uom_name"). + Select("COALESCE(recording_stocks.usage_qty, 0) AS usage_qty, LOWER(uoms.name) AS uom_name"). Joins("JOIN product_warehouses ON product_warehouses.id = recording_stocks.product_warehouse_id"). Joins("JOIN products ON products.id = product_warehouses.product_id"). Joins("JOIN uoms ON uoms.id = products.uom_id"). @@ -189,16 +304,16 @@ func (r *RecordingRepositoryImpl) GetFeedUsageInGrams(tx *gorm.DB, recordingID u var total float64 for _, row := range rows { - if row.UsageAmount <= 0 { + if row.UsageQty <= 0 { continue } switch strings.TrimSpace(row.UomName) { case "kilogram", "kg", "kilograms", "kilo": - total += row.UsageAmount * 1000 + total += row.UsageQty * 1000 case "gram", "g", "grams": - total += row.UsageAmount + total += row.UsageQty default: - total += row.UsageAmount + total += row.UsageQty } } return total, nil diff --git a/internal/modules/production/recordings/route.go b/internal/modules/production/recordings/route.go index 3af2b9cf..0d088998 100644 --- a/internal/modules/production/recordings/route.go +++ b/internal/modules/production/recordings/route.go @@ -23,7 +23,9 @@ func RecordingRoutes(v1 fiber.Router, u user.UserService, s recording.RecordingS route.Get("/", ctrl.GetAll) route.Get("/next-day", ctrl.GetNextDay) route.Post("/", ctrl.CreateOne) + route.Post("/gradings", ctrl.SubmitGrading) route.Get("/:id", ctrl.GetOne) route.Patch("/:id", ctrl.UpdateOne) + route.Post("/approvals", ctrl.Approve) route.Delete("/:id", ctrl.DeleteOne) } diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index a5238ff7..ee2670db 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -1,17 +1,23 @@ package service import ( + "context" "errors" "fmt" "math" + "strings" "time" + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" - rProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations" "gitlab.com/mbugroup/lti-api.git/internal/utils" + approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" + recordingutil "gitlab.com/mbugroup/lti-api.git/internal/utils/recording" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -26,28 +32,39 @@ type RecordingService interface { CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.Recording, error) UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Recording, error) DeleteOne(ctx *fiber.Ctx, id uint) error + SubmitGrading(ctx *fiber.Ctx, req *validation.SubmitGrading) (*entity.Recording, error) + Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.Recording, error) } type recordingService struct { - Log *logrus.Logger - Validate *validator.Validate - Repository repository.RecordingRepository - ProjectFlockKandangRepo rProjectFlockKandang.ProjectFlockKandangRepository - ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository + Log *logrus.Logger + Validate *validator.Validate + Repository repository.RecordingRepository + ProjectFlockKandangRepo rProjectFlock.ProjectFlockKandangRepository + ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository + ProjectFlockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository + ApprovalRepo commonRepo.ApprovalRepository + ApprovalSvc commonSvc.ApprovalService } func NewRecordingService( repo repository.RecordingRepository, - projectFlockKandangRepo rProjectFlockKandang.ProjectFlockKandangRepository, + projectFlockKandangRepo rProjectFlock.ProjectFlockKandangRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, + projectFlockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository, + approvalRepo commonRepo.ApprovalRepository, + approvalSvc commonSvc.ApprovalService, validate *validator.Validate, ) RecordingService { return &recordingService{ - Log: utils.Log, - Validate: validate, - Repository: repo, - ProjectFlockKandangRepo: projectFlockKandangRepo, - ProductWarehouseRepo: productWarehouseRepo, + Log: utils.Log, + Validate: validate, + Repository: repo, + ProjectFlockKandangRepo: projectFlockKandangRepo, + ProductWarehouseRepo: productWarehouseRepo, + ProjectFlockPopulationRepo: projectFlockPopulationRepo, + ApprovalRepo: approvalRepo, + ApprovalSvc: approvalSvc, } } @@ -69,7 +86,7 @@ func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti recordings, total, err := s.Repository.GetAll(c.Context(), offset, limit, func(db *gorm.DB) *gorm.DB { db = s.Repository.WithRelations(db) if params.ProjectFlockKandangId != 0 { - db = db.Where("project_flock_id = ?", params.ProjectFlockKandangId) + db = db.Where("project_flock_kandangs_id = ?", params.ProjectFlockKandangId) } return db.Order("record_datetime DESC").Order("created_at DESC") }) @@ -78,6 +95,9 @@ func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti s.Log.Errorf("Failed to get recordings: %+v", err) return nil, 0, err } + if err := s.attachLatestApprovals(c.Context(), recordings); err != nil { + return nil, 0, err + } return recordings, total, nil } @@ -92,6 +112,9 @@ func (s recordingService) GetOne(c *fiber.Ctx, id uint) (*entity.Recording, erro s.Log.Errorf("Failed get recording by id: %+v", err) return nil, err } + if err := s.attachLatestApproval(c.Context(), recording); err != nil { + return nil, err + } return recording, nil } @@ -115,7 +138,10 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent return nil, err } - if _, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), req.ProjectFlockKandangId); err != nil { + ctx := c.Context() + + pfk, err := s.ProjectFlockKandangRepo.GetByID(ctx, req.ProjectFlockKandangId) + if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusBadRequest, "Project flock kandang not found") } @@ -123,11 +149,28 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent return nil, err } - if err := s.ensureProductWarehousesExist(c, req.Stocks, req.Depletions); err != nil { + category := strings.ToUpper(pfk.ProjectFlock.Category) + isLaying := category == strings.ToUpper(string(utils.ProjectFlockCategoryLaying)) + + if err := s.ensureProjectFlockApproved(ctx, pfk.ProjectFlockId); err != nil { + return nil, err + } + if err := s.ensureChickInExists(ctx, pfk.Id); err != nil { return nil, err } - tx := s.Repository.DB().WithContext(c.Context()).Begin() + if !isLaying && len(req.Eggs) > 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Egg details permitted only for laying project flocks") + } + if isLaying && len(req.Eggs) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Egg details are required for laying project flocks") + } + + if err := s.ensureProductWarehousesExist(c, req.Stocks, req.Depletions, req.Eggs); err != nil { + return nil, err + } + + tx := s.Repository.DB().WithContext(ctx).Begin() if tx.Error != nil { s.Log.Errorf("Failed to start recording transaction: %+v", tx.Error) return nil, tx.Error @@ -146,58 +189,79 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent return nil, err } - currentTime := time.Now().UTC() - recordTime := currentTime - recordDate := time.Date( - recordTime.Year(), - recordTime.Month(), - recordTime.Day(), - 0, 0, 0, 0, - recordTime.Location(), - ) - ontimeFlag := computeOntime(recordTime, currentTime) + recordTime := time.Now().UTC() + + existsToday, err := s.Repository.ExistsOnDate(ctx, req.ProjectFlockKandangId, recordTime) + if err != nil { + _ = tx.Rollback() + s.Log.Errorf("Failed to verify existing recording on date: %+v", err) + return nil, err + } + if existsToday { + _ = tx.Rollback() + return nil, fiber.NewError(fiber.StatusBadRequest, "Recording for this project flock today already exists") + } recording := &entity.Recording{ ProjectFlockKandangId: req.ProjectFlockKandangId, RecordDatetime: recordTime, - RecordDate: &recordDate, - Ontime: boolToInt(ontimeFlag), Day: &nextDay, CreatedBy: 1, // TODO: replace with authenticated user } - if err := tx.Create(recording).Error; err != nil { + if err := s.Repository.CreateOne(ctx, recording, func(*gorm.DB) *gorm.DB { return tx }); err != nil { _ = tx.Rollback() if errors.Is(err, gorm.ErrDuplicatedKey) { - dateStr := recordDate.Format("2006-01-02") - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Recording for project flock %d on %s already exists", req.ProjectFlockKandangId, dateStr)) + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Recording for project flock kandang %d already exists", req.ProjectFlockKandangId)) } s.Log.Errorf("Failed to create recording: %+v", err) return nil, err } - if err := s.Repository.CreateBodyWeights(tx, mapBodyWeights(recording.Id, req.BodyWeights)); err != nil { + mappedBodyWeights := recordingutil.MapBodyWeights(recording.Id, req.BodyWeights) + if err := s.Repository.CreateBodyWeights(tx, mappedBodyWeights); err != nil { _ = tx.Rollback() s.Log.Errorf("Failed to persist body weights: %+v", err) return nil, err } - if err := s.Repository.CreateStocks(tx, mapStocks(recording.Id, req.Stocks)); err != nil { + mappedStocks := recordingutil.MapStocks(recording.Id, req.Stocks) + if err := s.Repository.CreateStocks(tx, mappedStocks); err != nil { _ = tx.Rollback() s.Log.Errorf("Failed to persist stocks: %+v", err) return nil, err } - if err := s.Repository.CreateDepletions(tx, mapDepletions(recording.Id, req.Depletions)); err != nil { + mappedDepletions := recordingutil.MapDepletions(recording.Id, req.Depletions) + if err := s.Repository.CreateDepletions(tx, mappedDepletions); err != nil { _ = tx.Rollback() s.Log.Errorf("Failed to persist depletions: %+v", err) return nil, err } + mappedEggs := recordingutil.MapEggs(recording.Id, recording.CreatedBy, req.Eggs) + if err := s.Repository.CreateEggs(tx, mappedEggs); err != nil { + _ = tx.Rollback() + s.Log.Errorf("Failed to persist eggs: %+v", err) + return nil, err + } - if err := s.computeAndUpdateMetrics(tx, recording); err != nil { + if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, mappedDepletions, nil, mappedStocks, nil, mappedEggs)); err != nil { + _ = tx.Rollback() + s.Log.Errorf("Failed to adjust product warehouses: %+v", err) + return nil, err + } + + if err := s.computeAndUpdateMetrics(ctx, tx, recording); err != nil { _ = tx.Rollback() s.Log.Errorf("Failed to compute recording metrics: %+v", err) return nil, err } + action := entity.ApprovalActionCreated + if err := s.createRecordingApproval(ctx, tx, recording.Id, utils.RecordingStepGradingTelur, action, recording.CreatedBy, nil); err != nil { + _ = tx.Rollback() + s.Log.Errorf("Failed to create recording approval for %d: %+v", recording.Id, err) + return nil, err + } + if err := tx.Commit().Error; err != nil { s.Log.Errorf("Failed to commit recording transaction: %+v", err) return nil, err @@ -211,7 +275,9 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin return nil, err } - tx := s.Repository.DB().WithContext(c.Context()).Begin() + ctx := c.Context() + + tx := s.Repository.DB().WithContext(ctx).Begin() if tx.Error != nil { s.Log.Errorf("Failed to start recording transaction: %+v", tx.Error) return nil, tx.Error @@ -223,8 +289,10 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin } }() - var recording entity.Recording - if err := tx.First(&recording, id).Error; err != nil { + recording, err := s.Repository.GetByID(ctx, id, func(db *gorm.DB) *gorm.DB { + return s.Repository.WithRelations(tx) + }) + if err != nil { _ = tx.Rollback() if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Recording not found") @@ -232,66 +300,139 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin s.Log.Errorf("Failed to find recording: %+v", err) return nil, err } + recordingEntity := recording - ontimeValue := boolToInt(computeOntime(recording.RecordDatetime, time.Now().UTC())) - if err := tx.Model(&entity.Recording{}).Where("id = ?", id).Update("ontime", ontimeValue).Error; err != nil { - _ = tx.Rollback() - s.Log.Errorf("Failed to refresh ontime flag: %+v", err) - return nil, err + var category string + if recordingEntity.ProjectFlockKandang != nil && recordingEntity.ProjectFlockKandang.ProjectFlock.Id != 0 { + category = strings.ToUpper(recordingEntity.ProjectFlockKandang.ProjectFlock.Category) + } + isLaying := category == strings.ToUpper(string(utils.ProjectFlockCategoryLaying)) + if req.Eggs != nil { + if !isLaying && len(req.Eggs) > 0 { + _ = tx.Rollback() + return nil, fiber.NewError(fiber.StatusBadRequest, "Egg details permitted only for laying project flocks") + } + if isLaying && len(req.Eggs) == 0 { + _ = tx.Rollback() + return nil, fiber.NewError(fiber.StatusBadRequest, "Egg details are required for laying project flocks") + } } - recording.Ontime = ontimeValue if req.BodyWeights != nil { - if err := s.Repository.DeleteBodyWeights(tx, recording.Id); err != nil { + if err := s.Repository.DeleteBodyWeights(tx, recordingEntity.Id); err != nil { _ = tx.Rollback() s.Log.Errorf("Failed to clear body weights: %+v", err) return nil, err } - if err := s.Repository.CreateBodyWeights(tx, mapBodyWeights(recording.Id, req.BodyWeights)); err != nil { + if err := s.Repository.CreateBodyWeights(tx, recordingutil.MapBodyWeights(recordingEntity.Id, req.BodyWeights)); err != nil { _ = tx.Rollback() s.Log.Errorf("Failed to update body weights: %+v", err) return nil, err } } if req.Stocks != nil { - if err := s.ensureProductWarehousesExist(c, req.Stocks, nil); err != nil { + if err := s.ensureProductWarehousesExist(c, req.Stocks, nil, nil); err != nil { _ = tx.Rollback() return nil, err } - if err := s.Repository.DeleteStocks(tx, recording.Id); err != nil { + existingStocks, err := s.Repository.ListStocks(tx, recordingEntity.Id) + if err != nil { + _ = tx.Rollback() + s.Log.Errorf("Failed to list existing stocks: %+v", err) + return nil, err + } + + if err := s.Repository.DeleteStocks(tx, recordingEntity.Id); err != nil { _ = tx.Rollback() s.Log.Errorf("Failed to clear stocks: %+v", err) return nil, err } - if err := s.Repository.CreateStocks(tx, mapStocks(recording.Id, req.Stocks)); err != nil { + mappedStocks := recordingutil.MapStocks(recordingEntity.Id, req.Stocks) + if err := s.Repository.CreateStocks(tx, mappedStocks); err != nil { _ = tx.Rollback() s.Log.Errorf("Failed to update stocks: %+v", err) return nil, err } + if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, nil, existingStocks, mappedStocks, nil, nil)); err != nil { + _ = tx.Rollback() + s.Log.Errorf("Failed to adjust product warehouses for stocks: %+v", err) + return nil, err + } } - if req.Depletions != nil { - if err := s.ensureProductWarehousesExist(c, nil, req.Depletions); err != nil { + if req.Eggs != nil && req.Depletions == nil { + if err := s.ensureProductWarehousesExist(c, nil, nil, req.Eggs); err != nil { _ = tx.Rollback() return nil, err } - if err := s.Repository.DeleteDepletions(tx, recording.Id); err != nil { + } + var existingDepletions []entity.RecordingDepletion + if req.Depletions != nil { + if err := s.ensureProductWarehousesExist(c, nil, req.Depletions, req.Eggs); err != nil { + _ = tx.Rollback() + return nil, err + } + var err error + existingDepletions, err = s.Repository.ListDepletions(tx, recordingEntity.Id) + if err != nil { + _ = tx.Rollback() + s.Log.Errorf("Failed to list existing depletions: %+v", err) + return nil, err + } + if err := s.Repository.DeleteDepletions(tx, recordingEntity.Id); err != nil { _ = tx.Rollback() s.Log.Errorf("Failed to clear depletions: %+v", err) return nil, err } - if err := s.Repository.CreateDepletions(tx, mapDepletions(recording.Id, req.Depletions)); err != nil { + mappedDepletions := recordingutil.MapDepletions(recordingEntity.Id, req.Depletions) + if err := s.Repository.CreateDepletions(tx, mappedDepletions); err != nil { _ = tx.Rollback() s.Log.Errorf("Failed to update depletions: %+v", err) return nil, err } + if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(existingDepletions, mappedDepletions, nil, nil, nil, nil)); err != nil { + _ = tx.Rollback() + s.Log.Errorf("Failed to adjust product warehouses for depletions: %+v", err) + return nil, err + } + } + if req.Eggs != nil { + existingEggs, err := s.Repository.ListEggs(tx, recordingEntity.Id) + if err != nil { + _ = tx.Rollback() + s.Log.Errorf("Failed to list existing eggs: %+v", err) + return nil, err + } + if err := s.Repository.DeleteEggs(tx, recordingEntity.Id); err != nil { + _ = tx.Rollback() + s.Log.Errorf("Failed to clear eggs: %+v", err) + return nil, err + } + mappedEggs := recordingutil.MapEggs(recordingEntity.Id, recordingEntity.CreatedBy, req.Eggs) + if err := s.Repository.CreateEggs(tx, mappedEggs); err != nil { + _ = tx.Rollback() + s.Log.Errorf("Failed to update eggs: %+v", err) + return nil, err + } + if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, nil, nil, nil, existingEggs, mappedEggs)); err != nil { + _ = tx.Rollback() + s.Log.Errorf("Failed to adjust product warehouses for eggs: %+v", err) + return nil, err + } } - if err := s.computeAndUpdateMetrics(tx, &recording); err != nil { + if err := s.computeAndUpdateMetrics(ctx, tx, recordingEntity); err != nil { _ = tx.Rollback() s.Log.Errorf("Failed to recompute recording metrics: %+v", err) return nil, err } + action := entity.ApprovalActionUpdated + if err := s.createRecordingApproval(ctx, tx, recordingEntity.Id, utils.RecordingStepPengajuan, action, recordingEntity.CreatedBy, nil); err != nil { + _ = tx.Rollback() + s.Log.Errorf("Failed to create approval after recording update %d: %+v", recordingEntity.Id, err) + return nil, err + } + if err := tx.Commit().Error; err != nil { s.Log.Errorf("Failed to commit recording transaction: %+v", err) return nil, err @@ -300,20 +441,242 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin return s.GetOne(c, id) } +func (s *recordingService) SubmitGrading(c *fiber.Ctx, req *validation.SubmitGrading) (*entity.Recording, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + ctx := c.Context() + tx := s.Repository.DB().WithContext(ctx).Begin() + if tx.Error != nil { + s.Log.Errorf("Failed to start grading transaction: %+v", tx.Error) + return nil, tx.Error + } + defer func() { + if r := recover(); r != nil { + _ = tx.Rollback() + panic(r) + } + }() + + recordingEgg, err := s.Repository.GetRecordingEggByID(ctx, req.RecordingEggId, func(db *gorm.DB) *gorm.DB { + return tx + }) + if errors.Is(err, gorm.ErrRecordNotFound) { + _ = tx.Rollback() + return nil, fiber.NewError(fiber.StatusNotFound, "Recording egg not found") + } + if err != nil { + _ = tx.Rollback() + s.Log.Errorf("Failed to get recording egg %d: %+v", req.RecordingEggId, err) + return nil, err + } + + var category string + if recordingEgg.Recording.ProjectFlockKandang != nil && recordingEgg.Recording.ProjectFlockKandang.ProjectFlock.Id != 0 { + category = strings.ToUpper(recordingEgg.Recording.ProjectFlockKandang.ProjectFlock.Category) + } + if category != strings.ToUpper(string(utils.ProjectFlockCategoryLaying)) { + _ = tx.Rollback() + return nil, fiber.NewError(fiber.StatusBadRequest, "Grading eggs hanya diperbolehkan pada project flock dengan kategori laying") + } + + totalGradingQty := 0.0 + for _, grading := range req.EggsGrading { + totalGradingQty += grading.Qty + } + + availableRecorded := float64(recordingEgg.Qty) + if totalGradingQty > availableRecorded { + _ = tx.Rollback() + return nil, fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Total grading (%.2f) melebihi jumlah telur tercatat (%.2f)", totalGradingQty, availableRecorded), + ) + } + + if recordingEgg.ProductWarehouse.Id != 0 { + availableWarehouse := recordingEgg.ProductWarehouse.Quantity + if totalGradingQty > availableWarehouse { + _ = tx.Rollback() + return nil, fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Total grading (%.2f) melebihi stok telur baik (%.2f)", totalGradingQty, availableWarehouse), + ) + } + } + + if err := s.Repository.DeleteGradingEggs(tx, recordingEgg.Id); err != nil { + _ = tx.Rollback() + s.Log.Errorf("Failed to clear grading eggs for recording egg %d: %+v", recordingEgg.Id, err) + return nil, err + } + + gradings := make([]entity.GradingEgg, 0, len(req.EggsGrading)) + createdBy := recordingEgg.CreatedBy + if createdBy == 0 { + createdBy = recordingEgg.Recording.CreatedBy + } + for _, item := range req.EggsGrading { + gradings = append(gradings, entity.GradingEgg{ + RecordingEggId: recordingEgg.Id, + Grade: strings.TrimSpace(item.Grade), + Qty: item.Qty, + CreatedBy: createdBy, + }) + } + + if len(gradings) > 0 { + if err := s.Repository.CreateGradingEggs(tx, gradings); err != nil { + _ = tx.Rollback() + s.Log.Errorf("Failed to persist grading eggs for recording egg %d: %+v", recordingEgg.Id, err) + return nil, err + } + } + + action := entity.ApprovalActionUpdated + if err := s.createRecordingApproval(ctx, tx, recordingEgg.RecordingId, utils.RecordingStepPengajuan, action, createdBy, nil); err != nil { + _ = tx.Rollback() + s.Log.Errorf("Failed to create approval after grading for recording %d: %+v", recordingEgg.RecordingId, err) + return nil, err + } + + if err := tx.Commit().Error; err != nil { + s.Log.Errorf("Failed to commit grading transaction: %+v", err) + return nil, err + } + + return s.GetOne(c, recordingEgg.RecordingId) +} + +func (s recordingService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entity.Recording, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + actionValue := strings.ToUpper(strings.TrimSpace(req.Action)) + var action entity.ApprovalAction + switch actionValue { + case string(entity.ApprovalActionApproved): + action = entity.ApprovalActionApproved + case string(entity.ApprovalActionRejected): + action = entity.ApprovalActionRejected + default: + return nil, fiber.NewError(fiber.StatusBadRequest, "action must be APPROVED or REJECTED") + } + + ids := uniqueUintSlice(req.ApprovableIds) + if len(ids) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "approvable_ids must contain at least one id") + } + + step := utils.RecordingStepPengajuan + if action == entity.ApprovalActionApproved { + step = utils.RecordingStepDisetujui + } + + ctx := c.Context() + actorID := uint(1) // TODO: replace with authenticated user once auth is integrated + + transactionErr := s.Repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + repoTx := s.Repository.WithTx(tx) + approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx)) + + for _, id := range ids { + if _, err := repoTx.GetByID(ctx, id, nil); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Recording %d not found", id)) + } + return err + } + + if _, err := approvalSvc.CreateApproval( + ctx, + utils.ApprovalWorkflowRecording, + id, + step, + &action, + actorID, + req.Notes, + ); err != nil { + return err + } + } + + return nil + }) + + if transactionErr != nil { + if fiberErr, ok := transactionErr.(*fiber.Error); ok { + return nil, fiberErr + } + s.Log.Errorf("Failed to record approvals for recordings %+v: %+v", ids, transactionErr) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to submit recording approval") + } + + updated := make([]entity.Recording, 0, len(ids)) + for _, id := range ids { + recording, err := s.GetOne(c, id) + if err != nil { + return nil, err + } + updated = append(updated, *recording) + } + + return updated, nil +} + func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error { - if err := s.Repository.DeleteOne(c.Context(), id); err != nil { + ctx := c.Context() + + tx := s.Repository.DB().WithContext(ctx).Begin() + if tx.Error != nil { + return tx.Error + } + + oldDepletions, err := s.Repository.ListDepletions(tx, id) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + _ = tx.Rollback() + s.Log.Errorf("Failed to list depletions before delete: %+v", err) + return err + } + oldEggs, err := s.Repository.ListEggs(tx, id) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + _ = tx.Rollback() + s.Log.Errorf("Failed to list eggs before delete: %+v", err) + return err + } + oldStocks, err := s.Repository.ListStocks(tx, id) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + _ = tx.Rollback() + s.Log.Errorf("Failed to list stocks before delete: %+v", err) + return err + } + + if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(oldDepletions, nil, oldStocks, nil, oldEggs, nil)); err != nil { + _ = tx.Rollback() + return err + } + + if err := s.Repository.WithTx(tx).DeleteOne(ctx, id); err != nil { + _ = tx.Rollback() if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusNotFound, "Recording not found") } s.Log.Errorf("Failed to delete recording: %+v", err) return err } + + if err := tx.Commit().Error; err != nil { + s.Log.Errorf("Failed to commit delete recording transaction: %+v", err) + return err + } return nil } // === Persistence Helpers === -func (s *recordingService) ensureProductWarehousesExist(c *fiber.Ctx, stocks []validation.Stock, depletions []validation.Depletion) error { +func (s *recordingService) ensureProductWarehousesExist(c *fiber.Ctx, stocks []validation.Stock, depletions []validation.Depletion, eggs []validation.Egg) error { idSet := make(map[uint]struct{}) for _, stock := range stocks { @@ -326,6 +689,11 @@ func (s *recordingService) ensureProductWarehousesExist(c *fiber.Ctx, stocks []v idSet[dep.ProductWarehouseId] = struct{}{} } } + for _, egg := range eggs { + if egg.ProductWarehouseId != 0 { + idSet[egg.ProductWarehouseId] = struct{}{} + } + } if len(idSet) == 0 { return nil @@ -345,87 +713,61 @@ func (s *recordingService) ensureProductWarehousesExist(c *fiber.Ctx, stocks []v return nil } -func computeOntime(recordDatetime, reference time.Time) bool { - return !recordDatetime.Before(reference) -} - -func boolToInt(v bool) int { - if v { - return 1 +func buildWarehouseDeltas( + oldDepletions, newDepletions []entity.RecordingDepletion, + oldStocks, newStocks []entity.RecordingStock, + oldEggs, newEggs []entity.RecordingEgg, +) map[uint]float64 { + deltas := make(map[uint]float64) + for _, item := range oldDepletions { + accumulateWarehouseDelta(deltas, item.ProductWarehouseId, -item.Qty) } - return 0 + for _, item := range newDepletions { + accumulateWarehouseDelta(deltas, item.ProductWarehouseId, item.Qty) + } + for _, item := range oldStocks { + accumulateWarehouseDelta(deltas, item.ProductWarehouseId, usageQtyValue(item.UsageQty)) + } + for _, item := range newStocks { + accumulateWarehouseDelta(deltas, item.ProductWarehouseId, -usageQtyValue(item.UsageQty)) + } + for _, item := range oldEggs { + accumulateWarehouseDelta(deltas, item.ProductWarehouseId, -float64(item.Qty)) + } + for _, item := range newEggs { + accumulateWarehouseDelta(deltas, item.ProductWarehouseId, float64(item.Qty)) + } + return deltas } -func mapBodyWeights(recordingID uint, payload []validation.BodyWeight) []entity.RecordingBW { - if len(payload) == 0 { +func usageQtyValue(val *float64) float64 { + if val == nil { + return 0 + } + return *val +} + +func accumulateWarehouseDelta(deltas map[uint]float64, id uint, value float64) { + if id == 0 || value == 0 { + return + } + deltas[id] += value +} + +func (s *recordingService) adjustProductWarehouseQuantities(ctx context.Context, tx *gorm.DB, deltas map[uint]float64) error { + if len(deltas) == 0 { return nil } - - items := make([]entity.RecordingBW, len(payload)) - for i, bw := range payload { - items[i] = entity.RecordingBW{ - RecordingId: recordingID, - Weight: bw.Weight, - Qty: bw.Qty, - Notes: bw.Notes, - } - } - return items + return s.ProductWarehouseRepo.AdjustQuantities(ctx, deltas, func(*gorm.DB) *gorm.DB { return tx }) } -func mapStocks(recordingID uint, payload []validation.Stock) []entity.RecordingStock { - if len(payload) == 0 { - return nil - } - - items := make([]entity.RecordingStock, len(payload)) - for i, stock := range payload { - items[i] = entity.RecordingStock{ - RecordingId: recordingID, - ProductWarehouseId: stock.ProductWarehouseId, - Notes: stock.Notes, - } - if stock.Increase != nil { - val := *stock.Increase - items[i].Increase = &val - } - if stock.Decrease != nil { - val := *stock.Decrease - items[i].Decrease = &val - } - if stock.UsageAmount != nil { - val := *stock.UsageAmount - items[i].UsageAmount = &val - } - } - return items -} - -func mapDepletions(recordingID uint, payload []validation.Depletion) []entity.RecordingDepletion { - if len(payload) == 0 { - return nil - } - - items := make([]entity.RecordingDepletion, len(payload)) - for i, dep := range payload { - total := dep.Total - items[i] = entity.RecordingDepletion{ - RecordingId: recordingID, - ProductWarehouseId: dep.ProductWarehouseId, - Total: total, - Notes: dep.Notes, - } - } - return items -} - -func (s *recordingService) computeAndUpdateMetrics(tx *gorm.DB, recording *entity.Recording) error { +func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm.DB, recording *entity.Recording) error { day := 0 if recording.Day != nil { day = *recording.Day } - totalDepletion, err := s.Repository.SumRecordingDepletions(tx, recording.Id) + totalDepletionQty, err := s.Repository.SumRecordingDepletions(tx, recording.Id) if err != nil { return fmt.Errorf("sumRecordingDepletions: %w", err) } @@ -435,12 +777,12 @@ func (s *recordingService) computeAndUpdateMetrics(tx *gorm.DB, recording *entit return fmt.Errorf("getPreviousRecording: %w", err) } - var prevCumDepletion int64 + var prevCumDepletionQty float64 var prevCumIntake float64 var prevAvgWeight float64 if prevRecording != nil { - if prevRecording.CumDepletion != nil { - prevCumDepletion = int64(*prevRecording.CumDepletion) + if prevRecording.TotalDepletionQty != nil { + prevCumDepletionQty = *prevRecording.TotalDepletionQty } if prevRecording.CumIntake != nil { prevCumIntake = float64(*prevRecording.CumIntake) @@ -471,48 +813,38 @@ func (s *recordingService) computeAndUpdateMetrics(tx *gorm.DB, recording *entit return fmt.Errorf("getFcrID: %w", err) } - currentAvgGrams := toGrams(currentAvgWeight) - currentAvgKg := gramsToKg(currentAvgGrams) - prevAvgGrams := toGrams(prevAvgWeight) + currentAvgGrams := recordingutil.ToGrams(currentAvgWeight) + currentAvgKg := recordingutil.GramsToKg(currentAvgGrams) + prevAvgGrams := recordingutil.ToGrams(prevAvgWeight) - totalDepletionInt := int(totalDepletion) - cumDepletion := prevCumDepletion + totalDepletion - cumDepletionInt := int(cumDepletion) + currentDepletion := float64(totalDepletionQty) + cumDepletionQty := prevCumDepletionQty + currentDepletion updates := map[string]any{ - "total_depletion": totalDepletionInt, - "cum_depletion": cumDepletionInt, + "total_depletion_qty": cumDepletionQty, } - - recording.TotalDepletion = &totalDepletionInt - recording.CumDepletion = &cumDepletionInt + recording.TotalDepletionQty = &cumDepletionQty if totalChick > 0 { - remainingChick := totalChick - cumDepletion + totalChickFloat := float64(totalChick) + remainingChick := totalChickFloat - cumDepletionQty if remainingChick < 0 { remainingChick = 0 } - updates["total_chick"] = remainingChick - recording.TotalChick = &remainingChick + updates["total_chick_qty"] = remainingChick + recording.TotalChickQty = &remainingChick - cumRate := (float64(cumDepletion) / float64(totalChick)) * 100 + cumRate := 0.0 + if totalChickFloat > 0 { + cumRate = (cumDepletionQty / totalChickFloat) * 100 + } updates["cum_depletion_rate"] = cumRate recording.CumDepletionRate = &cumRate - - remainingAfter := totalChick - cumDepletion - if remainingAfter <= 0 { - remainingAfter = 1 - } - dailyRate := (float64(totalDepletion) / float64(remainingAfter)) * 100 - updates["daily_depletion_rate"] = dailyRate - recording.DailyDepletionRate = &dailyRate } else { - updates["total_chick"] = gorm.Expr("NULL") + updates["total_chick_qty"] = gorm.Expr("NULL") updates["cum_depletion_rate"] = gorm.Expr("NULL") - updates["daily_depletion_rate"] = gorm.Expr("NULL") - recording.TotalChick = nil + recording.TotalChickQty = nil recording.CumDepletionRate = nil - recording.DailyDepletionRate = nil } if currentAvgGrams > 0 && prevAvgGrams > 0 { @@ -545,17 +877,16 @@ func (s *recordingService) computeAndUpdateMetrics(tx *gorm.DB, recording *entit if prevRecording == nil || prevRecording.CumIntake == nil { cumIntakeValue = usageInGrams / float64(totalChick) } else { - remaining := float64(totalChick - cumDepletion) + remaining := float64(totalChick) - cumDepletionQty if remaining <= 0 { remaining = float64(totalChick) } cumIntakeValue = prevCumIntake + (usageInGrams / remaining) } - cumIntakeRounded := int64(math.Round(cumIntakeValue)) + cumIntakeRounded := int(math.Round(cumIntakeValue)) updates["cum_intake"] = cumIntakeRounded recording.CumIntake = &cumIntakeRounded } else if prevRecording != nil && prevRecording.CumIntake != nil { - // Keep previous cumulative intake if no additional feed usage provided updates["cum_intake"] = *prevRecording.CumIntake recording.CumIntake = prevRecording.CumIntake } else { @@ -573,30 +904,172 @@ func (s *recordingService) computeAndUpdateMetrics(tx *gorm.DB, recording *entit recording.FcrValue = nil } - if err := tx.Model(&entity.Recording{}). - Where("id = ?", recording.Id). - Updates(updates).Error; err != nil { + if err := s.Repository.WithTx(tx).PatchOne(ctx, recording.Id, updates, nil); err != nil { return err } return nil } -// === Unit Helpers === +func (s *recordingService) createRecordingApproval( + ctx context.Context, + db *gorm.DB, + recordingID uint, + step approvalutils.ApprovalStep, + action entity.ApprovalAction, + actorID uint, + notes *string, +) error { + if recordingID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Recording tidak valid untuk approval") + } + if actorID == 0 { + actorID = 1 + } -func toGrams(weight float64) float64 { - if weight <= 0 { - return 0 + var svc commonSvc.ApprovalService + if db != nil { + svc = commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(db)) + } else if s.ApprovalSvc != nil { + svc = s.ApprovalSvc + } else { + svc = commonSvc.NewApprovalService(s.ApprovalRepo) } - if weight > 10 { - return weight - } - return weight * 1000 + + _, err := svc.CreateApproval(ctx, utils.ApprovalWorkflowRecording, recordingID, step, &action, actorID, notes) + return err } -func gramsToKg(value float64) float64 { - if value <= 0 { - return 0 +func (s *recordingService) attachLatestApprovals(ctx context.Context, items []entity.Recording) error { + if len(items) == 0 || s.ApprovalSvc == nil { + return nil } - return value / 1000 + + ids := make([]uint, 0, len(items)) + visited := make(map[uint]struct{}, len(items)) + for _, item := range items { + if item.Id == 0 { + continue + } + if _, ok := visited[item.Id]; ok { + continue + } + visited[item.Id] = struct{}{} + ids = append(ids, item.Id) + } + + if len(ids) == 0 { + return nil + } + + latestMap, err := s.ApprovalSvc.LatestByTargets(ctx, utils.ApprovalWorkflowRecording, ids, func(db *gorm.DB) *gorm.DB { + return db.Preload("ActionUser") + }) + if err != nil { + s.Log.Warnf("Unable to load latest approvals for recordings: %+v", err) + return nil + } + + if len(latestMap) == 0 { + return nil + } + + for i := range items { + if items[i].Id == 0 { + continue + } + if approval, ok := latestMap[items[i].Id]; ok { + items[i].LatestApproval = approval + } + } + + return nil +} + +func (s *recordingService) attachLatestApproval(ctx context.Context, item *entity.Recording) error { + if item == nil || item.Id == 0 || s.ApprovalSvc == nil { + return nil + } + + approvals, err := s.ApprovalSvc.ListByTarget(ctx, utils.ApprovalWorkflowRecording, item.Id, func(db *gorm.DB) *gorm.DB { + return db.Preload("ActionUser") + }) + if err != nil { + s.Log.Warnf("Unable to load approvals for recording %d: %+v", item.Id, err) + return nil + } + + if len(approvals) == 0 { + item.LatestApproval = nil + return nil + } + + latest := approvals[len(approvals)-1] + item.LatestApproval = &latest + return nil +} + +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 v == 0 { + continue + } + if _, ok := seen[v]; ok { + continue + } + seen[v] = struct{}{} + result = append(result, v) + } + return result +} + +func (s *recordingService) ensureProjectFlockApproved(ctx context.Context, projectFlockID uint) error { + if projectFlockID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Project flock kandang tidak valid") + } + + var ( + latest *entity.Approval + err error + ) + if s.ApprovalSvc != nil { + latest, err = s.ApprovalSvc.LatestByTarget(ctx, utils.ApprovalWorkflowProjectFlock, projectFlockID, nil) + } else { + latest, err = s.ApprovalRepo.LatestByTarget(ctx, utils.ApprovalWorkflowProjectFlock.String(), projectFlockID, nil) + } + if err != nil { + s.Log.Errorf("Failed to check project flock %d approval status: %+v", projectFlockID, err) + return fiber.NewError(fiber.StatusInternalServerError, "Gagal memeriksa status project flock") + } + + if latest == nil { + return fiber.NewError(fiber.StatusBadRequest, "Project flock masih dalam status pengajuan sehingga belum dapat membuat recording") + } + if latest.StepNumber != uint16(utils.ProjectFlockStepAktif) || latest.Action == nil || *latest.Action != entity.ApprovalActionApproved { + return fiber.NewError(fiber.StatusBadRequest, "Project flock masih dalam status pengajuan sehingga belum dapat membuat recording") + } + + return nil +} + +func (s *recordingService) ensureChickInExists(ctx context.Context, projectFlockKandangID uint) error { + if projectFlockKandangID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Project flock kandang tidak valid") + } + + _, err := s.ProjectFlockPopulationRepo.GetByProjectFlockKandangID(ctx, projectFlockKandangID) + if err == nil { + return nil + } + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusBadRequest, "Project flock belum melakukan chick in sehingga belum dapat membuat recording") + } + s.Log.Errorf("Failed to check project flock population for project_flock_kandang_id=%d: %+v", projectFlockKandangID, err) + return fiber.NewError(fiber.StatusInternalServerError, "Gagal memeriksa data chick in") } diff --git a/internal/modules/production/recordings/validations/recording.validation.go b/internal/modules/production/recordings/validations/recording.validation.go index d143de4b..d760c0ba 100644 --- a/internal/modules/production/recordings/validations/recording.validation.go +++ b/internal/modules/production/recordings/validations/recording.validation.go @@ -2,23 +2,25 @@ package validation type ( BodyWeight struct { - Weight float64 `json:"weight" validate:"required"` - Qty int `json:"qty" validate:"required,number,min=1"` - Notes *string `json:"notes,omitempty" validate:"omitempty"` + AvgWeight float64 `json:"avg_weight" validate:"required"` + Qty float64 `json:"qty" validate:"required,gt=0"` + TotalWeight *float64 `json:"total_weight,omitempty" validate:"omitempty,gt=0"` } Stock struct { ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,number,min=1"` - Increase *float64 `json:"increase,omitempty" validate:"omitempty"` - Decrease *float64 `json:"decrease,omitempty" validate:"omitempty"` - UsageAmount *int64 `json:"usage_amount,omitempty" validate:"omitempty,min=0"` - Notes *string `json:"notes,omitempty" validate:"omitempty"` + UsageAmount *float64 `json:"usage_amount,omitempty" validate:"omitempty,gte=0"` + PendingQty *float64 `json:"pending_qty,omitempty" validate:"omitempty,gte=0"` } Depletion struct { ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,number,min=1"` - Total int64 `json:"total" validate:"required,number,min=0"` - Notes *string `json:"notes,omitempty" validate:"omitempty"` + Qty float64 `json:"qty" validate:"required,gte=0"` + } + + Egg struct { + ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,number,min=1"` + Qty int `json:"qty" validate:"required,number,min=0"` } ) @@ -27,12 +29,14 @@ type Create struct { BodyWeights []BodyWeight `json:"body_weights,omitempty" validate:"omitempty,dive"` Stocks []Stock `json:"stocks,omitempty" validate:"omitempty,dive"` Depletions []Depletion `json:"depletions,omitempty" validate:"omitempty,dive"` + Eggs []Egg `json:"eggs,omitempty" validate:"omitempty,dive"` } type Update struct { BodyWeights []BodyWeight `json:"body_weights,omitempty" validate:"omitempty,dive"` Stocks []Stock `json:"stocks,omitempty" validate:"omitempty,dive"` Depletions []Depletion `json:"depletions,omitempty" validate:"omitempty,dive"` + Eggs []Egg `json:"eggs,omitempty" validate:"omitempty,dive"` } type Query struct { @@ -40,3 +44,19 @@ type Query struct { Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` ProjectFlockKandangId uint `query:"project_flock_kandang_id" validate:"omitempty,number,min=1"` } + +type EggGrading struct { + Grade string `json:"grade" validate:"required"` + Qty float64 `json:"qty" validate:"required,gte=0"` +} + +type SubmitGrading struct { + RecordingEggId uint `json:"recording_egg_id" validate:"required,number,min=1"` + EggsGrading []EggGrading `json:"eggs_grading" validate:"required,dive"` +} + +type Approve struct { + Action string `json:"action" validate:"required_strict"` + ApprovableIds []uint `json:"approvable_ids" validate:"required_strict,min=1,dive,gt=0"` + Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` +} diff --git a/internal/modules/shared/repositories/stock-logs.repository.go b/internal/modules/shared/repositories/stock-logs.repository.go index c93db2b1..77ed78ce 100644 --- a/internal/modules/shared/repositories/stock-logs.repository.go +++ b/internal/modules/shared/repositories/stock-logs.repository.go @@ -13,6 +13,7 @@ type StockLogRepository interface { GetByFlaggable(ctx context.Context, logType string, logId uint) ([]*entity.StockLog, error) GetByProductWarehouse(ctx context.Context, productWarehouseId uint, limit int) ([]*entity.StockLog, error) GetByTransactionType(ctx context.Context, transactionType string, limit int) ([]*entity.StockLog, error) + ApplyProductWarehouseFilters(db *gorm.DB, productID, warehouseID uint) *gorm.DB } type StockLogRepositoryImpl struct { @@ -86,3 +87,20 @@ func (r *StockLogRepositoryImpl) GetByTransactionType(ctx context.Context, trans return stockLogs, nil } + +func (r *StockLogRepositoryImpl) ApplyProductWarehouseFilters(db *gorm.DB, productID, warehouseID uint) *gorm.DB { + if productID == 0 && warehouseID == 0 { + return db + } + + db = db.Joins("JOIN product_warehouses ON product_warehouses.id = stock_logs.product_warehouse_id") + + if productID > 0 { + db = db.Where("product_warehouses.product_id = ?", productID) + } + if warehouseID > 0 { + db = db.Where("product_warehouses.warehouse_id = ?", warehouseID) + } + + return db +} diff --git a/internal/utils/constant.go b/internal/utils/constant.go index bdbc53b6..0a8862f9 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -140,6 +140,23 @@ var ProjectFlockApprovalSteps = map[approvalutils.ApprovalStep]string{ ProjectFlockStepAktif: "Aktif", } +// ------------------------------------------------------------------- +// Recording Approval +// ------------------------------------------------------------------- + +const ( + ApprovalWorkflowRecording approvalutils.ApprovalWorkflowKey = approvalutils.ApprovalWorkflowKey("RECORDINGS") + RecordingStepGradingTelur approvalutils.ApprovalStep = 1 + RecordingStepPengajuan approvalutils.ApprovalStep = 2 + RecordingStepDisetujui approvalutils.ApprovalStep = 3 +) + +var RecordingApprovalSteps = map[approvalutils.ApprovalStep]string{ + RecordingStepGradingTelur: "Grading-Telur", + RecordingStepPengajuan: "Pengajuan", + RecordingStepDisetujui: "Disetujui", +} + // ------------------------------------------------------------------- // Validators // ------------------------------------------------------------------- @@ -268,6 +285,8 @@ func IsValidSupplierCategory(v string) bool { // example use +// Recording helper + /** if !utils.IsValidFlagType(req.FlagName) { return fiber.NewError(fiber.StatusBadRequest, "Invalid flag type") diff --git a/internal/utils/recording/util.recording.go b/internal/utils/recording/util.recording.go new file mode 100644 index 00000000..e5467aaf --- /dev/null +++ b/internal/utils/recording/util.recording.go @@ -0,0 +1,96 @@ +package recording + +import ( + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations" +) + +func MapBodyWeights(recordingID uint, items []validation.BodyWeight) []entity.RecordingBW { + if len(items) == 0 { + return nil + } + + result := make([]entity.RecordingBW, 0, len(items)) + for _, item := range items { + totalWeight := item.TotalWeight + if totalWeight == nil { + calculated := item.AvgWeight * item.Qty + totalWeight = &calculated + } + + result = append(result, entity.RecordingBW{ + RecordingId: recordingID, + AvgWeight: item.AvgWeight, + Qty: item.Qty, + TotalWeight: *totalWeight, + }) + } + return result +} + +func MapStocks(recordingID uint, items []validation.Stock) []entity.RecordingStock { + if len(items) == 0 { + return nil + } + + result := make([]entity.RecordingStock, 0, len(items)) + for _, item := range items { + result = append(result, entity.RecordingStock{ + RecordingId: recordingID, + ProductWarehouseId: item.ProductWarehouseId, + UsageQty: item.UsageAmount, + PendingQty: item.PendingQty, + }) + } + return result +} + +func MapDepletions(recordingID uint, items []validation.Depletion) []entity.RecordingDepletion { + if len(items) == 0 { + return nil + } + + result := make([]entity.RecordingDepletion, 0, len(items)) + for _, item := range items { + result = append(result, entity.RecordingDepletion{ + RecordingId: recordingID, + ProductWarehouseId: item.ProductWarehouseId, + Qty: item.Qty, + }) + } + return result +} + +func MapEggs(recordingID uint, createdBy uint, items []validation.Egg) []entity.RecordingEgg { + if len(items) == 0 { + return nil + } + + result := make([]entity.RecordingEgg, 0, len(items)) + for _, item := range items { + result = append(result, entity.RecordingEgg{ + RecordingId: recordingID, + ProductWarehouseId: item.ProductWarehouseId, + Qty: item.Qty, + CreatedBy: createdBy, + }) + } + return result +} + +func ToGrams(weight float64) float64 { + if weight <= 0 { + return 0 + } + if weight < 10 { + return weight * 1000 + } + return weight +} + +func GramsToKg(grams float64) float64 { + if grams <= 0 { + return 0 + } + return grams / 1000 +} diff --git a/test/integration/master_data/kandang_test.go b/test/integration/master_data/kandang_test.go index 6f7c5ce7..b7b82b21 100644 --- a/test/integration/master_data/kandang_test.go +++ b/test/integration/master_data/kandang_test.go @@ -2,6 +2,7 @@ package test import ( "encoding/json" + "fmt" "net/http" "testing" @@ -58,7 +59,7 @@ func TestKandangIntegration(t *testing.T) { flocID := createFlock(t, app, "Floc Test") projectFloc := entities.ProjectFlock{ - FlockId: flocID, + FlockName: fmt.Sprintf("Project Flock %d", flocID), AreaId: areaID, Category: string(utils.ProjectFlockCategoryGrowing), FcrId: fcrID, diff --git a/test/integration/master_data/project_flock_test.go b/test/integration/master_data/project_flock_test.go index 60bb2d90..a7f8f3f8 100644 --- a/test/integration/master_data/project_flock_test.go +++ b/test/integration/master_data/project_flock_test.go @@ -1,417 +1,417 @@ package test -import ( - "encoding/json" - "fmt" - "net/http" - "net/url" - "testing" +// import ( +// "encoding/json" +// "fmt" +// "net/http" +// "net/url" +// "testing" - "github.com/gofiber/fiber/v2" +// "github.com/gofiber/fiber/v2" - "gitlab.com/mbugroup/lti-api.git/internal/entities" - "gitlab.com/mbugroup/lti-api.git/internal/utils" -) +// "gitlab.com/mbugroup/lti-api.git/internal/entities" +// "gitlab.com/mbugroup/lti-api.git/internal/utils" +// ) -func TestProjectFlockSummary(t *testing.T) { - app, db := setupIntegrationApp(t) +// func TestProjectFlockSummary(t *testing.T) { +// app, db := setupIntegrationApp(t) - areaID := createArea(t, app, "Area Project") - locationID := createLocation(t, app, "Location Project", "Address", areaID) - flockID := createFlock(t, app, "Flock Summary") - fcrID := createFcr(t, app, "FCR Summary", []map[string]any{ - {"weight": 1.0, "fcr_number": 1.5, "mortality": 2.0}, - }) - kandangID := createKandang(t, app, "Kandang Summary", locationID, 1) +// areaID := createArea(t, app, "Area Project") +// locationID := createLocation(t, app, "Location Project", "Address", areaID) +// flockID := createFlock(t, app, "Flock Summary") +// fcrID := createFcr(t, app, "FCR Summary", []map[string]any{ +// {"weight": 1.0, "fcr_number": 1.5, "mortality": 2.0}, +// }) +// kandangID := createKandang(t, app, "Kandang Summary", locationID, 1) - createPayload := map[string]any{ - "flock_id": flockID, - "area_id": areaID, - "category": "growing", - "fcr_id": fcrID, - "location_id": locationID, - "kandang_ids": []uint{kandangID}, - } - resp, body := doJSONRequest(t, app, http.MethodPost, "/api/production/project_flocks", createPayload) - if resp.StatusCode != fiber.StatusCreated { - t.Fatalf("expected 201 when creating project flock, got %d: %s", resp.StatusCode, string(body)) - } +// createPayload := map[string]any{ +// "flock_id": flockID, +// "area_id": areaID, +// "category": "growing", +// "fcr_id": fcrID, +// "location_id": locationID, +// "kandang_ids": []uint{kandangID}, +// } +// resp, body := doJSONRequest(t, app, http.MethodPost, "/api/production/project_flocks", createPayload) +// if resp.StatusCode != fiber.StatusCreated { +// t.Fatalf("expected 201 when creating project flock, got %d: %s", resp.StatusCode, string(body)) +// } - var createResp struct { - Data struct { - Id uint `json:"id"` - Period int `json:"period"` - Category string `json:"category"` - Flock struct { - Id uint `json:"id"` - Name string `json:"name"` - } `json:"flock"` - Area struct { - Id uint `json:"id"` - Name string `json:"name"` - } `json:"area"` - Fcr struct { - Id uint `json:"id"` - Name string `json:"name"` - } `json:"fcr"` - Location struct { - Id uint `json:"id"` - Name string `json:"name"` - Address string `json:"address"` - } `json:"location"` - Kandangs []struct { - Id uint `json:"id"` - Name string `json:"name"` - Status string `json:"status"` - } `json:"kandangs"` - CreatedUser struct { - Id uint `json:"id"` - IdUser uint `json:"id_user"` - Email string `json:"email"` - Name string `json:"name"` - } `json:"created_user"` - } `json:"data"` - } - if err := json.Unmarshal(body, &createResp); err != nil { - t.Fatalf("failed to parse create response: %v", err) - } - if createResp.Data.Flock.Id != flockID || createResp.Data.Flock.Name == "" { - t.Fatalf("expected flock detail to be present, got %+v", createResp.Data.Flock) - } - if createResp.Data.Area.Id != areaID || createResp.Data.Area.Name == "" { - t.Fatalf("expected area detail to be present, got %+v", createResp.Data.Area) - } - if createResp.Data.Category != string(utils.ProjectFlockCategoryGrowing) { - t.Fatalf("expected category to be %s, got %s", utils.ProjectFlockCategoryGrowing, createResp.Data.Category) - } - if createResp.Data.Location.Id != locationID || createResp.Data.Location.Name == "" { - t.Fatalf("expected location detail to be present, got %+v", createResp.Data.Location) - } - if len(createResp.Data.Kandangs) != 1 || createResp.Data.Kandangs[0].Id != kandangID { - t.Fatalf("expected kandang detail to be present, got %+v", createResp.Data.Kandangs) - } - if createResp.Data.Kandangs[0].Status != string(utils.KandangStatusPengajuan) { - t.Fatalf("expected kandang status to be PENGAJUAN, got %s", createResp.Data.Kandangs[0].Status) - } - if createResp.Data.Period != 1 { - t.Fatalf("expected period 1 to be assigned automatically, got %d", createResp.Data.Period) - } +// var createResp struct { +// Data struct { +// Id uint `json:"id"` +// Period int `json:"period"` +// Category string `json:"category"` +// Flock struct { +// Id uint `json:"id"` +// Name string `json:"name"` +// } `json:"flock"` +// Area struct { +// Id uint `json:"id"` +// Name string `json:"name"` +// } `json:"area"` +// Fcr struct { +// Id uint `json:"id"` +// Name string `json:"name"` +// } `json:"fcr"` +// Location struct { +// Id uint `json:"id"` +// Name string `json:"name"` +// Address string `json:"address"` +// } `json:"location"` +// Kandangs []struct { +// Id uint `json:"id"` +// Name string `json:"name"` +// Status string `json:"status"` +// } `json:"kandangs"` +// CreatedUser struct { +// Id uint `json:"id"` +// IdUser uint `json:"id_user"` +// Email string `json:"email"` +// Name string `json:"name"` +// } `json:"created_user"` +// } `json:"data"` +// } +// if err := json.Unmarshal(body, &createResp); err != nil { +// t.Fatalf("failed to parse create response: %v", err) +// } +// if createResp.Data.Flock.Id != flockID || createResp.Data.Flock.Name == "" { +// t.Fatalf("expected flock detail to be present, got %+v", createResp.Data.Flock) +// } +// if createResp.Data.Area.Id != areaID || createResp.Data.Area.Name == "" { +// t.Fatalf("expected area detail to be present, got %+v", createResp.Data.Area) +// } +// if createResp.Data.Category != string(utils.ProjectFlockCategoryGrowing) { +// t.Fatalf("expected category to be %s, got %s", utils.ProjectFlockCategoryGrowing, createResp.Data.Category) +// } +// if createResp.Data.Location.Id != locationID || createResp.Data.Location.Name == "" { +// t.Fatalf("expected location detail to be present, got %+v", createResp.Data.Location) +// } +// if len(createResp.Data.Kandangs) != 1 || createResp.Data.Kandangs[0].Id != kandangID { +// t.Fatalf("expected kandang detail to be present, got %+v", createResp.Data.Kandangs) +// } +// if createResp.Data.Kandangs[0].Status != string(utils.KandangStatusPengajuan) { +// t.Fatalf("expected kandang status to be PENGAJUAN, got %s", createResp.Data.Kandangs[0].Status) +// } +// if createResp.Data.Period != 1 { +// t.Fatalf("expected period 1 to be assigned automatically, got %d", createResp.Data.Period) +// } - createdKandang := fetchKandang(t, db, kandangID) - if createdKandang.Status != string(utils.KandangStatusPengajuan) { - t.Fatalf("expected kandang status in DB to be PENGAJUAN, got %s", createdKandang.Status) - } +// createdKandang := fetchKandang(t, db, kandangID) +// if createdKandang.Status != string(utils.KandangStatusPengajuan) { +// t.Fatalf("expected kandang status in DB to be PENGAJUAN, got %s", createdKandang.Status) +// } - var pivotRecords []entities.ProjectFlockKandang - if err := db.Where("project_flock_id = ?", createResp.Data.Id).Find(&pivotRecords).Error; err != nil { - t.Fatalf("failed to fetch pivot records: %v", err) - } - if len(pivotRecords) != 1 { - t.Fatalf("expected 1 pivot record, got %d", len(pivotRecords)) - } - firstPivotRecord := pivotRecords[0] - if firstPivotRecord.KandangId != kandangID { - t.Fatalf("expected pivot kandang id %d, got %d", kandangID, firstPivotRecord.KandangId) - } +// var pivotRecords []entities.ProjectFlockKandang +// if err := db.Where("project_flock_id = ?", createResp.Data.Id).Find(&pivotRecords).Error; err != nil { +// t.Fatalf("failed to fetch pivot records: %v", err) +// } +// if len(pivotRecords) != 1 { +// t.Fatalf("expected 1 pivot record, got %d", len(pivotRecords)) +// } +// firstPivotRecord := pivotRecords[0] +// if firstPivotRecord.KandangId != kandangID { +// t.Fatalf("expected pivot kandang id %d, got %d", kandangID, firstPivotRecord.KandangId) +// } - secondKandangID := createKandang(t, app, "Kandang Summary 2", locationID, 1) - secondPayload := map[string]any{ - "flock_id": flockID, - "area_id": areaID, - "category": "laying", - "fcr_id": fcrID, - "location_id": locationID, - "kandang_ids": []uint{secondKandangID}, - } - resp, body = doJSONRequest(t, app, http.MethodPost, "/api/production/project_flocks", secondPayload) - if resp.StatusCode != fiber.StatusCreated { - t.Fatalf("expected 201 when creating second project flock, got %d: %s", resp.StatusCode, string(body)) - } - var createRespSecond struct { - Data struct { - Id uint `json:"id"` - Period int `json:"period"` - Category string `json:"category"` - } `json:"data"` - } - if err := json.Unmarshal(body, &createRespSecond); err != nil { - t.Fatalf("failed to parse second create response: %v", err) - } - if createRespSecond.Data.Period != 2 { - t.Fatalf("expected second period to be 2, got %d", createRespSecond.Data.Period) - } - if createRespSecond.Data.Category != string(utils.ProjectFlockCategoryLaying) { - t.Fatalf("expected category to be %s, got %s", utils.ProjectFlockCategoryLaying, createRespSecond.Data.Category) - } +// secondKandangID := createKandang(t, app, "Kandang Summary 2", locationID, 1) +// secondPayload := map[string]any{ +// "flock_id": flockID, +// "area_id": areaID, +// "category": "laying", +// "fcr_id": fcrID, +// "location_id": locationID, +// "kandang_ids": []uint{secondKandangID}, +// } +// resp, body = doJSONRequest(t, app, http.MethodPost, "/api/production/project_flocks", secondPayload) +// if resp.StatusCode != fiber.StatusCreated { +// t.Fatalf("expected 201 when creating second project flock, got %d: %s", resp.StatusCode, string(body)) +// } +// var createRespSecond struct { +// Data struct { +// Id uint `json:"id"` +// Period int `json:"period"` +// Category string `json:"category"` +// } `json:"data"` +// } +// if err := json.Unmarshal(body, &createRespSecond); err != nil { +// t.Fatalf("failed to parse second create response: %v", err) +// } +// if createRespSecond.Data.Period != 2 { +// t.Fatalf("expected second period to be 2, got %d", createRespSecond.Data.Period) +// } +// if createRespSecond.Data.Category != string(utils.ProjectFlockCategoryLaying) { +// t.Fatalf("expected category to be %s, got %s", utils.ProjectFlockCategoryLaying, createRespSecond.Data.Category) +// } - pivotRecords = nil - if err := db.Where("project_flock_id = ?", createRespSecond.Data.Id).Find(&pivotRecords).Error; err != nil { - t.Fatalf("failed to fetch second pivot records: %v", err) - } - if len(pivotRecords) != 1 { - t.Fatalf("expected 1 pivot record for second project, got %d", len(pivotRecords)) - } - secondPivotRecord := pivotRecords[0] - if secondPivotRecord.KandangId != secondKandangID { - t.Fatalf("expected second pivot kandang id %d, got %d", secondKandangID, secondPivotRecord.KandangId) - } +// pivotRecords = nil +// if err := db.Where("project_flock_id = ?", createRespSecond.Data.Id).Find(&pivotRecords).Error; err != nil { +// t.Fatalf("failed to fetch second pivot records: %v", err) +// } +// if len(pivotRecords) != 1 { +// t.Fatalf("expected 1 pivot record for second project, got %d", len(pivotRecords)) +// } +// secondPivotRecord := pivotRecords[0] +// if secondPivotRecord.KandangId != secondKandangID { +// t.Fatalf("expected second pivot kandang id %d, got %d", secondKandangID, secondPivotRecord.KandangId) +// } - secondKandang := fetchKandang(t, db, secondKandangID) - if secondKandang.Status != string(utils.KandangStatusPengajuan) { - t.Fatalf("expected second kandang status in DB to be PENGAJUAN, got %s", secondKandang.Status) - } +// secondKandang := fetchKandang(t, db, secondKandangID) +// if secondKandang.Status != string(utils.KandangStatusPengajuan) { +// t.Fatalf("expected second kandang status in DB to be PENGAJUAN, got %s", secondKandang.Status) +// } - resp, body = doJSONRequest(t, app, http.MethodGet, "/api/production/project_flocks/flocks/"+uintToString(flockID)+"/periods", nil) - if resp.StatusCode != fiber.StatusOK { - t.Fatalf("expected 200 when fetching summary, got %d: %s", resp.StatusCode, string(body)) - } +// resp, body = doJSONRequest(t, app, http.MethodGet, "/api/production/project_flocks/flocks/"+uintToString(flockID)+"/periods", nil) +// if resp.StatusCode != fiber.StatusOK { +// t.Fatalf("expected 200 when fetching summary, got %d: %s", resp.StatusCode, string(body)) +// } - var summary struct { - Data struct { - NextPeriod int `json:"next_period"` - } `json:"data"` - } - if err := json.Unmarshal(body, &summary); err != nil { - t.Fatalf("failed to parse summary response: %v", err) - } +// var summary struct { +// Data struct { +// NextPeriod int `json:"next_period"` +// } `json:"data"` +// } +// if err := json.Unmarshal(body, &summary); err != nil { +// t.Fatalf("failed to parse summary response: %v", err) +// } - if summary.Data.NextPeriod != 3 { - t.Fatalf("expected next_period 3, got %d", summary.Data.NextPeriod) - } +// if summary.Data.NextPeriod != 3 { +// t.Fatalf("expected next_period 3, got %d", summary.Data.NextPeriod) +// } - resp, body = doJSONRequest(t, app, http.MethodDelete, "/api/production/project_flocks/"+uintToString(createResp.Data.Id), nil) - if resp.StatusCode != fiber.StatusOK { - t.Fatalf("expected 200 when deleting first project flock, got %d: %s", resp.StatusCode, string(body)) - } +// resp, body = doJSONRequest(t, app, http.MethodDelete, "/api/production/project_flocks/"+uintToString(createResp.Data.Id), nil) +// if resp.StatusCode != fiber.StatusOK { +// t.Fatalf("expected 200 when deleting first project flock, got %d: %s", resp.StatusCode, string(body)) +// } - firstKandang := fetchKandang(t, db, kandangID) - if firstKandang.ProjectFlockId != nil { - t.Fatalf("expected project_flock_id to be nil after delete, got %v", *firstKandang.ProjectFlockId) - } - if firstKandang.Status != string(utils.KandangStatusNonActive) { - t.Fatalf("expected kandang status to revert to NON_ACTIVE, got %s", firstKandang.Status) - } +// firstKandang := fetchKandang(t, db, kandangID) +// if firstKandang.ProjectFlockId != nil { +// t.Fatalf("expected project_flock_id to be nil after delete, got %v", *firstKandang.ProjectFlockId) +// } +// if firstKandang.Status != string(utils.KandangStatusNonActive) { +// t.Fatalf("expected kandang status to revert to NON_ACTIVE, got %s", firstKandang.Status) +// } - var remainingFirst int64 - if err := db.Model(&entities.ProjectFlockKandang{}). - Where("project_flock_id = ? AND kandang_id = ?", createResp.Data.Id, kandangID). - Count(&remainingFirst).Error; err != nil { - t.Fatalf("failed to count first pivot records after delete: %v", err) - } - if remainingFirst != 0 { - t.Fatalf("expected no pivot records remaining after delete, found %d", remainingFirst) - } +// var remainingFirst int64 +// if err := db.Model(&entities.ProjectFlockKandang{}). +// Where("project_flock_id = ? AND kandang_id = ?", createResp.Data.Id, kandangID). +// Count(&remainingFirst).Error; err != nil { +// t.Fatalf("failed to count first pivot records after delete: %v", err) +// } +// if remainingFirst != 0 { +// t.Fatalf("expected no pivot records remaining after delete, found %d", remainingFirst) +// } - resp, body = doJSONRequest(t, app, http.MethodDelete, "/api/production/project_flocks/"+uintToString(createRespSecond.Data.Id), nil) - if resp.StatusCode != fiber.StatusOK { - t.Fatalf("expected 200 when deleting second project flock, got %d: %s", resp.StatusCode, string(body)) - } +// resp, body = doJSONRequest(t, app, http.MethodDelete, "/api/production/project_flocks/"+uintToString(createRespSecond.Data.Id), nil) +// if resp.StatusCode != fiber.StatusOK { +// t.Fatalf("expected 200 when deleting second project flock, got %d: %s", resp.StatusCode, string(body)) +// } - secondKandang = fetchKandang(t, db, secondKandangID) - if secondKandang.ProjectFlockId != nil { - t.Fatalf("expected second project_flock_id to be nil after delete, got %v", *secondKandang.ProjectFlockId) - } - if secondKandang.Status != string(utils.KandangStatusNonActive) { - t.Fatalf("expected second kandang status to revert to NON_ACTIVE, got %s", secondKandang.Status) - } +// secondKandang = fetchKandang(t, db, secondKandangID) +// if secondKandang.ProjectFlockId != nil { +// t.Fatalf("expected second project_flock_id to be nil after delete, got %v", *secondKandang.ProjectFlockId) +// } +// if secondKandang.Status != string(utils.KandangStatusNonActive) { +// t.Fatalf("expected second kandang status to revert to NON_ACTIVE, got %s", secondKandang.Status) +// } - var remainingSecond int64 - if err := db.Model(&entities.ProjectFlockKandang{}). - Where("project_flock_id = ? AND kandang_id = ?", createRespSecond.Data.Id, secondKandangID). - Count(&remainingSecond).Error; err != nil { - t.Fatalf("failed to count second pivot records after delete: %v", err) - } - if remainingSecond != 0 { - t.Fatalf("expected no second pivot records remaining after delete, found %d", remainingSecond) - } +// var remainingSecond int64 +// if err := db.Model(&entities.ProjectFlockKandang{}). +// Where("project_flock_id = ? AND kandang_id = ?", createRespSecond.Data.Id, secondKandangID). +// Count(&remainingSecond).Error; err != nil { +// t.Fatalf("failed to count second pivot records after delete: %v", err) +// } +// if remainingSecond != 0 { +// t.Fatalf("expected no second pivot records remaining after delete, found %d", remainingSecond) +// } - resp, body = doJSONRequest(t, app, http.MethodGet, "/api/production/project_flocks/flocks/"+uintToString(flockID)+"/periods", nil) - if resp.StatusCode != fiber.StatusOK { - t.Fatalf("expected 200 when fetching summary after delete, got %d: %s", resp.StatusCode, string(body)) - } +// resp, body = doJSONRequest(t, app, http.MethodGet, "/api/production/project_flocks/flocks/"+uintToString(flockID)+"/periods", nil) +// if resp.StatusCode != fiber.StatusOK { +// t.Fatalf("expected 200 when fetching summary after delete, got %d: %s", resp.StatusCode, string(body)) +// } - if err := json.Unmarshal(body, &summary); err != nil { - t.Fatalf("failed to parse summary response after delete: %v", err) - } +// if err := json.Unmarshal(body, &summary); err != nil { +// t.Fatalf("failed to parse summary response after delete: %v", err) +// } - if summary.Data.NextPeriod != 1 { - t.Fatalf("expected next_period 1 after soft deletes, got %d", summary.Data.NextPeriod) - } -} +// if summary.Data.NextPeriod != 1 { +// t.Fatalf("expected next_period 1 after soft deletes, got %d", summary.Data.NextPeriod) +// } +// } -func uintToString(v uint) string { - return fmt.Sprintf("%d", v) -} +// func uintToString(v uint) string { +// return fmt.Sprintf("%d", v) +// } -func TestProjectFlockSearchByRelatedFields(t *testing.T) { - app, _ := setupIntegrationApp(t) +// func TestProjectFlockSearchByRelatedFields(t *testing.T) { +// app, _ := setupIntegrationApp(t) - areaID := createArea(t, app, "Area Search Target") - locationID := createLocation(t, app, "Location Search Target", "Location Address Target", areaID) - flockID := createFlock(t, app, "Flock Search Target") - fcrID := createFcr(t, app, "FCR Search Target", []map[string]any{ - {"weight": 1.0, "fcr_number": 1.5, "mortality": 2.0}, - }) - kandangID := createKandang(t, app, "Kandang Search Target", locationID, 1) +// areaID := createArea(t, app, "Area Search Target") +// locationID := createLocation(t, app, "Location Search Target", "Location Address Target", areaID) +// flockID := createFlock(t, app, "Flock Search Target") +// fcrID := createFcr(t, app, "FCR Search Target", []map[string]any{ +// {"weight": 1.0, "fcr_number": 1.5, "mortality": 2.0}, +// }) +// kandangID := createKandang(t, app, "Kandang Search Target", locationID, 1) - createPayload := map[string]any{ - "flock_id": flockID, - "area_id": areaID, - "category": "growing", - "fcr_id": fcrID, - "location_id": locationID, - "kandang_ids": []uint{kandangID}, - } +// createPayload := map[string]any{ +// "flock_id": flockID, +// "area_id": areaID, +// "category": "growing", +// "fcr_id": fcrID, +// "location_id": locationID, +// "kandang_ids": []uint{kandangID}, +// } - resp, body := doJSONRequest(t, app, http.MethodPost, "/api/production/project_flocks", createPayload) - if resp.StatusCode != fiber.StatusCreated { - t.Fatalf("expected 201 when creating project flock, got %d: %s", resp.StatusCode, string(body)) - } +// resp, body := doJSONRequest(t, app, http.MethodPost, "/api/production/project_flocks", createPayload) +// if resp.StatusCode != fiber.StatusCreated { +// t.Fatalf("expected 201 when creating project flock, got %d: %s", resp.StatusCode, string(body)) +// } - var createResp struct { - Data struct { - Id uint `json:"id"` - } `json:"data"` - } - if err := json.Unmarshal(body, &createResp); err != nil { - t.Fatalf("failed to parse create response: %v", err) - } +// var createResp struct { +// Data struct { +// Id uint `json:"id"` +// } `json:"data"` +// } +// if err := json.Unmarshal(body, &createResp); err != nil { +// t.Fatalf("failed to parse create response: %v", err) +// } - searchTerms := []string{ - "Flock Search Target", - "Area Search Target", - string(utils.ProjectFlockCategoryGrowing), - "growing", - "FCR Search Target", - "Kandang Search Target", - "Location Search Target", - "Location Address Target", - "Tester", - "1", - } +// searchTerms := []string{ +// "Flock Search Target", +// "Area Search Target", +// string(utils.ProjectFlockCategoryGrowing), +// "growing", +// "FCR Search Target", +// "Kandang Search Target", +// "Location Search Target", +// "Location Address Target", +// "Tester", +// "1", +// } - for _, term := range searchTerms { - path := "/api/production/project_flocks?search=" + url.QueryEscape(term) - resp, body := doJSONRequest(t, app, http.MethodGet, path, nil) - if resp.StatusCode != fiber.StatusOK { - t.Fatalf("expected 200 when searching for %q, got %d: %s", term, resp.StatusCode, string(body)) - } +// for _, term := range searchTerms { +// path := "/api/production/project_flocks?search=" + url.QueryEscape(term) +// resp, body := doJSONRequest(t, app, http.MethodGet, path, nil) +// if resp.StatusCode != fiber.StatusOK { +// t.Fatalf("expected 200 when searching for %q, got %d: %s", term, resp.StatusCode, string(body)) +// } - var listResp struct { - Data []struct { - Id uint `json:"id"` - } `json:"data"` - Meta struct { - TotalResults int64 `json:"total_results"` - } `json:"meta"` - } - if err := json.Unmarshal(body, &listResp); err != nil { - t.Fatalf("failed to parse list response for %q: %v", term, err) - } - if listResp.Meta.TotalResults == 0 { - t.Fatalf("expected at least one result when searching for %q", term) - } - if len(listResp.Data) == 0 { - t.Fatalf("expected data when searching for %q", term) - } - if listResp.Data[0].Id != createResp.Data.Id { - t.Fatalf("expected project flock id %d for search term %q, got %d", createResp.Data.Id, term, listResp.Data[0].Id) - } - } -} +// var listResp struct { +// Data []struct { +// Id uint `json:"id"` +// } `json:"data"` +// Meta struct { +// TotalResults int64 `json:"total_results"` +// } `json:"meta"` +// } +// if err := json.Unmarshal(body, &listResp); err != nil { +// t.Fatalf("failed to parse list response for %q: %v", term, err) +// } +// if listResp.Meta.TotalResults == 0 { +// t.Fatalf("expected at least one result when searching for %q", term) +// } +// if len(listResp.Data) == 0 { +// t.Fatalf("expected data when searching for %q", term) +// } +// if listResp.Data[0].Id != createResp.Data.Id { +// t.Fatalf("expected project flock id %d for search term %q, got %d", createResp.Data.Id, term, listResp.Data[0].Id) +// } +// } +// } -func TestProjectFlockSorting(t *testing.T) { - app, _ := setupIntegrationApp(t) +// func TestProjectFlockSorting(t *testing.T) { +// app, _ := setupIntegrationApp(t) - areaA := createArea(t, app, "Area Alpha") - areaB := createArea(t, app, "Area Beta") +// areaA := createArea(t, app, "Area Alpha") +// areaB := createArea(t, app, "Area Beta") - locationA := createLocation(t, app, "Location Alpha", "Address Alpha", areaA) - locationB := createLocation(t, app, "Location Beta", "Address Beta", areaB) +// locationA := createLocation(t, app, "Location Alpha", "Address Alpha", areaA) +// locationB := createLocation(t, app, "Location Beta", "Address Beta", areaB) - flockOne := createFlock(t, app, "Flock Sort One") - flockTwo := createFlock(t, app, "Flock Sort Two") +// flockOne := createFlock(t, app, "Flock Sort One") +// flockTwo := createFlock(t, app, "Flock Sort Two") - fcrID := createFcr(t, app, "FCR Sort", []map[string]any{ - {"weight": 1.0, "fcr_number": 1.5, "mortality": 2.0}, - }) +// fcrID := createFcr(t, app, "FCR Sort", []map[string]any{ +// {"weight": 1.0, "fcr_number": 1.5, "mortality": 2.0}, +// }) - kandangOne := createKandang(t, app, "Kandang Sort One", locationA, 1) - kandangTwo := createKandang(t, app, "Kandang Sort Two", locationB, 1) - kandangThree := createKandang(t, app, "Kandang Sort Three", locationB, 1) +// kandangOne := createKandang(t, app, "Kandang Sort One", locationA, 1) +// kandangTwo := createKandang(t, app, "Kandang Sort Two", locationB, 1) +// kandangThree := createKandang(t, app, "Kandang Sort Three", locationB, 1) - projectOnePayload := map[string]any{ - "flock_id": flockOne, - "area_id": areaA, - "category": "growing", - "fcr_id": fcrID, - "location_id": locationA, - "kandang_ids": []uint{kandangOne}, - } - resp, body := doJSONRequest(t, app, http.MethodPost, "/api/production/project_flocks", projectOnePayload) - if resp.StatusCode != fiber.StatusCreated { - t.Fatalf("expected 201 for project one, got %d: %s", resp.StatusCode, string(body)) - } - projectOneID := parseProjectFlockID(t, body) +// projectOnePayload := map[string]any{ +// "flock_id": flockOne, +// "area_id": areaA, +// "category": "growing", +// "fcr_id": fcrID, +// "location_id": locationA, +// "kandang_ids": []uint{kandangOne}, +// } +// resp, body := doJSONRequest(t, app, http.MethodPost, "/api/production/project_flocks", projectOnePayload) +// if resp.StatusCode != fiber.StatusCreated { +// t.Fatalf("expected 201 for project one, got %d: %s", resp.StatusCode, string(body)) +// } +// projectOneID := parseProjectFlockID(t, body) - projectTwoPayload := map[string]any{ - "flock_id": flockTwo, - "area_id": areaB, - "category": "laying", - "fcr_id": fcrID, - "location_id": locationB, - "kandang_ids": []uint{kandangTwo, kandangThree}, - } - resp, body = doJSONRequest(t, app, http.MethodPost, "/api/production/project_flocks", projectTwoPayload) - if resp.StatusCode != fiber.StatusCreated { - t.Fatalf("expected 201 for project two, got %d: %s", resp.StatusCode, string(body)) - } - projectTwoID := parseProjectFlockID(t, body) +// projectTwoPayload := map[string]any{ +// "flock_id": flockTwo, +// "area_id": areaB, +// "category": "laying", +// "fcr_id": fcrID, +// "location_id": locationB, +// "kandang_ids": []uint{kandangTwo, kandangThree}, +// } +// resp, body = doJSONRequest(t, app, http.MethodPost, "/api/production/project_flocks", projectTwoPayload) +// if resp.StatusCode != fiber.StatusCreated { +// t.Fatalf("expected 201 for project two, got %d: %s", resp.StatusCode, string(body)) +// } +// projectTwoID := parseProjectFlockID(t, body) - updatePeriodPayload := map[string]any{"period": 5} - resp, body = doJSONRequest(t, app, http.MethodPatch, "/api/production/project_flocks/"+uintToString(projectTwoID), updatePeriodPayload) - if resp.StatusCode != fiber.StatusOK { - t.Fatalf("expected 200 when updating period, got %d: %s", resp.StatusCode, string(body)) - } +// updatePeriodPayload := map[string]any{"period": 5} +// resp, body = doJSONRequest(t, app, http.MethodPatch, "/api/production/project_flocks/"+uintToString(projectTwoID), updatePeriodPayload) +// if resp.StatusCode != fiber.StatusOK { +// t.Fatalf("expected 200 when updating period, got %d: %s", resp.StatusCode, string(body)) +// } - assertOrder := func(t *testing.T, app *fiber.App, query string, expectedFirst uint) { - t.Helper() - resp, body := doJSONRequest(t, app, http.MethodGet, "/api/production/project_flocks?"+query, nil) - if resp.StatusCode != fiber.StatusOK { - t.Fatalf("expected 200 for query %q, got %d: %s", query, resp.StatusCode, string(body)) - } - var listResp struct { - Data []struct { - Id uint `json:"id"` - } `json:"data"` - } - if err := json.Unmarshal(body, &listResp); err != nil { - t.Fatalf("failed to parse list response for %q: %v", query, err) - } - if len(listResp.Data) == 0 { - t.Fatalf("expected data for query %q", query) - } - if listResp.Data[0].Id != expectedFirst { - t.Fatalf("expected first id %d for query %q, got %d", expectedFirst, query, listResp.Data[0].Id) - } - } +// assertOrder := func(t *testing.T, app *fiber.App, query string, expectedFirst uint) { +// t.Helper() +// resp, body := doJSONRequest(t, app, http.MethodGet, "/api/production/project_flocks?"+query, nil) +// if resp.StatusCode != fiber.StatusOK { +// t.Fatalf("expected 200 for query %q, got %d: %s", query, resp.StatusCode, string(body)) +// } +// var listResp struct { +// Data []struct { +// Id uint `json:"id"` +// } `json:"data"` +// } +// if err := json.Unmarshal(body, &listResp); err != nil { +// t.Fatalf("failed to parse list response for %q: %v", query, err) +// } +// if len(listResp.Data) == 0 { +// t.Fatalf("expected data for query %q", query) +// } +// if listResp.Data[0].Id != expectedFirst { +// t.Fatalf("expected first id %d for query %q, got %d", expectedFirst, query, listResp.Data[0].Id) +// } +// } - assertOrder(t, app, "sort_by=area&sort_order=asc", projectOneID) - assertOrder(t, app, "sort_by=location&sort_order=desc", projectTwoID) - assertOrder(t, app, "sort_by=period&sort_order=desc", projectTwoID) - assertOrder(t, app, "sort_by=kandangs&sort_order=desc", projectTwoID) - assertOrder(t, app, "sort_by=kandangs&sort_order=asc", projectOneID) -} +// assertOrder(t, app, "sort_by=area&sort_order=asc", projectOneID) +// assertOrder(t, app, "sort_by=location&sort_order=desc", projectTwoID) +// assertOrder(t, app, "sort_by=period&sort_order=desc", projectTwoID) +// assertOrder(t, app, "sort_by=kandangs&sort_order=desc", projectTwoID) +// assertOrder(t, app, "sort_by=kandangs&sort_order=asc", projectOneID) +// } -func parseProjectFlockID(t *testing.T, body []byte) uint { - t.Helper() - var resp struct { - Data struct { - Id uint `json:"id"` - } `json:"data"` - } - if err := json.Unmarshal(body, &resp); err != nil { - t.Fatalf("failed to parse project flock response: %v", err) - } - return resp.Data.Id -} +// func parseProjectFlockID(t *testing.T, body []byte) uint { +// t.Helper() +// var resp struct { +// Data struct { +// Id uint `json:"id"` +// } `json:"data"` +// } +// if err := json.Unmarshal(body, &resp); err != nil { +// t.Fatalf("failed to parse project flock response: %v", err) +// } +// return resp.Data.Id +// } From 4b39f52d5a37d566cee1deda45225fff49113657 Mon Sep 17 00:00:00 2001 From: ragilap Date: Fri, 31 Oct 2025 16:04:22 +0700 Subject: [PATCH 08/10] feat/BE/US-76/US-78/US-79/TASK-112,120,133,121-Recording growing/TASK-187,189,202,190-Recording Laying/TASK-191,192,194,197,203-Grading Telur --- internal/database/seed/seeder.go | 65 ++++++++--- .../controllers/projectflock.controller.go | 4 +- .../projectflock_kandang.repository.go | 18 +++ .../production/project_flocks/route.go | 2 +- .../services/projectflock.service.go | 73 ++++++++---- .../recordings/dto/recording.dto.go | 108 +++++++++++------- 6 files changed, 188 insertions(+), 82 deletions(-) diff --git a/internal/database/seed/seeder.go b/internal/database/seed/seeder.go index 99188e73..24425917 100644 --- a/internal/database/seed/seeder.go +++ b/internal/database/seed/seeder.go @@ -363,6 +363,7 @@ func seedProductCategories(tx *gorm.DB, createdBy uint) (map[string]uint, error) Name string Code string }{ + {"Pullet", "PLT"}, {"Bahan Baku", "RAW"}, {"Day Old Chick", "DOC"}, {"Telur", "EGG"}, @@ -569,6 +570,54 @@ func seedProducts(tx *gorm.DB, createdBy uint, uoms map[string]uint, categories Suppliers: []string{"PT CHAROEN POKPHAND INDONESIA Tbk"}, Flags: []utils.FlagType{utils.FlagDOC}, }, + { + Name: "Ayam Afkir", + Brand: "-", + Sku: "1", + Uom: "Ekor", + Category: "Day Old Chick", + Price: 1, + + + }, + { + Name: "Ayam Mati", + Brand: "-", + Sku: "2", + Uom: "Ekor", + Category: "Day Old Chick", + Price: 1, + + + }, + { + Name: "Ayam Culling", + Brand: "-", + Sku: "3", + Uom: "Ekor", + Category: "Day Old Chick", + Price: 1, + + + }, + { + Name: "Telur Konsumsi Baik", + Brand: "-", + Sku: "4", + Uom: "Unit", + Category: "Telur", + Price: 1, + + }, + { + Name: "Telur Pecah", + Brand: "-", + Sku: "5", + Uom: "Unit", + Category: "Telur", + Price: 1, + + }, { Name: "281 SPECIAL STARTER", Brand: "281 STARTER", @@ -580,22 +629,6 @@ func seedProducts(tx *gorm.DB, createdBy uint, uoms map[string]uint, categories Suppliers: []string{"PT CHAROEN POKPHAND INDONESIA Tbk"}, Flags: []utils.FlagType{utils.FlagPakan, utils.FlagStarter}, }, - { - Name: "Telur Konsumsi Baik", - Brand: "Layer Farm", - Sku: "EGG-GOOD", - Uom: "Unit", - Category: "Telur", - Price: 1800, - }, - { - Name: "Telur Pecah", - Brand: "Layer Farm", - Sku: "EGG-CRACK", - Uom: "Unit", - Category: "Telur", - Price: 900, - }, } for _, seed := range seeds { diff --git a/internal/modules/production/project_flocks/controllers/projectflock.controller.go b/internal/modules/production/project_flocks/controllers/projectflock.controller.go index 668743b3..d3b0061c 100644 --- a/internal/modules/production/project_flocks/controllers/projectflock.controller.go +++ b/internal/modules/production/project_flocks/controllers/projectflock.controller.go @@ -222,11 +222,11 @@ func (u *ProjectflockController) Approval(c *fiber.Ctx) error { } func (u *ProjectflockController) GetFlockPeriodSummary(c *fiber.Ctx) error { - param := c.Params("flock_id") + param := c.Params("project_flock_kandang_id") id, err := strconv.Atoi(param) if err != nil { - return fiber.NewError(fiber.StatusBadRequest, "Invalid Flock Id") + return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_kandang_id") } summary, err := u.ProjectflockService.GetFlockPeriodSummary(c, uint(id)) diff --git a/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go index f18d0654..e6a36c87 100644 --- a/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go +++ b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go @@ -2,6 +2,7 @@ package repository import ( "context" + "strings" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" "gorm.io/gorm" @@ -16,6 +17,7 @@ type ProjectFlockKandangRepository interface { ListExistingKandangIDs(ctx context.Context, projectFlockID uint, kandangIDs []uint) ([]uint, error) HasKandangsLinkedToOtherProject(ctx context.Context, kandangIDs []uint, exceptProjectID *uint) (bool, error) FindKandangsWithRecordings(ctx context.Context, projectFlockID uint, kandangIDs []uint) ([]entity.Kandang, error) + MaxPeriodByBaseName(ctx context.Context, baseName string) (int, error) WithTx(tx *gorm.DB) ProjectFlockKandangRepository DB() *gorm.DB } @@ -24,6 +26,8 @@ type projectFlockKandangRepositoryImpl struct { db *gorm.DB } +const flockBaseNameExpression = "LOWER(TRIM(regexp_replace(project_flocks.flock_name, '\\s+\\d+(\\s+\\d+)*$', '', 'g')))" + func NewProjectFlockKandangRepository(db *gorm.DB) ProjectFlockKandangRepository { return &projectFlockKandangRepositoryImpl{db: db} } @@ -149,3 +153,17 @@ func (r *projectFlockKandangRepositoryImpl) FindKandangsWithRecordings(ctx conte Scan(&kandangs).Error return kandangs, err } + +func (r *projectFlockKandangRepositoryImpl) MaxPeriodByBaseName(ctx context.Context, baseName string) (int, error) { + if strings.TrimSpace(baseName) == "" { + return 0, nil + } + var max int + err := r.db.WithContext(ctx). + Table("project_flock_kandangs pfk"). + Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id"). + Where(flockBaseNameExpression+" = LOWER(?)", baseName). + Select("COALESCE(MAX(pf.period), 0)"). + Scan(&max).Error + return max, err +} diff --git a/internal/modules/production/project_flocks/route.go b/internal/modules/production/project_flocks/route.go index 38f14bb0..7642b90c 100644 --- a/internal/modules/production/project_flocks/route.go +++ b/internal/modules/production/project_flocks/route.go @@ -27,6 +27,6 @@ func ProjectflockRoutes(v1 fiber.Router, u user.UserService, s projectflock.Proj route.Delete("/:id", ctrl.DeleteOne) route.Get("/kandangs/lookup", ctrl.LookupProjectFlockKandang) route.Post("/approvals", ctrl.Approval) - route.Get("/flocks/:flock_id/periods", ctrl.GetFlockPeriodSummary) + route.Get("/kandangs/:project_flock_kandang_id/periods", ctrl.GetFlockPeriodSummary) } diff --git a/internal/modules/production/project_flocks/services/projectflock.service.go b/internal/modules/production/project_flocks/services/projectflock.service.go index 47589f08..e01d3385 100644 --- a/internal/modules/production/project_flocks/services/projectflock.service.go +++ b/internal/modules/production/project_flocks/services/projectflock.service.go @@ -52,8 +52,8 @@ type projectflockService struct { } type FlockPeriodSummary struct { - Flock entity.Flock - NextPeriod int + Flock entity.Flock + NextPeriod int } func NewProjectflockService( @@ -719,28 +719,57 @@ func (s projectflockService) GetAvailableDocQuantity(ctx *fiber.Ctx, kandangID u return total, nil } -func (s projectflockService) GetFlockPeriodSummary(c *fiber.Ctx, flockID uint) (*FlockPeriodSummary, error) { - flock, err := s.FlockRepo.GetByID(c.Context(), flockID, func(db *gorm.DB) *gorm.DB { - return db.Preload("CreatedUser") - }) - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Flock not found") - } - if err != nil { - s.Log.Errorf("Failed get flock %d for period summary: %+v", flockID, err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch flock") - } +func (s projectflockService) GetFlockPeriodSummary(c *fiber.Ctx, projectFlockKandangID uint) (*FlockPeriodSummary, error) { + if projectFlockKandangID == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is required") + } - maxPeriod, err := s.Repository.GetMaxPeriodByBaseName(c.Context(), flock.Name) - if err != nil { - s.Log.Errorf("Failed to compute next period for flock %d: %+v", flockID, err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to compute next period") - } + pivot, err := s.pivotRepo().GetByID(c.Context(), projectFlockKandangID) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Project flock kandang not found") + } + if err != nil { + s.Log.Errorf("Failed to fetch project_flock_kandang %d: %+v", projectFlockKandangID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandang") + } - return &FlockPeriodSummary{ - Flock: *flock, - NextPeriod: maxPeriod + 1, - }, nil + var baseName string + var referenceFlock *entity.Flock + if pivot.ProjectFlock.Id != 0 { + baseName = pfutils.DeriveBaseName(pivot.ProjectFlock.FlockName) + } + + if strings.TrimSpace(baseName) != "" { + referenceFlock, err = s.FlockRepo.GetByName(c.Context(), baseName) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Failed to fetch flock %q: %+v", baseName, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch flock") + } + } + + if referenceFlock == nil { + referenceFlock = &entity.Flock{Name: pivot.ProjectFlock.FlockName} + } + + maxPeriod := pivot.ProjectFlock.Period + if strings.TrimSpace(baseName) != "" { + if headerMax, err := s.Repository.GetMaxPeriodByBaseName(c.Context(), baseName); err != nil { + s.Log.Warnf("Unable to compute header period for base %q: %+v", baseName, err) + } else if headerMax > maxPeriod { + maxPeriod = headerMax + } + + if pivotMax, err := s.pivotRepo().MaxPeriodByBaseName(c.Context(), baseName); err != nil { + s.Log.Warnf("Unable to compute pivot period for base %q: %+v", baseName, err) + } else if pivotMax > maxPeriod { + maxPeriod = pivotMax + } + } + + return &FlockPeriodSummary{ + Flock: *referenceFlock, + NextPeriod: maxPeriod + 1, + }, nil } func uniqueUintSlice(values []uint) []uint { diff --git a/internal/modules/production/recordings/dto/recording.dto.go b/internal/modules/production/recordings/dto/recording.dto.go index 07135e1d..e8d04758 100644 --- a/internal/modules/production/recordings/dto/recording.dto.go +++ b/internal/modules/production/recordings/dto/recording.dto.go @@ -14,21 +14,22 @@ import ( // === DTO Structs === type RecordingBaseDTO struct { - Id uint `json:"id"` - ProjectFlockKandangId uint `json:"project_flock_kandang_id"` - RecordDatetime time.Time `json:"record_datetime"` - Day *int `json:"day,omitempty"` - ProjectFlockCategory *string `json:"project_flock_category,omitempty"` - TotalDepletionQty *float64 `json:"total_depletion_qty,omitempty"` - CumDepletionRate *float64 `json:"cum_depletion_rate,omitempty"` - DailyGain *float64 `json:"daily_gain,omitempty"` - AvgDailyGain *float64 `json:"avg_daily_gain,omitempty"` - CumIntake *int `json:"cum_intake,omitempty"` - FcrValue *float64 `json:"fcr_value,omitempty"` - TotalChickQty *float64 `json:"total_chick_qty,omitempty"` - Approval approvalDTO.ApprovalBaseDTO `json:"approval"` - EggGradingStatus *string `json:"egg_grading_status,omitempty"` - EggGradingPendingQty *int `json:"egg_grading_pending_qty,omitempty"` + Id uint `json:"id"` + ProjectFlockKandangId uint `json:"project_flock_kandang_id"` + RecordDatetime time.Time `json:"record_datetime"` + Day *int `json:"day,omitempty"` + ProjectFlockCategory *string `json:"project_flock_category,omitempty"` + TotalDepletionQty *float64 `json:"total_depletion_qty,omitempty"` + CumDepletionRate *float64 `json:"cum_depletion_rate,omitempty"` + DailyGain *float64 `json:"daily_gain,omitempty"` + AvgDailyGain *float64 `json:"avg_daily_gain,omitempty"` + CumIntake *int `json:"cum_intake,omitempty"` + FcrValue *float64 `json:"fcr_value,omitempty"` + TotalChickQty *float64 `json:"total_chick_qty,omitempty"` + Approval approvalDTO.ApprovalBaseDTO `json:"approval"` + EggGradingStatus *string `json:"egg_grading_status,omitempty"` + EggGradingPendingQty *int `json:"egg_grading_pending_qty,omitempty"` + EggGradingCompletedQty *int `json:"egg_grading_completed_qty,omitempty"` } type RecordingListDTO struct { @@ -102,24 +103,25 @@ func ToRecordingBaseDTO(e entity.Recording) RecordingBaseDTO { latestApproval = snapshot } - gradingStatus, gradingPending := computeEggGradingStatus(e) + gradingStatus, gradingPending, gradingCompleted := computeEggGradingStatus(e) return RecordingBaseDTO{ - Id: e.Id, - ProjectFlockKandangId: e.ProjectFlockKandangId, - RecordDatetime: e.RecordDatetime, - Day: e.Day, - ProjectFlockCategory: projectFlockCategory, - TotalDepletionQty: e.TotalDepletionQty, - CumDepletionRate: e.CumDepletionRate, - DailyGain: e.DailyGain, - AvgDailyGain: e.AvgDailyGain, - CumIntake: e.CumIntake, - FcrValue: e.FcrValue, - TotalChickQty: e.TotalChickQty, - Approval: latestApproval, - EggGradingStatus: gradingStatus, - EggGradingPendingQty: gradingPending, + Id: e.Id, + ProjectFlockKandangId: e.ProjectFlockKandangId, + RecordDatetime: e.RecordDatetime, + Day: e.Day, + ProjectFlockCategory: projectFlockCategory, + TotalDepletionQty: e.TotalDepletionQty, + CumDepletionRate: e.CumDepletionRate, + DailyGain: e.DailyGain, + AvgDailyGain: e.AvgDailyGain, + CumIntake: e.CumIntake, + FcrValue: e.FcrValue, + TotalChickQty: e.TotalChickQty, + Approval: latestApproval, + EggGradingStatus: gradingStatus, + EggGradingPendingQty: gradingPending, + EggGradingCompletedQty: gradingCompleted, } } @@ -243,14 +245,17 @@ func toRecordingProductWarehouseDTO(pw *entity.ProductWarehouse) *RecordingProdu return &dto } -func computeEggGradingStatus(e entity.Recording) (*string, *int) { - if len(e.Eggs) == 0 { - return nil, nil +const goodEggProductWarehouseID uint = 5 + +func computeEggGradingStatus(e entity.Recording) (*string, *int, *int) { + goodEggs := filterGoodEggs(e.Eggs) + if len(goodEggs) == 0 { + return nil, nil, nil } totalEggs := 0 totalGraded := 0.0 - for _, egg := range e.Eggs { + for _, egg := range goodEggs { totalEggs += egg.Qty for _, grading := range egg.GradingEggs { totalGraded += grading.Qty @@ -258,20 +263,41 @@ func computeEggGradingStatus(e entity.Recording) (*string, *int) { } if totalEggs == 0 { - return nil, nil + return nil, nil, nil } - pending := float64(totalEggs) - totalGraded + pendingFloat := float64(totalEggs) - totalGraded + if pendingFloat < 0 { + pendingFloat = 0 + } + pendingInt := int(math.Round(pendingFloat)) + completedInt := int(math.Round(totalGraded)) + if completedInt < 0 { + completedInt = 0 + } - if pending > 0.5 { + if pendingInt > 0 { status := "GRADING_TELUR" - pendingInt := int(math.Round(pending)) - return &status, &pendingInt + return &status, &pendingInt, &completedInt } status := "GRADING_SELESAI" zero := 0 - return &status, &zero + return &status, &zero, &completedInt +} + +func filterGoodEggs(eggs []entity.RecordingEgg) []entity.RecordingEgg { + if len(eggs) == 0 { + return nil + } + + result := make([]entity.RecordingEgg, 0, len(eggs)) + for _, egg := range eggs { + if egg.ProductWarehouseId == goodEggProductWarehouseID { + result = append(result, egg) + } + } + return result } func defaultRecordingLatestApproval(e entity.Recording) approvalDTO.ApprovalBaseDTO { From 6ab6ee80702c3cad77c8858d4251c9f86a0b349f Mon Sep 17 00:00:00 2001 From: ragilap Date: Fri, 31 Oct 2025 19:15:24 +0700 Subject: [PATCH 09/10] feat/BE/US-76/US-78/US-79/TASK-112,120,133,121-Recording growing/TASK-187,189,202,190-Recording Laying/TASK-191,192,194,197,203-Grading Telur --- .../services/projectflock.service.go | 1 - .../recordings/services/recording.service.go | 706 ++++++++---------- .../validations/recording.validation.go | 10 +- internal/utils/recording/util.recording.go | 14 +- 4 files changed, 346 insertions(+), 385 deletions(-) diff --git a/internal/modules/production/project_flocks/services/projectflock.service.go b/internal/modules/production/project_flocks/services/projectflock.service.go index 7224a691..ee18f0d8 100644 --- a/internal/modules/production/project_flocks/services/projectflock.service.go +++ b/internal/modules/production/project_flocks/services/projectflock.service.go @@ -208,7 +208,6 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (* } createBody := &entity.ProjectFlock{ - FlockName: "", AreaId: req.AreaId, Category: cat, FcrId: req.FcrId, diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index ee2670db..e8836590 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -170,104 +170,90 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent return nil, err } - tx := s.Repository.DB().WithContext(ctx).Begin() - if tx.Error != nil { - s.Log.Errorf("Failed to start recording transaction: %+v", tx.Error) - return nil, tx.Error - } - defer func() { - if r := recover(); r != nil { - _ = tx.Rollback() - panic(r) + var createdRecording entity.Recording + transactionErr := s.Repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + nextDay, err := s.Repository.GenerateNextDay(tx, req.ProjectFlockKandangId) + if err != nil { + s.Log.Errorf("Failed to determine recording day: %+v", err) + return err } - }() - nextDay, err := s.Repository.GenerateNextDay(tx, req.ProjectFlockKandangId) - if err != nil { - _ = tx.Rollback() - s.Log.Errorf("Failed to determine recording day: %+v", err) - return nil, err - } - - recordTime := time.Now().UTC() - - existsToday, err := s.Repository.ExistsOnDate(ctx, req.ProjectFlockKandangId, recordTime) - if err != nil { - _ = tx.Rollback() - s.Log.Errorf("Failed to verify existing recording on date: %+v", err) - return nil, err - } - if existsToday { - _ = tx.Rollback() - return nil, fiber.NewError(fiber.StatusBadRequest, "Recording for this project flock today already exists") - } - - recording := &entity.Recording{ - ProjectFlockKandangId: req.ProjectFlockKandangId, - RecordDatetime: recordTime, - Day: &nextDay, - CreatedBy: 1, // TODO: replace with authenticated user - } - - if err := s.Repository.CreateOne(ctx, recording, func(*gorm.DB) *gorm.DB { return tx }); err != nil { - _ = tx.Rollback() - if errors.Is(err, gorm.ErrDuplicatedKey) { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Recording for project flock kandang %d already exists", req.ProjectFlockKandangId)) + recordTime := time.Now().UTC() + existsToday, err := s.Repository.ExistsOnDate(ctx, req.ProjectFlockKandangId, recordTime) + if err != nil { + s.Log.Errorf("Failed to verify existing recording on date: %+v", err) + return err } - s.Log.Errorf("Failed to create recording: %+v", err) - return nil, err + if existsToday { + return fiber.NewError(fiber.StatusBadRequest, "Recording for this project flock today already exists") + } + + day := nextDay + createdRecording = entity.Recording{ + ProjectFlockKandangId: req.ProjectFlockKandangId, + RecordDatetime: recordTime, + Day: &day, + CreatedBy: 1, // TODO: replace with authenticated user + } + + if err := s.Repository.CreateOne(ctx, &createdRecording, func(*gorm.DB) *gorm.DB { return tx }); err != nil { + if errors.Is(err, gorm.ErrDuplicatedKey) { + return fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Recording for project flock kandang %d already exists", req.ProjectFlockKandangId), + ) + } + s.Log.Errorf("Failed to create recording: %+v", err) + return err + } + + mappedBodyWeights := recordingutil.MapBodyWeights(createdRecording.Id, req.BodyWeights) + if err := s.Repository.CreateBodyWeights(tx, mappedBodyWeights); err != nil { + s.Log.Errorf("Failed to persist body weights: %+v", err) + return err + } + + mappedStocks := recordingutil.MapStocks(createdRecording.Id, req.Stocks) + if err := s.Repository.CreateStocks(tx, mappedStocks); err != nil { + s.Log.Errorf("Failed to persist stocks: %+v", err) + return err + } + + mappedDepletions := recordingutil.MapDepletions(createdRecording.Id, req.Depletions) + if err := s.Repository.CreateDepletions(tx, mappedDepletions); err != nil { + s.Log.Errorf("Failed to persist depletions: %+v", err) + return err + } + + mappedEggs := recordingutil.MapEggs(createdRecording.Id, createdRecording.CreatedBy, req.Eggs) + if err := s.Repository.CreateEggs(tx, mappedEggs); err != nil { + s.Log.Errorf("Failed to persist eggs: %+v", err) + return err + } + + if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, mappedDepletions, nil, mappedStocks, nil, mappedEggs)); err != nil { + s.Log.Errorf("Failed to adjust product warehouses: %+v", err) + return err + } + + if err := s.computeAndUpdateMetrics(ctx, tx, &createdRecording); err != nil { + s.Log.Errorf("Failed to compute recording metrics: %+v", err) + return err + } + + action := entity.ApprovalActionCreated + if err := s.createRecordingApproval(ctx, tx, createdRecording.Id, utils.RecordingStepGradingTelur, action, createdRecording.CreatedBy, nil); err != nil { + s.Log.Errorf("Failed to create recording approval for %d: %+v", createdRecording.Id, err) + return err + } + + return nil + }) + if transactionErr != nil { + return nil, transactionErr } - mappedBodyWeights := recordingutil.MapBodyWeights(recording.Id, req.BodyWeights) - if err := s.Repository.CreateBodyWeights(tx, mappedBodyWeights); err != nil { - _ = tx.Rollback() - s.Log.Errorf("Failed to persist body weights: %+v", err) - return nil, err - } - mappedStocks := recordingutil.MapStocks(recording.Id, req.Stocks) - if err := s.Repository.CreateStocks(tx, mappedStocks); err != nil { - _ = tx.Rollback() - s.Log.Errorf("Failed to persist stocks: %+v", err) - return nil, err - } - mappedDepletions := recordingutil.MapDepletions(recording.Id, req.Depletions) - if err := s.Repository.CreateDepletions(tx, mappedDepletions); err != nil { - _ = tx.Rollback() - s.Log.Errorf("Failed to persist depletions: %+v", err) - return nil, err - } - mappedEggs := recordingutil.MapEggs(recording.Id, recording.CreatedBy, req.Eggs) - if err := s.Repository.CreateEggs(tx, mappedEggs); err != nil { - _ = tx.Rollback() - s.Log.Errorf("Failed to persist eggs: %+v", err) - return nil, err - } - - if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, mappedDepletions, nil, mappedStocks, nil, mappedEggs)); err != nil { - _ = tx.Rollback() - s.Log.Errorf("Failed to adjust product warehouses: %+v", err) - return nil, err - } - - if err := s.computeAndUpdateMetrics(ctx, tx, recording); err != nil { - _ = tx.Rollback() - s.Log.Errorf("Failed to compute recording metrics: %+v", err) - return nil, err - } - - action := entity.ApprovalActionCreated - if err := s.createRecordingApproval(ctx, tx, recording.Id, utils.RecordingStepGradingTelur, action, recording.CreatedBy, nil); err != nil { - _ = tx.Rollback() - s.Log.Errorf("Failed to create recording approval for %d: %+v", recording.Id, err) - return nil, err - } - - if err := tx.Commit().Error; err != nil { - s.Log.Errorf("Failed to commit recording transaction: %+v", err) - return nil, err - } - - return s.GetOne(c, recording.Id) + return s.GetOne(c, createdRecording.Id) } func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Recording, error) { @@ -277,165 +263,146 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin ctx := c.Context() - tx := s.Repository.DB().WithContext(ctx).Begin() - if tx.Error != nil { - s.Log.Errorf("Failed to start recording transaction: %+v", tx.Error) - return nil, tx.Error - } - defer func() { - if r := recover(); r != nil { - _ = tx.Rollback() - panic(r) + var recordingEntity *entity.Recording + transactionErr := s.Repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + recording, err := s.Repository.GetByID(ctx, id, func(db *gorm.DB) *gorm.DB { + return s.Repository.WithRelations(tx) + }) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Recording not found") + } + s.Log.Errorf("Failed to find recording: %+v", err) + return err } - }() + recordingEntity = recording - recording, err := s.Repository.GetByID(ctx, id, func(db *gorm.DB) *gorm.DB { - return s.Repository.WithRelations(tx) + var category string + if recordingEntity.ProjectFlockKandang != nil && recordingEntity.ProjectFlockKandang.ProjectFlock.Id != 0 { + category = strings.ToUpper(recordingEntity.ProjectFlockKandang.ProjectFlock.Category) + } + isLaying := category == strings.ToUpper(string(utils.ProjectFlockCategoryLaying)) + if req.Eggs != nil { + if !isLaying && len(req.Eggs) > 0 { + return fiber.NewError(fiber.StatusBadRequest, "Egg details permitted only for laying project flocks") + } + if isLaying && len(req.Eggs) == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Egg details are required for laying project flocks") + } + } + + if req.BodyWeights != nil { + if err := s.Repository.DeleteBodyWeights(tx, recordingEntity.Id); err != nil { + s.Log.Errorf("Failed to clear body weights: %+v", err) + return err + } + if err := s.Repository.CreateBodyWeights(tx, recordingutil.MapBodyWeights(recordingEntity.Id, req.BodyWeights)); err != nil { + s.Log.Errorf("Failed to update body weights: %+v", err) + return err + } + } + + if req.Stocks != nil { + if err := s.ensureProductWarehousesExist(c, req.Stocks, nil, nil); err != nil { + return err + } + + existingStocks, err := s.Repository.ListStocks(tx, recordingEntity.Id) + if err != nil { + s.Log.Errorf("Failed to list existing stocks: %+v", err) + return err + } + + if err := s.Repository.DeleteStocks(tx, recordingEntity.Id); err != nil { + s.Log.Errorf("Failed to clear stocks: %+v", err) + return err + } + + mappedStocks := recordingutil.MapStocks(recordingEntity.Id, req.Stocks) + if err := s.Repository.CreateStocks(tx, mappedStocks); err != nil { + s.Log.Errorf("Failed to update stocks: %+v", err) + return err + } + + if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, nil, existingStocks, mappedStocks, nil, nil)); err != nil { + s.Log.Errorf("Failed to adjust product warehouses for stocks: %+v", err) + return err + } + } + + if req.Eggs != nil && req.Depletions == nil { + if err := s.ensureProductWarehousesExist(c, nil, nil, req.Eggs); err != nil { + return err + } + } + + if req.Depletions != nil { + if err := s.ensureProductWarehousesExist(c, nil, req.Depletions, req.Eggs); err != nil { + return err + } + + existingDepletions, err := s.Repository.ListDepletions(tx, recordingEntity.Id) + if err != nil { + s.Log.Errorf("Failed to list existing depletions: %+v", err) + return err + } + + if err := s.Repository.DeleteDepletions(tx, recordingEntity.Id); err != nil { + s.Log.Errorf("Failed to clear depletions: %+v", err) + return err + } + + mappedDepletions := recordingutil.MapDepletions(recordingEntity.Id, req.Depletions) + if err := s.Repository.CreateDepletions(tx, mappedDepletions); err != nil { + s.Log.Errorf("Failed to update depletions: %+v", err) + return err + } + + if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(existingDepletions, mappedDepletions, nil, nil, nil, nil)); err != nil { + s.Log.Errorf("Failed to adjust product warehouses for depletions: %+v", err) + return err + } + } + + if req.Eggs != nil { + existingEggs, err := s.Repository.ListEggs(tx, recordingEntity.Id) + if err != nil { + s.Log.Errorf("Failed to list existing eggs: %+v", err) + return err + } + + if err := s.Repository.DeleteEggs(tx, recordingEntity.Id); err != nil { + s.Log.Errorf("Failed to clear eggs: %+v", err) + return err + } + + mappedEggs := recordingutil.MapEggs(recordingEntity.Id, recordingEntity.CreatedBy, req.Eggs) + if err := s.Repository.CreateEggs(tx, mappedEggs); err != nil { + s.Log.Errorf("Failed to update eggs: %+v", err) + return err + } + + if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, nil, nil, nil, existingEggs, mappedEggs)); err != nil { + s.Log.Errorf("Failed to adjust product warehouses for eggs: %+v", err) + return err + } + } + + if err := s.computeAndUpdateMetrics(ctx, tx, recordingEntity); err != nil { + s.Log.Errorf("Failed to recompute recording metrics: %+v", err) + return err + } + + action := entity.ApprovalActionUpdated + if err := s.createRecordingApproval(ctx, tx, recordingEntity.Id, utils.RecordingStepPengajuan, action, recordingEntity.CreatedBy, nil); err != nil { + s.Log.Errorf("Failed to create approval after recording update %d: %+v", recordingEntity.Id, err) + return err + } + + return nil }) - if err != nil { - _ = tx.Rollback() - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Recording not found") - } - s.Log.Errorf("Failed to find recording: %+v", err) - return nil, err - } - recordingEntity := recording - - var category string - if recordingEntity.ProjectFlockKandang != nil && recordingEntity.ProjectFlockKandang.ProjectFlock.Id != 0 { - category = strings.ToUpper(recordingEntity.ProjectFlockKandang.ProjectFlock.Category) - } - isLaying := category == strings.ToUpper(string(utils.ProjectFlockCategoryLaying)) - if req.Eggs != nil { - if !isLaying && len(req.Eggs) > 0 { - _ = tx.Rollback() - return nil, fiber.NewError(fiber.StatusBadRequest, "Egg details permitted only for laying project flocks") - } - if isLaying && len(req.Eggs) == 0 { - _ = tx.Rollback() - return nil, fiber.NewError(fiber.StatusBadRequest, "Egg details are required for laying project flocks") - } - } - - if req.BodyWeights != nil { - if err := s.Repository.DeleteBodyWeights(tx, recordingEntity.Id); err != nil { - _ = tx.Rollback() - s.Log.Errorf("Failed to clear body weights: %+v", err) - return nil, err - } - if err := s.Repository.CreateBodyWeights(tx, recordingutil.MapBodyWeights(recordingEntity.Id, req.BodyWeights)); err != nil { - _ = tx.Rollback() - s.Log.Errorf("Failed to update body weights: %+v", err) - return nil, err - } - } - if req.Stocks != nil { - if err := s.ensureProductWarehousesExist(c, req.Stocks, nil, nil); err != nil { - _ = tx.Rollback() - return nil, err - } - existingStocks, err := s.Repository.ListStocks(tx, recordingEntity.Id) - if err != nil { - _ = tx.Rollback() - s.Log.Errorf("Failed to list existing stocks: %+v", err) - return nil, err - } - - if err := s.Repository.DeleteStocks(tx, recordingEntity.Id); err != nil { - _ = tx.Rollback() - s.Log.Errorf("Failed to clear stocks: %+v", err) - return nil, err - } - mappedStocks := recordingutil.MapStocks(recordingEntity.Id, req.Stocks) - if err := s.Repository.CreateStocks(tx, mappedStocks); err != nil { - _ = tx.Rollback() - s.Log.Errorf("Failed to update stocks: %+v", err) - return nil, err - } - if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, nil, existingStocks, mappedStocks, nil, nil)); err != nil { - _ = tx.Rollback() - s.Log.Errorf("Failed to adjust product warehouses for stocks: %+v", err) - return nil, err - } - } - if req.Eggs != nil && req.Depletions == nil { - if err := s.ensureProductWarehousesExist(c, nil, nil, req.Eggs); err != nil { - _ = tx.Rollback() - return nil, err - } - } - var existingDepletions []entity.RecordingDepletion - if req.Depletions != nil { - if err := s.ensureProductWarehousesExist(c, nil, req.Depletions, req.Eggs); err != nil { - _ = tx.Rollback() - return nil, err - } - var err error - existingDepletions, err = s.Repository.ListDepletions(tx, recordingEntity.Id) - if err != nil { - _ = tx.Rollback() - s.Log.Errorf("Failed to list existing depletions: %+v", err) - return nil, err - } - if err := s.Repository.DeleteDepletions(tx, recordingEntity.Id); err != nil { - _ = tx.Rollback() - s.Log.Errorf("Failed to clear depletions: %+v", err) - return nil, err - } - mappedDepletions := recordingutil.MapDepletions(recordingEntity.Id, req.Depletions) - if err := s.Repository.CreateDepletions(tx, mappedDepletions); err != nil { - _ = tx.Rollback() - s.Log.Errorf("Failed to update depletions: %+v", err) - return nil, err - } - if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(existingDepletions, mappedDepletions, nil, nil, nil, nil)); err != nil { - _ = tx.Rollback() - s.Log.Errorf("Failed to adjust product warehouses for depletions: %+v", err) - return nil, err - } - } - if req.Eggs != nil { - existingEggs, err := s.Repository.ListEggs(tx, recordingEntity.Id) - if err != nil { - _ = tx.Rollback() - s.Log.Errorf("Failed to list existing eggs: %+v", err) - return nil, err - } - if err := s.Repository.DeleteEggs(tx, recordingEntity.Id); err != nil { - _ = tx.Rollback() - s.Log.Errorf("Failed to clear eggs: %+v", err) - return nil, err - } - mappedEggs := recordingutil.MapEggs(recordingEntity.Id, recordingEntity.CreatedBy, req.Eggs) - if err := s.Repository.CreateEggs(tx, mappedEggs); err != nil { - _ = tx.Rollback() - s.Log.Errorf("Failed to update eggs: %+v", err) - return nil, err - } - if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, nil, nil, nil, existingEggs, mappedEggs)); err != nil { - _ = tx.Rollback() - s.Log.Errorf("Failed to adjust product warehouses for eggs: %+v", err) - return nil, err - } - } - - if err := s.computeAndUpdateMetrics(ctx, tx, recordingEntity); err != nil { - _ = tx.Rollback() - s.Log.Errorf("Failed to recompute recording metrics: %+v", err) - return nil, err - } - - action := entity.ApprovalActionUpdated - if err := s.createRecordingApproval(ctx, tx, recordingEntity.Id, utils.RecordingStepPengajuan, action, recordingEntity.CreatedBy, nil); err != nil { - _ = tx.Rollback() - s.Log.Errorf("Failed to create approval after recording update %d: %+v", recordingEntity.Id, err) - return nil, err - } - - if err := tx.Commit().Error; err != nil { - s.Log.Errorf("Failed to commit recording transaction: %+v", err) - return nil, err + if transactionErr != nil { + return nil, transactionErr } return s.GetOne(c, id) @@ -446,107 +413,102 @@ func (s *recordingService) SubmitGrading(c *fiber.Ctx, req *validation.SubmitGra return nil, err } - ctx := c.Context() - tx := s.Repository.DB().WithContext(ctx).Begin() - if tx.Error != nil { - s.Log.Errorf("Failed to start grading transaction: %+v", tx.Error) - return nil, tx.Error + if len(req.EggsGrading) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "eggs_grading must contain at least one item") } - defer func() { - if r := recover(); r != nil { - _ = tx.Rollback() - panic(r) + + recordingEggID := req.EggsGrading[0].RecordingEggId + for _, grading := range req.EggsGrading[1:] { + if grading.RecordingEggId != recordingEggID { + return nil, fiber.NewError(fiber.StatusBadRequest, "semua grading harus untuk recording egg yang sama") } - }() - - recordingEgg, err := s.Repository.GetRecordingEggByID(ctx, req.RecordingEggId, func(db *gorm.DB) *gorm.DB { - return tx - }) - if errors.Is(err, gorm.ErrRecordNotFound) { - _ = tx.Rollback() - return nil, fiber.NewError(fiber.StatusNotFound, "Recording egg not found") - } - if err != nil { - _ = tx.Rollback() - s.Log.Errorf("Failed to get recording egg %d: %+v", req.RecordingEggId, err) - return nil, err } - var category string - if recordingEgg.Recording.ProjectFlockKandang != nil && recordingEgg.Recording.ProjectFlockKandang.ProjectFlock.Id != 0 { - category = strings.ToUpper(recordingEgg.Recording.ProjectFlockKandang.ProjectFlock.Category) - } - if category != strings.ToUpper(string(utils.ProjectFlockCategoryLaying)) { - _ = tx.Rollback() - return nil, fiber.NewError(fiber.StatusBadRequest, "Grading eggs hanya diperbolehkan pada project flock dengan kategori laying") - } + ctx := c.Context() + var recordingID uint + transactionErr := s.Repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + recordingEgg, err := s.Repository.GetRecordingEggByID(ctx, recordingEggID, func(db *gorm.DB) *gorm.DB { + return tx + }) + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Recording egg not found") + } + if err != nil { + s.Log.Errorf("Failed to get recording egg %d: %+v", recordingEggID, err) + return err + } - totalGradingQty := 0.0 - for _, grading := range req.EggsGrading { - totalGradingQty += grading.Qty - } + var category string + if recordingEgg.Recording.ProjectFlockKandang != nil && recordingEgg.Recording.ProjectFlockKandang.ProjectFlock.Id != 0 { + category = strings.ToUpper(recordingEgg.Recording.ProjectFlockKandang.ProjectFlock.Category) + } + if category != strings.ToUpper(string(utils.ProjectFlockCategoryLaying)) { + return fiber.NewError(fiber.StatusBadRequest, "Grading eggs hanya diperbolehkan pada project flock dengan kategori laying") + } - availableRecorded := float64(recordingEgg.Qty) - if totalGradingQty > availableRecorded { - _ = tx.Rollback() - return nil, fiber.NewError( - fiber.StatusBadRequest, - fmt.Sprintf("Total grading (%.2f) melebihi jumlah telur tercatat (%.2f)", totalGradingQty, availableRecorded), - ) - } + totalGradingQty := 0.0 + for _, grading := range req.EggsGrading { + totalGradingQty += grading.Qty + } - if recordingEgg.ProductWarehouse.Id != 0 { - availableWarehouse := recordingEgg.ProductWarehouse.Quantity - if totalGradingQty > availableWarehouse { - _ = tx.Rollback() - return nil, fiber.NewError( + availableRecorded := float64(recordingEgg.Qty) + if totalGradingQty > availableRecorded { + return fiber.NewError( fiber.StatusBadRequest, - fmt.Sprintf("Total grading (%.2f) melebihi stok telur baik (%.2f)", totalGradingQty, availableWarehouse), + fmt.Sprintf("Total grading (%.2f) melebihi jumlah telur tercatat (%.2f)", totalGradingQty, availableRecorded), ) } - } - if err := s.Repository.DeleteGradingEggs(tx, recordingEgg.Id); err != nil { - _ = tx.Rollback() - s.Log.Errorf("Failed to clear grading eggs for recording egg %d: %+v", recordingEgg.Id, err) - return nil, err - } - - gradings := make([]entity.GradingEgg, 0, len(req.EggsGrading)) - createdBy := recordingEgg.CreatedBy - if createdBy == 0 { - createdBy = recordingEgg.Recording.CreatedBy - } - for _, item := range req.EggsGrading { - gradings = append(gradings, entity.GradingEgg{ - RecordingEggId: recordingEgg.Id, - Grade: strings.TrimSpace(item.Grade), - Qty: item.Qty, - CreatedBy: createdBy, - }) - } - - if len(gradings) > 0 { - if err := s.Repository.CreateGradingEggs(tx, gradings); err != nil { - _ = tx.Rollback() - s.Log.Errorf("Failed to persist grading eggs for recording egg %d: %+v", recordingEgg.Id, err) - return nil, err + if recordingEgg.ProductWarehouse.Id != 0 { + availableWarehouse := recordingEgg.ProductWarehouse.Quantity + if totalGradingQty > availableWarehouse { + return fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Total grading (%.2f) melebihi stok telur baik (%.2f)", totalGradingQty, availableWarehouse), + ) + } } + + if err := s.Repository.DeleteGradingEggs(tx, recordingEgg.Id); err != nil { + s.Log.Errorf("Failed to clear grading eggs for recording egg %d: %+v", recordingEgg.Id, err) + return err + } + + gradings := make([]entity.GradingEgg, 0, len(req.EggsGrading)) + createdBy := recordingEgg.CreatedBy + if createdBy == 0 { + createdBy = recordingEgg.Recording.CreatedBy + } + for _, item := range req.EggsGrading { + gradings = append(gradings, entity.GradingEgg{ + RecordingEggId: recordingEgg.Id, + Grade: strings.TrimSpace(item.Grade), + Qty: item.Qty, + CreatedBy: createdBy, + }) + } + + if len(gradings) > 0 { + if err := s.Repository.CreateGradingEggs(tx, gradings); err != nil { + s.Log.Errorf("Failed to persist grading eggs for recording egg %d: %+v", recordingEgg.Id, err) + return err + } + } + + action := entity.ApprovalActionUpdated + if err := s.createRecordingApproval(ctx, tx, recordingEgg.RecordingId, utils.RecordingStepPengajuan, action, createdBy, nil); err != nil { + s.Log.Errorf("Failed to create approval after grading for recording %d: %+v", recordingEgg.RecordingId, err) + return err + } + + recordingID = recordingEgg.RecordingId + return nil + }) + if transactionErr != nil { + return nil, transactionErr } - action := entity.ApprovalActionUpdated - if err := s.createRecordingApproval(ctx, tx, recordingEgg.RecordingId, utils.RecordingStepPengajuan, action, createdBy, nil); err != nil { - _ = tx.Rollback() - s.Log.Errorf("Failed to create approval after grading for recording %d: %+v", recordingEgg.RecordingId, err) - return nil, err - } - - if err := tx.Commit().Error; err != nil { - s.Log.Errorf("Failed to commit grading transaction: %+v", err) - return nil, err - } - - return s.GetOne(c, recordingEgg.RecordingId) + return s.GetOne(c, recordingID) } func (s recordingService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entity.Recording, error) { @@ -629,49 +591,39 @@ func (s recordingService) Approval(c *fiber.Ctx, req *validation.Approve) ([]ent func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error { ctx := c.Context() - tx := s.Repository.DB().WithContext(ctx).Begin() - if tx.Error != nil { - return tx.Error - } - - oldDepletions, err := s.Repository.ListDepletions(tx, id) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - _ = tx.Rollback() - s.Log.Errorf("Failed to list depletions before delete: %+v", err) - return err - } - oldEggs, err := s.Repository.ListEggs(tx, id) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - _ = tx.Rollback() - s.Log.Errorf("Failed to list eggs before delete: %+v", err) - return err - } - oldStocks, err := s.Repository.ListStocks(tx, id) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - _ = tx.Rollback() - s.Log.Errorf("Failed to list stocks before delete: %+v", err) - return err - } - - if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(oldDepletions, nil, oldStocks, nil, oldEggs, nil)); err != nil { - _ = tx.Rollback() - return err - } - - if err := s.Repository.WithTx(tx).DeleteOne(ctx, id); err != nil { - _ = tx.Rollback() - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, "Recording not found") + return s.Repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + oldDepletions, err := s.Repository.ListDepletions(tx, id) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Failed to list depletions before delete: %+v", err) + return err } - s.Log.Errorf("Failed to delete recording: %+v", err) - return err - } - if err := tx.Commit().Error; err != nil { - s.Log.Errorf("Failed to commit delete recording transaction: %+v", err) - return err - } - return nil + oldEggs, err := s.Repository.ListEggs(tx, id) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Failed to list eggs before delete: %+v", err) + return err + } + + oldStocks, err := s.Repository.ListStocks(tx, id) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Failed to list stocks before delete: %+v", err) + return err + } + + if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(oldDepletions, nil, oldStocks, nil, oldEggs, nil)); err != nil { + return err + } + + if err := s.Repository.WithTx(tx).DeleteOne(ctx, id); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Recording not found") + } + s.Log.Errorf("Failed to delete recording: %+v", err) + return err + } + + return nil + }) } // === Persistence Helpers === diff --git a/internal/modules/production/recordings/validations/recording.validation.go b/internal/modules/production/recordings/validations/recording.validation.go index d760c0ba..f058248c 100644 --- a/internal/modules/production/recordings/validations/recording.validation.go +++ b/internal/modules/production/recordings/validations/recording.validation.go @@ -9,7 +9,7 @@ type ( Stock struct { ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,number,min=1"` - UsageAmount *float64 `json:"usage_amount,omitempty" validate:"omitempty,gte=0"` + Qty *float64 `json:"qty,omitempty" validate:"required_without=UsageAmount,gte=0"` PendingQty *float64 `json:"pending_qty,omitempty" validate:"omitempty,gte=0"` } @@ -46,13 +46,13 @@ type Query struct { } type EggGrading struct { - Grade string `json:"grade" validate:"required"` - Qty float64 `json:"qty" validate:"required,gte=0"` + RecordingEggId uint `json:"recording_egg_id" validate:"required,number,min=1"` + Grade string `json:"grade" validate:"required"` + Qty float64 `json:"qty" validate:"required,gte=0"` } type SubmitGrading struct { - RecordingEggId uint `json:"recording_egg_id" validate:"required,number,min=1"` - EggsGrading []EggGrading `json:"eggs_grading" validate:"required,dive"` + EggsGrading []EggGrading `json:"eggs_grading" validate:"required,dive"` } type Approve struct { diff --git a/internal/utils/recording/util.recording.go b/internal/utils/recording/util.recording.go index e5467aaf..fd463cf9 100644 --- a/internal/utils/recording/util.recording.go +++ b/internal/utils/recording/util.recording.go @@ -35,11 +35,21 @@ func MapStocks(recordingID uint, items []validation.Stock) []entity.RecordingSto result := make([]entity.RecordingStock, 0, len(items)) for _, item := range items { + var usageAmount float64 + if item.Qty != nil { + usageAmount = *item.Qty + } + usagePtr := new(float64) + *usagePtr = usageAmount + pending := item.PendingQty + if pending == nil { + pending = new(float64) + } result = append(result, entity.RecordingStock{ RecordingId: recordingID, ProductWarehouseId: item.ProductWarehouseId, - UsageQty: item.UsageAmount, - PendingQty: item.PendingQty, + UsageQty: usagePtr, + PendingQty: pending, }) } return result From f5c04413372a3e8e0ccd87b26a22ee2541ef25b2 Mon Sep 17 00:00:00 2001 From: ragil adi prasetio Date: Mon, 3 Nov 2025 04:14:15 +0000 Subject: [PATCH 10/10] Fix:delete relation dto in flock in project_flock --- .../project_flocks/dto/projectflock.dto.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/internal/modules/production/project_flocks/dto/projectflock.dto.go b/internal/modules/production/project_flocks/dto/projectflock.dto.go index 3929d7f8..bfadf3e2 100644 --- a/internal/modules/production/project_flocks/dto/projectflock.dto.go +++ b/internal/modules/production/project_flocks/dto/projectflock.dto.go @@ -10,7 +10,7 @@ import ( flockDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/flocks/dto" kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto" locationDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/locations/dto" - pfutils "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/utils" + // pfutils "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/utils" userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" "gitlab.com/mbugroup/lti-api.git/internal/utils" approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" @@ -24,7 +24,7 @@ type ProjectFlockBaseDTO struct { type ProjectFlockListDTO struct { ProjectFlockBaseDTO - Flock *flockDTO.FlockBaseDTO `json:"flock,omitempty"` + // Flock *flockDTO.FlockBaseDTO `json:"flock,omitempty"` Area *areaDTO.AreaBaseDTO `json:"area,omitempty"` Category string `json:"category"` Fcr *fcrDTO.FcrBaseDTO `json:"fcr,omitempty"` @@ -60,11 +60,11 @@ func ToProjectFlockListDTO(e entity.ProjectFlock) ProjectFlockListDTO { } } - var flockSummary *flockDTO.FlockBaseDTO - if baseName := pfutils.DeriveBaseName(e.FlockName); baseName != "" { - summary := flockDTO.FlockBaseDTO{Id: 0, Name: baseName} - flockSummary = &summary - } + // var flockSummary *flockDTO.FlockBaseDTO + // if baseName := pfutils.DeriveBaseName(e.FlockName); baseName != "" { + // summary := flockDTO.FlockBaseDTO{Id: 0, Name: baseName} + // flockSummary = &summary + // } var areaSummary *areaDTO.AreaBaseDTO if e.Area.Id != 0 { @@ -92,7 +92,7 @@ func ToProjectFlockListDTO(e entity.ProjectFlock) ProjectFlockListDTO { return ProjectFlockListDTO{ ProjectFlockBaseDTO: createProjectFlockBaseDTO(e), - Flock: flockSummary, + // Flock: flockSummary, Area: areaSummary, Kandangs: kandangSummaries, Category: e.Category,