From 8fab5d7d913b5a3751172f1e8eae53c3e63c318b Mon Sep 17 00:00:00 2001 From: "Hafizh A. Y" Date: Mon, 12 Jan 2026 14:54:14 +0700 Subject: [PATCH 01/15] feat(BE): price-product-supplier --- ...016_add_price_to_product_supplier.down.sql | 6 ++ ...73016_add_price_to_product_supplier.up.sql | 6 ++ internal/entities/nonstock_supplier.go | 1 + internal/entities/product_supplier.go | 1 + .../master/nonstocks/dto/nonstock.dto.go | 27 ++++++--- .../repositories/nonstock.repository.go | 33 +++++++---- .../nonstocks/services/nonstock.service.go | 55 +++++++++++++----- .../validations/nonstock.validation.go | 9 ++- .../master/products/dto/product.dto.go | 29 +++++++--- .../repositories/product.repository.go | 33 +++++++---- .../products/services/product.service.go | 58 +++++++++++++------ .../validations/product.validation.go | 9 ++- .../suppliers/dto/supplier_nonstock.dto.go | 2 + .../suppliers/dto/supplier_product.dto.go | 26 +++++---- 14 files changed, 212 insertions(+), 83 deletions(-) create mode 100644 internal/database/migrations/20260112073016_add_price_to_product_supplier.down.sql create mode 100644 internal/database/migrations/20260112073016_add_price_to_product_supplier.up.sql diff --git a/internal/database/migrations/20260112073016_add_price_to_product_supplier.down.sql b/internal/database/migrations/20260112073016_add_price_to_product_supplier.down.sql new file mode 100644 index 00000000..0acbcbe2 --- /dev/null +++ b/internal/database/migrations/20260112073016_add_price_to_product_supplier.down.sql @@ -0,0 +1,6 @@ +-- Rollback: remove price from supplier relations +ALTER TABLE product_suppliers + DROP COLUMN IF EXISTS price; + +ALTER TABLE nonstock_suppliers + DROP COLUMN IF EXISTS price; diff --git a/internal/database/migrations/20260112073016_add_price_to_product_supplier.up.sql b/internal/database/migrations/20260112073016_add_price_to_product_supplier.up.sql new file mode 100644 index 00000000..b96abf64 --- /dev/null +++ b/internal/database/migrations/20260112073016_add_price_to_product_supplier.up.sql @@ -0,0 +1,6 @@ +-- Migration: add price to supplier relations +ALTER TABLE product_suppliers + ADD COLUMN IF NOT EXISTS price NUMERIC(15, 3) NOT NULL DEFAULT 0; + +ALTER TABLE nonstock_suppliers + ADD COLUMN IF NOT EXISTS price NUMERIC(15, 3) NOT NULL DEFAULT 0; diff --git a/internal/entities/nonstock_supplier.go b/internal/entities/nonstock_supplier.go index 2206390c..d666e3c8 100644 --- a/internal/entities/nonstock_supplier.go +++ b/internal/entities/nonstock_supplier.go @@ -5,6 +5,7 @@ import "time" type NonstockSupplier struct { NonstockId uint `gorm:"not null"` SupplierId uint `gorm:"not null"` + Price float64 `gorm:"type:numeric(15,3);not null;default:0"` CreatedAt time.Time `gorm:"autoCreateTime"` Nonstock Nonstock `gorm:"foreignKey:NonstockId;references:Id"` diff --git a/internal/entities/product_supplier.go b/internal/entities/product_supplier.go index d64b1e85..9b9aa67c 100644 --- a/internal/entities/product_supplier.go +++ b/internal/entities/product_supplier.go @@ -5,6 +5,7 @@ import "time" type ProductSupplier struct { ProductId uint `gorm:"not null"` SupplierId uint `gorm:"not null"` + Price float64 `gorm:"type:numeric(15,3);not null;default:0"` CreatedAt time.Time `gorm:"autoCreateTime"` Product Product `gorm:"foreignKey:ProductId;references:Id"` diff --git a/internal/modules/master/nonstocks/dto/nonstock.dto.go b/internal/modules/master/nonstocks/dto/nonstock.dto.go index 9954ee76..fa102b9c 100644 --- a/internal/modules/master/nonstocks/dto/nonstock.dto.go +++ b/internal/modules/master/nonstocks/dto/nonstock.dto.go @@ -4,7 +4,6 @@ import ( "time" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" - supplierDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/dto" uomDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/uoms/dto" userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" ) @@ -23,7 +22,7 @@ type NonstockListDTO struct { Name string `json:"name"` Flags []string `json:"flags"` Uom *uomDTO.UomRelationDTO `json:"uom"` - Suppliers []supplierDTO.SupplierRelationDTO `json:"suppliers"` + Suppliers []NonstockSupplierDTO `json:"suppliers"` CreatedUser *userDTO.UserRelationDTO `json:"created_user"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -33,6 +32,14 @@ type NonstockDetailDTO struct { NonstockListDTO } +type NonstockSupplierDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + Alias string `json:"alias"` + Category string `json:"category"` + Price float64 `json:"price"` +} + // === Mapper Functions === func ToNonstockRelationDTO(e entity.Nonstock) NonstockRelationDTO { @@ -99,21 +106,27 @@ func ToNonstockDetailDTO(e entity.Nonstock) NonstockDetailDTO { } } -func toNonstockSupplierDTOs(relations []entity.NonstockSupplier) []supplierDTO.SupplierRelationDTO { +func toNonstockSupplierDTOs(relations []entity.NonstockSupplier) []NonstockSupplierDTO { if len(relations) == 0 { - return make([]supplierDTO.SupplierRelationDTO, 0) + return make([]NonstockSupplierDTO, 0) } - result := make([]supplierDTO.SupplierRelationDTO, 0, len(relations)) + result := make([]NonstockSupplierDTO, 0, len(relations)) for _, relation := range relations { if relation.Supplier.Id == 0 { continue } - result = append(result, supplierDTO.ToSupplierRelationDTO(relation.Supplier)) + result = append(result, NonstockSupplierDTO{ + Id: relation.Supplier.Id, + Name: relation.Supplier.Name, + Alias: relation.Supplier.Alias, + Category: relation.Supplier.Category, + Price: relation.Price, + }) } if len(result) == 0 { - return make([]supplierDTO.SupplierRelationDTO, 0) + return make([]NonstockSupplierDTO, 0) } return result diff --git a/internal/modules/master/nonstocks/repositories/nonstock.repository.go b/internal/modules/master/nonstocks/repositories/nonstock.repository.go index aeff162f..16260272 100644 --- a/internal/modules/master/nonstocks/repositories/nonstock.repository.go +++ b/internal/modules/master/nonstocks/repositories/nonstock.repository.go @@ -12,7 +12,7 @@ import ( type NonstockRepository interface { repository.BaseRepository[entity.Nonstock] NameExists(ctx context.Context, name string, excludeID *uint) (bool, error) - SyncSuppliersDiff(ctx context.Context, tx *gorm.DB, nonstockID uint, supplierIDs []uint) error + SyncSuppliersDiff(ctx context.Context, tx *gorm.DB, nonstockID uint, suppliers []entity.NonstockSupplier) error UomExists(ctx context.Context, uomID uint) (bool, error) GetSuppliersByIDs(ctx context.Context, supplierIDs []uint) ([]entity.Supplier, error) SyncFlags(ctx context.Context, tx *gorm.DB, nonstockID uint, flags []string) error @@ -40,13 +40,13 @@ func (r *NonstockRepositoryImpl) IdExists(ctx context.Context, id uint) (bool, e return repository.Exists[entity.Nonstock](ctx, r.DB(), id) } -func (r *NonstockRepositoryImpl) SyncSuppliersDiff(ctx context.Context, tx *gorm.DB, nonstockID uint, supplierIDs []uint) error { +func (r *NonstockRepositoryImpl) SyncSuppliersDiff(ctx context.Context, tx *gorm.DB, nonstockID uint, suppliers []entity.NonstockSupplier) error { db := tx if db == nil { db = r.DB() } - if supplierIDs == nil { + if suppliers == nil { return db.WithContext(ctx). Where("nonstock_id = ?", nonstockID). Delete(&entity.NonstockSupplier{}). @@ -61,18 +61,31 @@ func (r *NonstockRepositoryImpl) SyncSuppliersDiff(ctx context.Context, tx *gorm return err } - existingMap := make(map[uint]struct{}, len(existing)) + existingMap := make(map[uint]entity.NonstockSupplier, len(existing)) for _, rel := range existing { - existingMap[rel.SupplierId] = struct{}{} + existingMap[rel.SupplierId] = rel } - incomingMap := make(map[uint]struct{}, len(supplierIDs)) - for _, id := range supplierIDs { - incomingMap[id] = struct{}{} - if _, exists := existingMap[id]; exists { + incomingMap := make(map[uint]struct{}, len(suppliers)) + for _, rel := range suppliers { + incomingMap[rel.SupplierId] = struct{}{} + if existingRel, exists := existingMap[rel.SupplierId]; exists { + if existingRel.Price != rel.Price { + if err := db.WithContext(ctx). + Model(&entity.NonstockSupplier{}). + Where("nonstock_id = ? AND supplier_id = ?", nonstockID, rel.SupplierId). + Update("price", rel.Price). + Error; err != nil { + return err + } + } continue } - record := entity.NonstockSupplier{NonstockId: nonstockID, SupplierId: id} + record := entity.NonstockSupplier{ + NonstockId: nonstockID, + SupplierId: rel.SupplierId, + Price: rel.Price, + } if err := db.WithContext(ctx).Create(&record).Error; err != nil { return err } diff --git a/internal/modules/master/nonstocks/services/nonstock.service.go b/internal/modules/master/nonstocks/services/nonstock.service.go index ad044b08..e1cc5495 100644 --- a/internal/modules/master/nonstocks/services/nonstock.service.go +++ b/internal/modules/master/nonstocks/services/nonstock.service.go @@ -111,8 +111,25 @@ func (s *nonstockService) CreateOne(c *fiber.Ctx, req *validation.Create) (*enti return nil, err } - supplierIDs := utils.UniqueUintSlice(req.SupplierIDs) - if len(supplierIDs) > 0 { + var ( + supplierLinks []entity.NonstockSupplier + supplierIDs []uint + ) + if len(req.Suppliers) > 0 { + seen := make(map[uint]struct{}, len(req.Suppliers)) + supplierLinks = make([]entity.NonstockSupplier, 0, len(req.Suppliers)) + supplierIDs = make([]uint, 0, len(req.Suppliers)) + for _, supplier := range req.Suppliers { + if _, exists := seen[supplier.SupplierID]; exists { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Duplicate supplier_id %d", supplier.SupplierID)) + } + seen[supplier.SupplierID] = struct{}{} + supplierIDs = append(supplierIDs, supplier.SupplierID) + supplierLinks = append(supplierLinks, entity.NonstockSupplier{ + SupplierId: supplier.SupplierID, + Price: supplier.Price, + }) + } supplierList, supplierErr := s.Repository.GetSuppliersByIDs(ctx, supplierIDs) if supplierErr != nil { s.Log.Errorf("Failed to validate suppliers: %+v", supplierErr) @@ -155,7 +172,7 @@ func (s *nonstockService) CreateOne(c *fiber.Ctx, req *validation.Create) (*enti return err } - return s.Repository.SyncSuppliersDiff(ctx, tx, createBody.Id, supplierIDs) + return s.Repository.SyncSuppliersDiff(ctx, tx, createBody.Id, supplierLinks) }) if err != nil { @@ -193,15 +210,27 @@ func (s nonstockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint updateBody["uom_id"] = *req.UomID } - var supplierIDs []uint + var supplierLinks []entity.NonstockSupplier var supplierUpdate bool - if req.SupplierIDs != nil { + if req.Suppliers != nil { supplierUpdate = true - supplierIDs = utils.UniqueUintSlice(*req.SupplierIDs) - if len(supplierIDs) > 0 { - var supplierList []entity.Supplier - var supplierErr error - supplierList, supplierErr = s.Repository.GetSuppliersByIDs(ctx, supplierIDs) + if len(*req.Suppliers) > 0 { + seen := make(map[uint]struct{}, len(*req.Suppliers)) + supplierLinks = make([]entity.NonstockSupplier, 0, len(*req.Suppliers)) + supplierIDs := make([]uint, 0, len(*req.Suppliers)) + for _, supplier := range *req.Suppliers { + if _, exists := seen[supplier.SupplierID]; exists { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Duplicate supplier_id %d", supplier.SupplierID)) + } + seen[supplier.SupplierID] = struct{}{} + supplierIDs = append(supplierIDs, supplier.SupplierID) + supplierLinks = append(supplierLinks, entity.NonstockSupplier{ + SupplierId: supplier.SupplierID, + Price: supplier.Price, + }) + } + + supplierList, supplierErr := s.Repository.GetSuppliersByIDs(ctx, supplierIDs) if supplierErr != nil { s.Log.Errorf("Failed to validate suppliers: %+v", supplierErr) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate suppliers") @@ -253,11 +282,7 @@ func (s nonstockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint } if supplierUpdate { - var ids []uint - if len(supplierIDs) > 0 { - ids = supplierIDs - } - if err := s.Repository.SyncSuppliersDiff(ctx, tx, id, ids); err != nil { + if err := s.Repository.SyncSuppliersDiff(ctx, tx, id, supplierLinks); err != nil { return err } } diff --git a/internal/modules/master/nonstocks/validations/nonstock.validation.go b/internal/modules/master/nonstocks/validations/nonstock.validation.go index 62a41197..c5491991 100644 --- a/internal/modules/master/nonstocks/validations/nonstock.validation.go +++ b/internal/modules/master/nonstocks/validations/nonstock.validation.go @@ -1,16 +1,21 @@ package validation +type SupplierPrice struct { + SupplierID uint `json:"supplier_id" validate:"required,gt=0"` + Price float64 `json:"price" validate:"required,gte=0"` +} + type Create struct { Name string `json:"name" validate:"required_strict,min=3,max=50"` UomID uint `json:"uom_id" validate:"required,gt=0"` - SupplierIDs []uint `json:"supplier_ids" validate:"dive,gt=0"` + Suppliers []SupplierPrice `json:"suppliers,omitempty" validate:"omitempty,dive"` Flags []string `json:"flags" validate:"dive,max=50"` } type Update struct { Name *string `json:"name,omitempty" validate:"omitempty,min=3,max=50"` UomID *uint `json:"uom_id,omitempty" validate:"omitempty,gt=0"` - SupplierIDs *[]uint `json:"supplier_ids,omitempty" validate:"omitempty,dive,gt=0"` + Suppliers *[]SupplierPrice `json:"suppliers,omitempty" validate:"omitempty,dive"` Flags *[]string `json:"flags,omitempty" validate:"omitempty,dive,max=50"` } diff --git a/internal/modules/master/products/dto/product.dto.go b/internal/modules/master/products/dto/product.dto.go index 59f57034..d115ad23 100644 --- a/internal/modules/master/products/dto/product.dto.go +++ b/internal/modules/master/products/dto/product.dto.go @@ -5,7 +5,6 @@ import ( entity "gitlab.com/mbugroup/lti-api.git/internal/entities" productCategoryDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/product-categories/dto" - supplierDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/dto" uomDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/uoms/dto" userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" ) @@ -20,7 +19,7 @@ type ProductRelationDTO struct { Uom *uomDTO.UomRelationDTO `json:"uom,omitempty"` Flags *[]string `json:"flags,omitempty"` ProductCategory *productCategoryDTO.ProductCategoryRelationDTO `json:"product_category,omitempty"` - Suppliers []supplierDTO.SupplierRelationDTO `json:"suppliers"` + Suppliers []ProductSupplierDTO `json:"suppliers"` } type ProductListDTO struct { @@ -35,7 +34,7 @@ type ProductListDTO struct { Flags []string `json:"flags"` Uom *uomDTO.UomRelationDTO `json:"uom,omitempty"` ProductCategory *productCategoryDTO.ProductCategoryRelationDTO `json:"product_category,omitempty"` - Suppliers []supplierDTO.SupplierRelationDTO `json:"suppliers"` + Suppliers []ProductSupplierDTO `json:"suppliers"` CreatedUser *userDTO.UserRelationDTO `json:"created_user"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -45,6 +44,14 @@ type ProductDetailDTO struct { ProductListDTO } +type ProductSupplierDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + Alias string `json:"alias"` + Category string `json:"category"` + Price float64 `json:"price"` +} + // === Mapper Functions === func ToProductRelationDTO(e entity.Product) ProductRelationDTO { @@ -134,21 +141,27 @@ func ToProductDetailDTO(e entity.Product) ProductDetailDTO { } } -func toProductSupplierDTOs(relations []entity.ProductSupplier) []supplierDTO.SupplierRelationDTO { +func toProductSupplierDTOs(relations []entity.ProductSupplier) []ProductSupplierDTO { if len(relations) == 0 { - return make([]supplierDTO.SupplierRelationDTO, 0) + return make([]ProductSupplierDTO, 0) } - result := make([]supplierDTO.SupplierRelationDTO, 0, len(relations)) + result := make([]ProductSupplierDTO, 0, len(relations)) for _, relation := range relations { if relation.Supplier.Id == 0 { continue } - result = append(result, supplierDTO.ToSupplierRelationDTO(relation.Supplier)) + result = append(result, ProductSupplierDTO{ + Id: relation.Supplier.Id, + Name: relation.Supplier.Name, + Alias: relation.Supplier.Alias, + Category: relation.Supplier.Category, + Price: relation.Price, + }) } if len(result) == 0 { - return make([]supplierDTO.SupplierRelationDTO, 0) + return make([]ProductSupplierDTO, 0) } return result diff --git a/internal/modules/master/products/repositories/product.repository.go b/internal/modules/master/products/repositories/product.repository.go index 244259d5..ecef0204 100644 --- a/internal/modules/master/products/repositories/product.repository.go +++ b/internal/modules/master/products/repositories/product.repository.go @@ -17,7 +17,7 @@ type ProductRepository interface { CategoryExists(ctx context.Context, categoryID uint) (bool, error) GetSuppliersByIDs(ctx context.Context, supplierIDs []uint) ([]entity.Supplier, error) IsLinkedToSupplier(ctx context.Context, productID, supplierID uint) (bool, error) - SyncSuppliersDiff(ctx context.Context, tx *gorm.DB, productID uint, supplierIDs []uint) error + SyncSuppliersDiff(ctx context.Context, tx *gorm.DB, productID uint, suppliers []entity.ProductSupplier) error SyncFlags(ctx context.Context, tx *gorm.DB, productID uint, flags []string) error DeleteFlags(ctx context.Context, tx *gorm.DB, productID uint) error GetFlags(ctx context.Context, productID uint) ([]entity.Flag, error) @@ -102,13 +102,13 @@ func (r *ProductRepositoryImpl) IsLinkedToSupplier(ctx context.Context, productI return count > 0, nil } -func (r *ProductRepositoryImpl) SyncSuppliersDiff(ctx context.Context, tx *gorm.DB, productID uint, supplierIds []uint) error { +func (r *ProductRepositoryImpl) SyncSuppliersDiff(ctx context.Context, tx *gorm.DB, productID uint, suppliers []entity.ProductSupplier) error { db := tx if db == nil { db = r.DB() } - if supplierIds == nil { + if suppliers == nil { return db.WithContext(ctx). Where("product_id = ?", productID). Delete(&entity.ProductSupplier{}). @@ -123,18 +123,31 @@ func (r *ProductRepositoryImpl) SyncSuppliersDiff(ctx context.Context, tx *gorm. return err } - existingMap := make(map[uint]struct{}, len(existing)) + existingMap := make(map[uint]entity.ProductSupplier, len(existing)) for _, rel := range existing { - existingMap[rel.SupplierId] = struct{}{} + existingMap[rel.SupplierId] = rel } - incomingMap := make(map[uint]struct{}, len(supplierIds)) - for _, id := range supplierIds { - incomingMap[id] = struct{}{} - if _, exists := existingMap[id]; exists { + incomingMap := make(map[uint]struct{}, len(suppliers)) + for _, rel := range suppliers { + incomingMap[rel.SupplierId] = struct{}{} + if existingRel, exists := existingMap[rel.SupplierId]; exists { + if existingRel.Price != rel.Price { + if err := db.WithContext(ctx). + Model(&entity.ProductSupplier{}). + Where("product_id = ? AND supplier_id = ?", productID, rel.SupplierId). + Update("price", rel.Price). + Error; err != nil { + return err + } + } continue } - record := entity.ProductSupplier{ProductId: productID, SupplierId: id} + record := entity.ProductSupplier{ + ProductId: productID, + SupplierId: rel.SupplierId, + Price: rel.Price, + } if err := db.WithContext(ctx).Create(&record).Error; err != nil { return err } diff --git a/internal/modules/master/products/services/product.service.go b/internal/modules/master/products/services/product.service.go index e63b462b..0aaa0952 100644 --- a/internal/modules/master/products/services/product.service.go +++ b/internal/modules/master/products/services/product.service.go @@ -138,9 +138,25 @@ func (s *productService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit return nil, err } - supplierIDs := utils.UniqueUintSlice(req.SupplierIDs) - var err error - if len(supplierIDs) > 0 { + var ( + supplierLinks []entity.ProductSupplier + supplierIDs []uint + ) + if len(req.Suppliers) > 0 { + seen := make(map[uint]struct{}, len(req.Suppliers)) + supplierLinks = make([]entity.ProductSupplier, 0, len(req.Suppliers)) + supplierIDs = make([]uint, 0, len(req.Suppliers)) + for _, supplier := range req.Suppliers { + if _, exists := seen[supplier.SupplierID]; exists { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Duplicate supplier_id %d", supplier.SupplierID)) + } + seen[supplier.SupplierID] = struct{}{} + supplierIDs = append(supplierIDs, supplier.SupplierID) + supplierLinks = append(supplierLinks, entity.ProductSupplier{ + SupplierId: supplier.SupplierID, + Price: supplier.Price, + }) + } suppliers, err := s.Repository.GetSuppliersByIDs(ctx, supplierIDs) if err != nil { s.Log.Errorf("Failed to validate suppliers: %+v", err) @@ -180,7 +196,7 @@ func (s *productService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit CreatedBy: 1, } - err = s.Repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + err := s.Repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { repoTx := s.Repository.WithTx(tx) if err := repoTx.CreateOne(ctx, createBody, nil); err != nil { @@ -191,7 +207,7 @@ func (s *productService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit return err } - return s.Repository.SyncSuppliersDiff(ctx, tx, createBody.Id, supplierIDs) + return s.Repository.SyncSuppliersDiff(ctx, tx, createBody.Id, supplierLinks) }) if err != nil { @@ -276,15 +292,27 @@ func (s productService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) ctx := c.Context() - var suppliers []entity.Supplier - var supplierIDs []uint + var supplierLinks []entity.ProductSupplier var supplierUpdate bool - if req.SupplierIDs != nil { + if req.Suppliers != nil { supplierUpdate = true - supplierIDs = utils.UniqueUintSlice(*req.SupplierIDs) - if len(supplierIDs) > 0 { - var err error - suppliers, err = s.Repository.GetSuppliersByIDs(ctx, supplierIDs) + if len(*req.Suppliers) > 0 { + seen := make(map[uint]struct{}, len(*req.Suppliers)) + supplierLinks = make([]entity.ProductSupplier, 0, len(*req.Suppliers)) + supplierIDs := make([]uint, 0, len(*req.Suppliers)) + for _, supplier := range *req.Suppliers { + if _, exists := seen[supplier.SupplierID]; exists { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Duplicate supplier_id %d", supplier.SupplierID)) + } + seen[supplier.SupplierID] = struct{}{} + supplierIDs = append(supplierIDs, supplier.SupplierID) + supplierLinks = append(supplierLinks, entity.ProductSupplier{ + SupplierId: supplier.SupplierID, + Price: supplier.Price, + }) + } + + suppliers, err := s.Repository.GetSuppliersByIDs(ctx, supplierIDs) if err != nil { s.Log.Errorf("Failed to validate suppliers: %+v", err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate suppliers") @@ -336,11 +364,7 @@ func (s productService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) } if supplierUpdate { - var ids []uint - if len(supplierIDs) > 0 { - ids = supplierIDs - } - if err := s.Repository.SyncSuppliersDiff(ctx, tx, id, ids); err != nil { + if err := s.Repository.SyncSuppliersDiff(ctx, tx, id, supplierLinks); err != nil { return err } } diff --git a/internal/modules/master/products/validations/product.validation.go b/internal/modules/master/products/validations/product.validation.go index e732d054..77e8e1bf 100644 --- a/internal/modules/master/products/validations/product.validation.go +++ b/internal/modules/master/products/validations/product.validation.go @@ -1,5 +1,10 @@ package validation +type SupplierPrice struct { + SupplierID uint `json:"supplier_id" validate:"required,gt=0"` + Price float64 `json:"price" validate:"required,gte=0"` +} + type Create struct { Name string `json:"name" validate:"required_strict,min=3,max=50"` Brand string `json:"brand" validate:"required_strict,min=2,max=50"` @@ -10,7 +15,7 @@ type Create struct { SellingPrice *float64 `json:"selling_price,omitempty" validate:"omitempty"` Tax *float64 `json:"tax,omitempty" validate:"omitempty"` ExpiryPeriod *int `json:"expiry_period,omitempty" validate:"omitempty,gt=0"` - SupplierIDs []uint `json:"supplier_ids,omitempty" validate:"omitempty,dive,gt=0"` + Suppliers []SupplierPrice `json:"suppliers,omitempty" validate:"omitempty,dive"` Flags []string `json:"flags,omitempty" validate:"omitempty,dive"` } @@ -24,7 +29,7 @@ type Update struct { SellingPrice *float64 `json:"selling_price,omitempty" validate:"omitempty"` Tax *float64 `json:"tax,omitempty" validate:"omitempty"` ExpiryPeriod *int `json:"expiry_period,omitempty" validate:"omitempty,gt=0"` - SupplierIDs *[]uint `json:"supplier_ids,omitempty" validate:"omitempty,dive,gt=0"` + Suppliers *[]SupplierPrice `json:"suppliers,omitempty" validate:"omitempty,dive"` Flags *[]string `json:"flags,omitempty" validate:"omitempty,dive"` } diff --git a/internal/modules/master/suppliers/dto/supplier_nonstock.dto.go b/internal/modules/master/suppliers/dto/supplier_nonstock.dto.go index 828063eb..8c5e0082 100644 --- a/internal/modules/master/suppliers/dto/supplier_nonstock.dto.go +++ b/internal/modules/master/suppliers/dto/supplier_nonstock.dto.go @@ -10,6 +10,7 @@ import ( type SupplierNonstockDTO struct { Id uint `json:"id"` Name string `json:"name"` + Price float64 `json:"price"` Uom *uomDTO.UomRelationDTO `json:"uom,omitempty"` Flags []string `json:"flags"` } @@ -42,6 +43,7 @@ func toSupplierNonstockDTOs(relations []entity.NonstockSupplier) []SupplierNonst result = append(result, SupplierNonstockDTO{ Id: Nonstock.Id, Name: Nonstock.Name, + Price: relation.Price, Uom: uomRef, Flags: flags, }) diff --git a/internal/modules/master/suppliers/dto/supplier_product.dto.go b/internal/modules/master/suppliers/dto/supplier_product.dto.go index 47a6ae0e..a6178aaf 100644 --- a/internal/modules/master/suppliers/dto/supplier_product.dto.go +++ b/internal/modules/master/suppliers/dto/supplier_product.dto.go @@ -8,12 +8,13 @@ import ( // === DTO Structs === type SupplierProductDTO struct { - Id uint `json:"id"` - Name string `json:"name"` - ProductPrice float64 `gorm:"type:numeric(15,3);not null"` - SellingPrice *float64 `gorm:"type:numeric(15,3)"` - Uom *uomDTO.UomRelationDTO `json:"uom,omitempty"` - Flags []string `json:"flags"` + Id uint `json:"id"` + Name string `json:"name"` + ProductPrice float64 `gorm:"type:numeric(15,3);not null"` + SellingPrice *float64 `gorm:"type:numeric(15,3)"` + SupplierPrice float64 `json:"supplier_price"` + Uom *uomDTO.UomRelationDTO `json:"uom,omitempty"` + Flags []string `json:"flags"` } // === Mapper Functions === @@ -42,12 +43,13 @@ func toSupplierProductDTOs(relations []entity.ProductSupplier) []SupplierProduct } result = append(result, SupplierProductDTO{ - Id: product.Id, - Name: product.Name, - ProductPrice: product.ProductPrice, - SellingPrice: product.SellingPrice, - Uom: uomRef, - Flags: flags, + Id: product.Id, + Name: product.Name, + ProductPrice: product.ProductPrice, + SellingPrice: product.SellingPrice, + SupplierPrice: relation.Price, + Uom: uomRef, + Flags: flags, }) } return result From eaa208f73321d21494f961066bb65f71e2f74f83 Mon Sep 17 00:00:00 2001 From: ragilap Date: Tue, 13 Jan 2026 14:11:53 +0700 Subject: [PATCH 02/15] [FIX/BE-US-281] response recording and add payload record_at only in createOne --- .DS_Store | Bin 6148 -> 8196 bytes .../recordings/dto/recording.dto.go | 419 +++++++++++------- .../repositories/recording.repository.go | 11 +- .../recordings/services/recording.service.go | 63 ++- .../validations/recording.validation.go | 1 + 5 files changed, 342 insertions(+), 152 deletions(-) diff --git a/.DS_Store b/.DS_Store index e39247fdff6549a6304ce8065c332c38da11c1a4..be6f22d7458c6ebeb623d831ffa7f5c4a8127cb7 100644 GIT binary patch delta 715 zcmd5)u}Z^G6uplm=8*<%Q9>52?dD+64-l&8(8+?UAZ-&R_+qq;Lnk|Q%M>0i4qf~J zDH;79!9Nf|)RQ=vWO4PueYw1M?m73|m)Xv&V-pcOxOI1lvXmpTZ@re>Sc%hO?M3fd zNjLe2@~Cs3OjXuSP4$y`1`TOM9=V7sipZxviLMBT0}&1~2E-G>`N|ivjM=3Sb(g3a z(KfP>0=mXN#)(5BzXcES32uZuRhuWPe^gDN3~X7Oy;rmiI{ej^J>8O))@as?r*sY% zqS%S-Xsy&(3fPtb37;;|c0V^8!t}T-ncJO9q3_-4FqDnQhq 0 { - eggs = ToRecordingEggDTOs(e.Eggs) - } + listDTO := toRecordingListDTO(e) return RecordingDetailDTO{ RecordingListDTO: listDTO, - Depletions: ToRecordingDepletionDTOs(e.Depletions), - Stocks: ToRecordingStockDTOs(e.Stocks), - Eggs: eggs, + Warehouse: recordingWarehouseDTO(e), + ProductCategory: recordingProductCategory(e), + Depletions: ToRecordingDepletionDTOs(e.Depletions), + Stocks: ToRecordingStockDTOs(e.Stocks), + Eggs: ToRecordingEggDTOs(e.Eggs), } } @@ -233,11 +160,15 @@ func ToRecordingStockDTOs(stocks []entity.RecordingStock) []RecordingStockDTO { if s.UsageQty != nil { usageAmount = *s.UsageQty } + var pendingQty float64 + if s.PendingQty != nil { + pendingQty = *s.PendingQty + } result[i] = RecordingStockDTO{ ProductWarehouseId: s.ProductWarehouseId, UsageAmount: usageAmount, - PendingQty: s.PendingQty, + PendingQty: pendingQty, ProductWarehouse: mapProductWarehouseDTO(&s.ProductWarehouse), } } @@ -258,6 +189,184 @@ func ToRecordingEggDTOs(eggs []entity.RecordingEgg) []RecordingEggDTO { return result } +func toRecordingListDTO(e entity.Recording) RecordingListDTO { + relation := toRecordingRelationDTO(e) + + var createdUser *userDTO.UserRelationDTO + if e.CreatedUser != nil && e.CreatedUser.Id != 0 { + mapped := userDTO.ToUserRelationDTO(*e.CreatedUser) + createdUser = &mapped + } + + return RecordingListDTO{ + RecordingRelationDTO: relation, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + CreatedUser: createdUser, + } +} + +func toRecordingRelationDTO(e entity.Recording) RecordingRelationDTO { + latestApproval := defaultRecordingLatestApproval(e) + if e.LatestApproval != nil { + snapshot := approvalDTO.ToApprovalDTO(*e.LatestApproval) + latestApproval = snapshot + } + + return RecordingRelationDTO{ + Id: e.Id, + ProjectFlock: toRecordingProjectFlockDTO(e), + RecordDatetime: e.RecordDatetime, + Day: intValue(e.Day), + TotalDepletionQty: floatValue(e.TotalDepletionQty), + CumDepletionRate: floatValue(e.CumDepletionRate), + CumIntake: intValue(e.CumIntake), + FcrValue: floatValue(e.FcrValue), + HenDay: floatValue(e.HenDay), + HenHouse: floatValue(e.HenHouse), + FeedIntake: floatValue(e.FeedIntake), + EggMass: floatValue(e.EggMass), + EggWeight: floatValue(e.EggWeight), + Approval: latestApproval, + } +} + +func toRecordingProjectFlockDTO(e entity.Recording) RecordingProjectFlockDTO { + result := RecordingProjectFlockDTO{ + ProjectFlockKandangId: e.ProjectFlockKandangId, + } + + pfk := e.ProjectFlockKandang + if pfk == nil { + return result + } + + if pfk.ProjectFlock.Id != 0 { + result.FlockName = pfk.ProjectFlock.FlockName + if pfk.ProjectFlock.Category != "" { + result.ProjectFlockCategory = strings.ToUpper(pfk.ProjectFlock.Category) + } + } + + result.Period = pfk.Period + + if pfk.ProjectFlock.ProductionStandard.Id != 0 { + result.ProductionStandart = &RecordingProductionStandardDTO{ + Id: pfk.ProjectFlock.ProductionStandard.Id, + Week: recordingWeekValue(e), + Name: pfk.ProjectFlock.ProductionStandard.Name, + HenDayStd: floatValue(e.StandardHenDay), + HenHouseStd: floatValue(e.StandardHenHouse), + FeedIntakeStd: floatValue(e.StandardFeedIntake), + MaxDepletionStd: floatValue(e.StandardMaxDepletion), + EggMassStd: floatValue(e.StandardEggMass), + EggWeightStd: floatValue(e.StandardEggWeight), + } + } + + if pfk.ProjectFlock.Fcr.Id != 0 || e.StandardFcr != nil { + result.Fcr = &RecordingFcrDTO{ + Id: pfk.ProjectFlock.Fcr.Id, + Name: pfk.ProjectFlock.Fcr.Name, + FcrStd: floatValue(e.StandardFcr), + } + } + + result.TotalChickQty = floatValue(e.TotalChickQty) + + return result +} + +func recordingWeekValue(e entity.Recording) int { + day := intValue(e.Day) + if day <= 0 { + return 0 + } + weekBase := 1 + if isLayingRecording(e) { + weekBase = 18 + } + return ((day - 1) / 7) + weekBase +} + +func isLayingRecording(e entity.Recording) bool { + if e.ProjectFlockKandang == nil { + return false + } + return strings.EqualFold(e.ProjectFlockKandang.ProjectFlock.Category, string(utils.ProjectFlockCategoryLaying)) +} + +func recordingProductCategory(e entity.Recording) string { + if e.ProjectFlockKandang == nil { + return "" + } + project := e.ProjectFlockKandang.ProjectFlock + if project.Id == 0 { + return "" + } + if project.ProductionStandard.Id != 0 && project.ProductionStandard.ProjectCategory != "" { + return strings.ToUpper(project.ProductionStandard.ProjectCategory) + } + if project.Category != "" { + return strings.ToUpper(project.Category) + } + return "" +} + +func recordingWarehouseDTO(e entity.Recording) *RecordingWarehouseDTO { + pw := primaryProductWarehouse(e) + if pw == nil || pw.Warehouse.Id == 0 { + return nil + } + return mapWarehouseDTO(&pw.Warehouse) +} + +func primaryProductWarehouse(e entity.Recording) *entity.ProductWarehouse { + if len(e.Stocks) > 0 { + pw := e.Stocks[0].ProductWarehouse + if pw.Id != 0 { + return &pw + } + } + if len(e.Depletions) > 0 { + pw := e.Depletions[0].ProductWarehouse + if pw.Id != 0 { + return &pw + } + } + if len(e.Eggs) > 0 { + pw := e.Eggs[0].ProductWarehouse + if pw.Id != 0 { + return &pw + } + } + return nil +} + +func mapWarehouseDTO(wh *entity.Warehouse) *RecordingWarehouseDTO { + if wh == nil || wh.Id == 0 { + return nil + } + dto := &RecordingWarehouseDTO{ + Id: wh.Id, + Name: wh.Name, + } + if wh.Area.Id != 0 { + dto.Area = &RecordingAreaDTO{ + Id: wh.Area.Id, + Name: wh.Area.Name, + } + } + if wh.Location != nil && wh.Location.Id != 0 { + dto.Location = &RecordingLocationDTO{ + Id: wh.Location.Id, + Name: wh.Location.Name, + Address: wh.Location.Address, + } + } + return dto +} + func mapProductWarehouseDTO(pw *entity.ProductWarehouse) productWarehouseDTO.ProductWarehouseDTO { if pw == nil { return productWarehouseDTO.ProductWarehouseDTO{} @@ -271,6 +380,20 @@ func mapProductWarehouseDTO(pw *entity.ProductWarehouse) productWarehouseDTO.Pro return *mapped } +func floatValue(value *float64) float64 { + if value == nil { + return 0 + } + return *value +} + +func intValue(value *int) int { + if value == nil { + return 0 + } + return *value +} + func defaultRecordingLatestApproval(e entity.Recording) approvalDTO.ApprovalRelationDTO { result := approvalDTO.ApprovalRelationDTO{} diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index 941d4507..dafd92ce 100644 --- a/internal/modules/production/recordings/repositories/recording.repository.go +++ b/internal/modules/production/recordings/repositories/recording.repository.go @@ -64,19 +64,28 @@ func (r *RecordingRepositoryImpl) WithRelations(db *gorm.DB) *gorm.DB { return db. Preload("CreatedUser"). Preload("ProjectFlockKandang"). + Preload("ProjectFlockKandang.Kandang"). Preload("ProjectFlockKandang.ProjectFlock"). + Preload("ProjectFlockKandang.ProjectFlock.ProductionStandard"). + Preload("ProjectFlockKandang.ProjectFlock.Fcr"). Preload("Depletions"). Preload("Depletions.ProductWarehouse"). Preload("Depletions.ProductWarehouse.Product"). Preload("Depletions.ProductWarehouse.Warehouse"). + Preload("Depletions.ProductWarehouse.Warehouse.Area"). + Preload("Depletions.ProductWarehouse.Warehouse.Location"). Preload("Stocks"). Preload("Stocks.ProductWarehouse"). Preload("Stocks.ProductWarehouse.Product"). Preload("Stocks.ProductWarehouse.Warehouse"). + Preload("Stocks.ProductWarehouse.Warehouse.Area"). + Preload("Stocks.ProductWarehouse.Warehouse.Location"). Preload("Eggs"). Preload("Eggs.ProductWarehouse"). Preload("Eggs.ProductWarehouse.Product"). - Preload("Eggs.ProductWarehouse.Warehouse") + Preload("Eggs.ProductWarehouse.Warehouse"). + Preload("Eggs.ProductWarehouse.Warehouse.Area"). + Preload("Eggs.ProductWarehouse.Warehouse.Location") } func (r *RecordingRepositoryImpl) GetLatestByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (*entity.Recording, error) { diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 819552dc..18c00966 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -169,6 +169,14 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent } ctx := c.Context() + recordTime := time.Now().UTC() + if req.RecordDate != nil && strings.TrimSpace(*req.RecordDate) != "" { + parsed, err := time.Parse("2006-01-02", strings.TrimSpace(*req.RecordDate)) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "record_date must be in YYYY-MM-DD format") + } + recordTime = parsed.UTC() + } pfk, err := s.ProjectFlockKandangRepo.GetByID(ctx, req.ProjectFlockKandangId) if err != nil { @@ -188,6 +196,9 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent if err := s.ensureChickInExists(ctx, pfk.Id); err != nil { return nil, err } + if err := s.ensureProductionStandardWeekStart(ctx, pfk); err != nil { + return nil, err + } if !isLaying && len(req.Eggs) > 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "Egg details permitted only for laying project flocks") @@ -211,7 +222,6 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent return err } - 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) @@ -1330,12 +1340,16 @@ func (s *recordingService) attachProductionStandard(ctx context.Context, item *e return nil } - week := ((int(*item.Day) - 1) / 7) + 1 + category := strings.ToUpper(item.ProjectFlockKandang.ProjectFlock.Category) + weekBase := 1 + if category == string(utils.ProjectFlockCategoryLaying) { + weekBase = 18 + } + week := ((int(*item.Day) - 1) / 7) + weekBase if week <= 0 { return nil } - category := strings.ToUpper(item.ProjectFlockKandang.ProjectFlock.Category) db := s.Repository.DB() standardDetailRepo := rProductionStandard.NewProductionStandardDetailRepository(db) growthDetailRepo := rProductionStandard.NewStandardGrowthDetailRepository(db) @@ -1462,3 +1476,46 @@ func (s *recordingService) ensureChickInExists(ctx context.Context, projectFlock return fiber.NewError(fiber.StatusBadRequest, "Chick in project flock belum disetujui sehingga belum dapat membuat recording") } + +func (s *recordingService) ensureProductionStandardWeekStart(ctx context.Context, pfk *entity.ProjectFlockKandang) error { + if pfk == nil || pfk.ProjectFlock.Id == 0 { + return nil + } + + standardID := pfk.ProjectFlock.ProductionStandardId + if standardID == 0 { + return nil + } + + category := strings.ToUpper(pfk.ProjectFlock.Category) + switch category { + case string(utils.ProjectFlockCategoryLaying): + detailRepo := rProductionStandard.NewProductionStandardDetailRepository(s.Repository.DB()) + details, err := detailRepo.GetByProductionStandardID(ctx, standardID) + if err != nil { + return err + } + startWeek := 0 + if len(details) > 0 { + startWeek = details[0].Week + } + if startWeek != 18 { + return fiber.NewError(fiber.StatusBadRequest, "Week tidak sesuai dengan standart kategori project flock") + } + case string(utils.ProjectFlockCategoryGrowing): + growthRepo := rProductionStandard.NewStandardGrowthDetailRepository(s.Repository.DB()) + details, err := growthRepo.GetByProductionStandardID(ctx, standardID) + if err != nil { + return err + } + startWeek := 0 + if len(details) > 0 { + startWeek = details[0].Week + } + if startWeek != 1 { + return fiber.NewError(fiber.StatusBadRequest, "Week tidak sesuai dengan standart kategori project flock") + } + } + + return nil +} diff --git a/internal/modules/production/recordings/validations/recording.validation.go b/internal/modules/production/recordings/validations/recording.validation.go index a1d6aaf7..8b4eab57 100644 --- a/internal/modules/production/recordings/validations/recording.validation.go +++ b/internal/modules/production/recordings/validations/recording.validation.go @@ -21,6 +21,7 @@ type ( type Create struct { ProjectFlockKandangId uint `json:"project_flock_kandang_id" validate:"required,number,min=1"` + RecordDate *string `json:"record_date,omitempty" validate:"omitempty,datetime=2006-01-02"` Stocks []Stock `json:"stocks" validate:"dive"` Depletions []Depletion `json:"depletions" validate:"dive"` Eggs []Egg `json:"eggs" validate:"omitempty,dive"` From ce7ce778fd62bcd72583ef75a3e19ad6658c4149 Mon Sep 17 00:00:00 2001 From: ragilap Date: Tue, 13 Jan 2026 14:39:59 +0700 Subject: [PATCH 03/15] [FIX/BE-US-281] add response validation if weeks not have production standart --- .../services/production-standard.service.go | 81 +++++++++++++++++++ .../modules/production/recordings/module.go | 13 +++ .../recordings/services/recording.service.go | 58 +++---------- 3 files changed, 107 insertions(+), 45 deletions(-) diff --git a/internal/modules/master/production-standards/services/production-standard.service.go b/internal/modules/master/production-standards/services/production-standard.service.go index e1470170..2ea95cf3 100644 --- a/internal/modules/master/production-standards/services/production-standard.service.go +++ b/internal/modules/master/production-standards/services/production-standard.service.go @@ -1,8 +1,10 @@ package service import ( + "context" "errors" "fmt" + "strings" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" m "gitlab.com/mbugroup/lti-api.git/internal/middleware" @@ -22,6 +24,8 @@ type ProductionStandardService interface { CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.ProductionStandard, error) UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.ProductionStandard, error) DeleteOne(ctx *fiber.Ctx, id uint) error + EnsureWeekStart(ctx context.Context, standardID uint, category string) error + EnsureWeekAvailable(ctx context.Context, standardID uint, category string, day int) error } type productionStandardService struct { @@ -299,3 +303,80 @@ func (s productionStandardService) DeleteOne(c *fiber.Ctx, id uint) error { } return nil } + +func (s productionStandardService) EnsureWeekStart(ctx context.Context, standardID uint, category string) error { + if standardID == 0 || strings.TrimSpace(category) == "" { + return nil + } + + switch strings.ToUpper(category) { + case string(utils.ProjectFlockCategoryLaying): + details, err := s.ProductionStandardDetailRepo.GetByProductionStandardID(ctx, standardID) + if err != nil { + return err + } + startWeek := 0 + if len(details) > 0 { + startWeek = details[0].Week + } + if startWeek != 18 { + return fiber.NewError(fiber.StatusBadRequest, "Week tidak sesuai dengan standart kategori project flock") + } + case string(utils.ProjectFlockCategoryGrowing): + details, err := s.StandardGrowthDetailRepo.GetByProductionStandardID(ctx, standardID) + if err != nil { + return err + } + startWeek := 0 + if len(details) > 0 { + startWeek = details[0].Week + } + if startWeek != 1 { + return fiber.NewError(fiber.StatusBadRequest, "Week tidak sesuai dengan standart kategori project flock") + } + } + + return nil +} + +func (s productionStandardService) EnsureWeekAvailable(ctx context.Context, standardID uint, category string, day int) error { + if standardID == 0 || day <= 0 { + return nil + } + + upperCategory := strings.ToUpper(category) + weekBase := 1 + if upperCategory == string(utils.ProjectFlockCategoryLaying) { + weekBase = 18 + } + week := ((day - 1) / 7) + weekBase + if week <= 0 { + return nil + } + + if upperCategory == string(utils.ProjectFlockCategoryLaying) { + detail, err := s.ProductionStandardDetailRepo.GetByStandardIDAndWeek(ctx, standardID, week) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Standart production tidak tersedia untuk week %d", week)) + } + return err + } + if detail == nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Standart production tidak tersedia untuk week %d", week)) + } + } + + growthDetail, err := s.StandardGrowthDetailRepo.GetByStandardIDAndWeek(ctx, standardID, week) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Standart production tidak tersedia untuk week %d", week)) + } + return err + } + if growthDetail == nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Standart production tidak tersedia untuk week %d", week)) + } + + return nil +} diff --git a/internal/modules/production/recordings/module.go b/internal/modules/production/recordings/module.go index a19faa33..91b024ac 100644 --- a/internal/modules/production/recordings/module.go +++ b/internal/modules/production/recordings/module.go @@ -11,6 +11,8 @@ import ( 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" + rProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories" + sProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/services" 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" @@ -29,6 +31,16 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate projectFlockPopulationRepo := rProjectFlock.NewProjectFlockPopulationRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) stockAllocationRepo := commonRepo.NewStockAllocationRepository(db) + productionStandardRepo := rProductionStandard.NewProductionStandardRepository(db) + productionStandardDetailRepo := rProductionStandard.NewProductionStandardDetailRepository(db) + standardGrowthDetailRepo := rProductionStandard.NewStandardGrowthDetailRepository(db) + + productionStandardService := sProductionStandard.NewProductionStandardService( + productionStandardRepo, + productionStandardDetailRepo, + standardGrowthDetailRepo, + validate, + ) fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log) if err := fifoService.RegisterUsable(fifo.UsableConfig{ @@ -63,6 +75,7 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate approvalRepo, approvalService, fifoService, + productionStandardService, validate, ) userService := sUser.NewUserService(userRepo, validate) diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 18c00966..ccf360b9 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -10,6 +10,7 @@ import ( m "gitlab.com/mbugroup/lti-api.git/internal/middleware" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" rProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories" + sProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/services" 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" @@ -53,6 +54,7 @@ type recordingService struct { ProjectFlockPopulationRepo rProjectFlock.ProjectFlockPopulationRepository ApprovalRepo commonRepo.ApprovalRepository ApprovalSvc commonSvc.ApprovalService + ProductionStandardSvc sProductionStandard.ProductionStandardService FifoSvc commonSvc.FifoService } @@ -64,6 +66,7 @@ func NewRecordingService( approvalRepo commonRepo.ApprovalRepository, approvalSvc commonSvc.ApprovalService, fifoSvc commonSvc.FifoService, + productionStandardSvc sProductionStandard.ProductionStandardService, validate *validator.Validate, ) RecordingService { return &recordingService{ @@ -75,6 +78,7 @@ func NewRecordingService( ProjectFlockPopulationRepo: projectFlockPopulationRepo, ApprovalRepo: approvalRepo, ApprovalSvc: approvalSvc, + ProductionStandardSvc: productionStandardSvc, FifoSvc: fifoSvc, } } @@ -196,8 +200,10 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent if err := s.ensureChickInExists(ctx, pfk.Id); err != nil { return nil, err } - if err := s.ensureProductionStandardWeekStart(ctx, pfk); err != nil { - return nil, err + if s.ProductionStandardSvc != nil { + if err := s.ProductionStandardSvc.EnsureWeekStart(ctx, pfk.ProjectFlock.ProductionStandardId, category); err != nil { + return nil, err + } } if !isLaying && len(req.Eggs) > 0 { @@ -221,6 +227,11 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent s.Log.Errorf("Failed to determine recording day: %+v", err) return err } + if s.ProductionStandardSvc != nil { + if err := s.ProductionStandardSvc.EnsureWeekAvailable(ctx, pfk.ProjectFlock.ProductionStandardId, category, nextDay); err != nil { + return err + } + } existsToday, err := s.Repository.ExistsOnDate(ctx, req.ProjectFlockKandangId, recordTime) if err != nil { @@ -1476,46 +1487,3 @@ func (s *recordingService) ensureChickInExists(ctx context.Context, projectFlock return fiber.NewError(fiber.StatusBadRequest, "Chick in project flock belum disetujui sehingga belum dapat membuat recording") } - -func (s *recordingService) ensureProductionStandardWeekStart(ctx context.Context, pfk *entity.ProjectFlockKandang) error { - if pfk == nil || pfk.ProjectFlock.Id == 0 { - return nil - } - - standardID := pfk.ProjectFlock.ProductionStandardId - if standardID == 0 { - return nil - } - - category := strings.ToUpper(pfk.ProjectFlock.Category) - switch category { - case string(utils.ProjectFlockCategoryLaying): - detailRepo := rProductionStandard.NewProductionStandardDetailRepository(s.Repository.DB()) - details, err := detailRepo.GetByProductionStandardID(ctx, standardID) - if err != nil { - return err - } - startWeek := 0 - if len(details) > 0 { - startWeek = details[0].Week - } - if startWeek != 18 { - return fiber.NewError(fiber.StatusBadRequest, "Week tidak sesuai dengan standart kategori project flock") - } - case string(utils.ProjectFlockCategoryGrowing): - growthRepo := rProductionStandard.NewStandardGrowthDetailRepository(s.Repository.DB()) - details, err := growthRepo.GetByProductionStandardID(ctx, standardID) - if err != nil { - return err - } - startWeek := 0 - if len(details) > 0 { - startWeek = details[0].Week - } - if startWeek != 1 { - return fiber.NewError(fiber.StatusBadRequest, "Week tidak sesuai dengan standart kategori project flock") - } - } - - return nil -} From 590df26a1f5d7d1de04a1110454c586b3a540c48 Mon Sep 17 00:00:00 2001 From: MacBook Air M1 Date: Tue, 13 Jan 2026 14:43:37 +0700 Subject: [PATCH 04/15] adjust api closing production data --- .../controllers/closing.controller.go | 12 +- internal/modules/closings/dto/closing.dto.go | 33 ++- internal/modules/closings/module.go | 5 +- .../repositories/closing.repository.go | 18 ++ .../closings/services/closing.service.go | 214 +++++++++++++++--- .../repositories/recording.repository.go | 73 ++++++ 6 files changed, 313 insertions(+), 42 deletions(-) diff --git a/internal/modules/closings/controllers/closing.controller.go b/internal/modules/closings/controllers/closing.controller.go index 6ab2d398..8f129521 100644 --- a/internal/modules/closings/controllers/closing.controller.go +++ b/internal/modules/closings/controllers/closing.controller.go @@ -338,7 +338,17 @@ func (u *ClosingController) GetClosingDataProduksi(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, "Invalid projectFlockId") } - result, err := u.ClosingService.GetClosingDataProduksi(c, uint(id)) + var kandangID *uint + if raw := c.Query("kandang_id"); raw != "" { + kandangInt, convErr := strconv.Atoi(raw) + if convErr != nil || kandangInt <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid kandang_id") + } + kandangUint := uint(kandangInt) + kandangID = &kandangUint + } + + result, err := u.ClosingService.GetClosingDataProduksi(c, uint(id), kandangID) if err != nil { return err } diff --git a/internal/modules/closings/dto/closing.dto.go b/internal/modules/closings/dto/closing.dto.go index ac172c83..05a606ca 100644 --- a/internal/modules/closings/dto/closing.dto.go +++ b/internal/modules/closings/dto/closing.dto.go @@ -65,33 +65,44 @@ type ClosingPurchaseDTO struct { FinalPopulation int `json:"final_population"` FeedIn float64 `json:"feed_in"` FeedUsed float64 `json:"feed_used"` - FeedUsedPerHead float64 `json:"feed_used_per_head"` + // FeedUsedPerHead float64 `json:"feed_used_per_head"` } type ClosingSalesDTO struct { SalesPopulation int `json:"sales_population"` SalesWeight float64 `json:"sales_weight"` - AverageWeight float64 `json:"average_weight"` - AverageSellingPrice float64 `json:"chicken_average_selling_price"` + AverageWeight float64 `json:"avg_weight"` + AverageSellingPrice float64 `json:"avg_selling_price"` } type ClosingEggSalesDTO struct { EggPieces int `json:"egg_pieces"` - EggMassKg float64 `json:"egg_mass_kg"` - AverageEggWeightKg float64 `json:"average_egg_weight_kg"` - AverageSellingPrice float64 `json:"egg_average_selling_price"` + EggMassKg float64 `json:"egg_mass"` + AverageEggWeightKg float64 `json:"avg_egg_weight"` + AverageSellingPrice float64 `json:"avg_selling_price"` } type ClosingPerformanceDTO struct { Depletion float64 `json:"depletion"` Age float64 `json:"age_day"` - MortalityStd float64 `json:"mortality_std"` - MortalityAct float64 `json:"mortality_act"` - DeffMortality float64 `json:"deff_mortality"` + MortalityStd float64 `json:"mor_std"` + MortalityAct float64 `json:"mor_act"` + DeffMortality float64 `json:"mor_diff"` FcrStd float64 `json:"fcr_std"` FcrAct float64 `json:"fcr_act"` - DeffFcr float64 `json:"deff_fcr"` - Awg float64 `json:"awg"` + DeffFcr float64 `json:"fcr_diff"` + AwgAct float64 `json:"awg_act"` + AwgStd float64 `json:"awg_std"` + FeedIntake float64 `json:"feed_intake"` + FeedIntakeStd float64 `json:"feed_intake_std"` + HenDayAct *float64 `json:"hen_day_act,omitempty"` + HendayStd *float64 `json:"hen_day_std,omitempty"` + EggMass *float64 `json:"egg_mass,omitempty"` + EggMassStd *float64 `json:"egg_mass_std,omitempty"` + EggWeight *float64 `json:"egg_weight,omitempty"` + EggWeightStd *float64 `json:"egg_weight_std,omitempty"` + HenHouseAct *float64 `json:"hen_housed_act,omitempty"` + HenHouseStd *float64 `json:"hen_housed_std,omitempty"` } type ClosingSalesGroupDTO struct { diff --git a/internal/modules/closings/module.go b/internal/modules/closings/module.go index c89e6125..87ce815a 100644 --- a/internal/modules/closings/module.go +++ b/internal/modules/closings/module.go @@ -11,6 +11,7 @@ import ( sClosing "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/services" rExpenseRealization "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" rMarketings "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" + rProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories" rChickin "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/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" @@ -33,11 +34,13 @@ func (ClosingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * expenseRealizationRepo := rExpenseRealization.NewExpenseRealizationRepository(db) chickinRepo := rChickin.NewChickinRepository(db) recordingRepo := rRecording.NewRecordingRepository(db) + standardGrowthDetailRepo := rProductionStandard.NewStandardGrowthDetailRepository(db) + productionStandardDetailRepo := rProductionStandard.NewProductionStandardDetailRepository(db) purchaseRepo := rPurchase.NewPurchaseRepository(db) approvalRepo := commonRepo.NewApprovalRepository(db) approvalService := commonSvc.NewApprovalService(approvalRepo) - closingService := sClosing.NewClosingService(closingRepo, projectFlockRepo, marketingRepo, marketingDeliveryProductRepo, approvalService, expenseRealizationRepo, projectBudgetRepo, chickinRepo, purchaseRepo, recordingRepo, validate) + closingService := sClosing.NewClosingService(closingRepo, projectFlockRepo, marketingRepo, marketingDeliveryProductRepo, approvalService, expenseRealizationRepo, projectBudgetRepo, chickinRepo, purchaseRepo, recordingRepo, standardGrowthDetailRepo, productionStandardDetailRepo, validate) sapronakService := sClosing.NewSapronakService(closingRepo, projectFlockKandangRepo, validate) userService := sUser.NewUserService(userRepo, validate) diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go index 9d08d083..2ce3e496 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -18,6 +18,7 @@ type ClosingRepository interface { repository.BaseRepository[entity.ProjectFlock] GetSapronak(ctx context.Context, params SapronakQueryParams) ([]SapronakRow, int64, error) SumFeedPurchaseAndUsedByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) (float64, float64, error) + SumProjectChickinUsageByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) SumClaimCullingByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) SumMarketingWeightAndQtyByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) (float64, float64, float64, error) SumMarketingWeightAndQtyByProjectFlockKandangIDsAndFlagNames(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string) (float64, float64, float64, error) @@ -166,6 +167,23 @@ func (r *ClosingRepositoryImpl) SumFeedPurchaseAndUsedByProjectFlockKandangIDs(c return purchaseAgg.TotalIn, usageAgg.TotalUsed, nil } +func (r *ClosingRepositoryImpl) SumProjectChickinUsageByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) { + if len(projectFlockKandangIDs) == 0 { + return 0, nil + } + + var total float64 + if err := r.DB().WithContext(ctx). + Model(&entity.ProjectChickin{}). + Where("project_flock_kandang_id IN ?", projectFlockKandangIDs). + Select("COALESCE(SUM(usage_qty), 0)"). + Scan(&total).Error; err != nil { + return 0, err + } + + return total, nil +} + func (r *ClosingRepositoryImpl) SumClaimCullingByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) { if len(projectFlockKandangIDs) == 0 { return 0, nil diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index 245fd24c..8b00475f 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -16,6 +16,7 @@ import ( expenseRealizationRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" marketingDeliveryProductRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" marketingRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" + productionStandardRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories" chickinRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories" projectflockRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" recordingRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" @@ -35,7 +36,7 @@ type ClosingService interface { GetPenjualan(ctx *fiber.Ctx, projectFlockID uint) ([]entity.MarketingDeliveryProduct, error) GetClosingSummary(ctx *fiber.Ctx, projectFlockID uint) (*dto.ClosingSummaryDTO, error) GetOverhead(ctx *fiber.Ctx, projectFlockID uint) (*dto.OverheadListDTO, error) - GetClosingDataProduksi(ctx *fiber.Ctx, projectFlockID uint) (*dto.ClosingProductionReportDTO, error) + GetClosingDataProduksi(ctx *fiber.Ctx, projectFlockID uint, kandangID *uint) (*dto.ClosingProductionReportDTO, error) GetClosingSapronak(ctx *fiber.Ctx, projectFlockID uint, params *validation.ClosingSapronakQuery) ([]dto.ClosingSapronakItemDTO, int64, error) GetClosingKeuangan(ctx *fiber.Ctx, projectFlockID uint) (*dto.ReportResponse, error) GetExpeditionHPP(ctx *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) (*dto.ExpeditionHPPDTO, error) @@ -54,9 +55,11 @@ type closingService struct { ChickinRepo chickinRepository.ProjectChickinRepository PurchaseRepo purchaseRepository.PurchaseRepository RecordingRepo recordingRepository.RecordingRepository + StandardGrowthDetailRepo productionStandardRepository.StandardGrowthDetailRepository + ProductionStandardDetailRepo productionStandardRepository.ProductionStandardDetailRepository } -func NewClosingService(repo repository.ClosingRepository, projectFlockRepo projectflockRepository.ProjectflockRepository, marketingRepo marketingRepository.MarketingRepository, marketingDeliveryProductRepo marketingDeliveryProductRepository.MarketingDeliveryProductRepository, approvalSvc commonSvc.ApprovalService, expenseRealizationRepo expenseRealizationRepository.ExpenseRealizationRepository, projectBudgetRepo projectflockRepository.ProjectBudgetRepository, chickinRepo chickinRepository.ProjectChickinRepository, purchaseRepo purchaseRepository.PurchaseRepository, recordingRepo recordingRepository.RecordingRepository, validate *validator.Validate) ClosingService { +func NewClosingService(repo repository.ClosingRepository, projectFlockRepo projectflockRepository.ProjectflockRepository, marketingRepo marketingRepository.MarketingRepository, marketingDeliveryProductRepo marketingDeliveryProductRepository.MarketingDeliveryProductRepository, approvalSvc commonSvc.ApprovalService, expenseRealizationRepo expenseRealizationRepository.ExpenseRealizationRepository, projectBudgetRepo projectflockRepository.ProjectBudgetRepository, chickinRepo chickinRepository.ProjectChickinRepository, purchaseRepo purchaseRepository.PurchaseRepository, recordingRepo recordingRepository.RecordingRepository, standardGrowthDetailRepo productionStandardRepository.StandardGrowthDetailRepository, productionStandardDetailRepo productionStandardRepository.ProductionStandardDetailRepository, validate *validator.Validate) ClosingService { return &closingService{ Log: utils.Log, Validate: validate, @@ -70,6 +73,8 @@ func NewClosingService(repo repository.ClosingRepository, projectFlockRepo proje ChickinRepo: chickinRepo, PurchaseRepo: purchaseRepo, RecordingRepo: recordingRepo, + StandardGrowthDetailRepo: standardGrowthDetailRepo, + ProductionStandardDetailRepo: productionStandardDetailRepo, } } @@ -231,7 +236,7 @@ func (s closingService) GetClosingSapronak(c *fiber.Ctx, projectFlockID uint, pa var projectFlockKandangIDs []uint if params.Type == validation.SapronakTypeOutgoing { - projectFlockKandangIDs, err = s.getProjectFlockKandangIDs(c.Context(), projectFlockID) + projectFlockKandangIDs, err = s.getProjectFlockKandangIDs(c.Context(), projectFlockID, nil) if err != nil { s.Log.Errorf("Failed to fetch project flock kandang IDs for project flock %d: %+v", projectFlockID, err) return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandang") @@ -311,12 +316,15 @@ func (s closingService) getWarehouseIDsByProjectFlock(ctx context.Context, proje return ids, nil } -func (s closingService) getProjectFlockKandangIDs(ctx context.Context, projectFlockID uint) ([]uint, error) { +func (s closingService) getProjectFlockKandangIDs(ctx context.Context, projectFlockID uint, kandangID *uint) ([]uint, error) { var ids []uint - err := s.Repository.DB().WithContext(ctx). + query := s.Repository.DB().WithContext(ctx). Model(&entity.ProjectFlockKandang{}). - Where("project_flock_id = ?", projectFlockID). - Pluck("id", &ids).Error + Where("project_flock_id = ?", projectFlockID) + if kandangID != nil { + query = query.Where("kandang_id = ?", *kandangID) + } + err := query.Order("id ASC").Pluck("id", &ids).Error if err != nil { return nil, err } @@ -520,12 +528,22 @@ func (s closingService) GetExpeditionHPP(c *fiber.Ctx, projectFlockID uint, proj return result, nil } -func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint) (*dto.ClosingProductionReportDTO, error) { +func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint, kandangID *uint) (*dto.ClosingProductionReportDTO, error) { if projectFlockID == 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project flock id") } - project, err := s.Repository.GetByID(c.Context(), projectFlockID, s.withClosingRelations) + projectFlockKandangIDs, err := s.getProjectFlockKandangIDs(c.Context(), projectFlockID, kandangID) + if err != nil { + s.Log.Errorf("Failed to fetch project flock kandangs for %d: %+v", projectFlockID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandangs") + } + + if len(projectFlockKandangIDs) == 0 { + return nil, fiber.NewError(fiber.StatusNotFound, "No project flock kandang found") + } + + project, err := s.Repository.GetByID(c.Context(), projectFlockID, s.withRelations) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Project flock not found") } @@ -534,19 +552,29 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock") } - var population float64 - for _, history := range project.KandangHistory { - for _, chickin := range history.Chickins { - population += chickin.UsageQty - } + population, err := s.Repository.SumProjectChickinUsageByProjectFlockKandangIDs(c.Context(), projectFlockKandangIDs) + if err != nil { + s.Log.Errorf("Failed to sum population for project flock %d: %+v", projectFlockID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch population data") } isGrowing := strings.EqualFold(project.Category, string(utils.ProjectFlockCategoryGrowing)) - projectFlockKandangIDs, err := s.getProjectFlockKandangIDs(c.Context(), projectFlockID) + currentWeek, err := s.determineProductionWeek(c.Context(), projectFlockKandangIDs) if err != nil { - s.Log.Errorf("Failed to fetch project flock kandangs for %d: %+v", projectFlockID, err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandangs") + s.Log.Errorf("Failed to determine production week for project flock %d: %+v", projectFlockID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to determine production week") + } + + targetAverages, err := s.RecordingRepo.GetAverageTargetMetricsByProjectFlockKandangID(c.Context(), projectFlockKandangIDs[0], !isGrowing) + if err != nil { + s.Log.Errorf("Failed to calculate target metrics for project flock %d: %+v", projectFlockID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch target metrics data") + } + var fcrActFromRecording *float64 + if targetAverages.FcrCount > 0 { + fcrAvg := targetAverages.FcrAvg + fcrActFromRecording = &fcrAvg } feedIn, feedUsed, err := s.Repository.SumFeedPurchaseAndUsedByProjectFlockKandangIDs(c.Context(), projectFlockKandangIDs) @@ -555,6 +583,30 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch feed purchase data") } + averageFeedIntake := targetAverages.FeedIntakeAvg + + feedIntakeStd := 0.0 + if project.ProductionStandardId > 0 && currentWeek > 0 && s.StandardGrowthDetailRepo != nil { + feedIntakeStd, err = s.calculateFeedIntakeStd(c.Context(), project.ProductionStandardId, currentWeek) + if err != nil { + s.Log.Errorf("Failed to compute feed intake std for project flock %d: %+v", projectFlockID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch feed intake standard data") + } + } + + var productionStandardDetail *entity.ProductionStandardDetail + if project.ProductionStandardId > 0 && currentWeek > 0 && s.ProductionStandardDetailRepo != nil { + productionStandardDetail, err = s.ProductionStandardDetailRepo.GetByStandardIDAndWeek(c.Context(), project.ProductionStandardId, currentWeek) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + productionStandardDetail = nil + } else { + s.Log.Errorf("Failed to fetch production standard detail for project flock %d: %+v", projectFlockID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch production standard detail data") + } + } + } + claimCulling, err := s.Repository.SumClaimCullingByProjectFlockKandangIDs(c.Context(), projectFlockKandangIDs) if err != nil { s.Log.Errorf("Failed to sum claim culling for project flock %d: %+v", projectFlockID, err) @@ -577,10 +629,10 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch sales age data") } - feedUsedPerHead := 0.0 - if population > 0 { - feedUsedPerHead = feedUsed / population - } + // feedUsedPerHead := 0.0 + // if population > 0 { + // feedUsedPerHead = feedUsed / population + // } purchase := dto.ClosingPurchaseDTO{ InitialPopulation: int(population), @@ -588,7 +640,7 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint FinalPopulation: int(finalPopulation), FeedIn: feedIn, FeedUsed: feedUsed, - FeedUsedPerHead: feedUsedPerHead, + // FeedUsedPerHead: feedUsedPerHead, } chickenFlagNames := []string{string(utils.FlagPullet)} @@ -621,6 +673,9 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint } chickenPerformance := calculatePerformanceMetrics(chickenAverageWeight, chickenSalesWeight, feedUsed, population, chickenDepletion, age, standards) + if fcrActFromRecording != nil { + chickenPerformance.FcrAct = *fcrActFromRecording + } var eggSales *dto.ClosingEggSalesDTO var eggPerformance *dto.ClosingPerformanceDTO @@ -668,6 +723,9 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint } eggPerf := calculatePerformanceMetrics(averageEggWeight, eggSalesWeight, feedUsed, harvestEggQty, eggDepletion, age, standards) + if fcrActFromRecording != nil { + eggPerf.FcrAct = *fcrActFromRecording + } eggPerformance = &eggPerf } @@ -684,15 +742,55 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint DeffMortality: chickenPerformance.DeffMortality, } if eggPerformance != nil { - performance.FcrStd = eggPerformance.FcrStd + // performance.FcrStd = eggPerformance.FcrStd performance.FcrAct = eggPerformance.FcrAct - performance.DeffFcr = eggPerformance.DeffFcr - performance.Awg = eggPerformance.Awg + // performance.DeffFcr = eggPerformance.DeffFcr + performance.AwgAct = eggPerformance.AwgAct } else { - performance.FcrStd = chickenPerformance.FcrStd + // performance.FcrStd = chickenPerformance.FcrStd performance.FcrAct = chickenPerformance.FcrAct - performance.DeffFcr = chickenPerformance.DeffFcr - performance.Awg = chickenPerformance.Awg + // performance.DeffFcr = chickenPerformance.DeffFcr + performance.AwgAct = chickenPerformance.AwgAct + } + performance.FeedIntake = averageFeedIntake + performance.FeedIntakeStd = feedIntakeStd + if !isGrowing { + if targetAverages.HenDayCount > 0 { + henDayAct := targetAverages.HenDayAvg + performance.HenDayAct = &henDayAct + } + if targetAverages.HenHouseCount > 0 { + henHouseAct := targetAverages.HenHouseAvg + performance.HenHouseAct = &henHouseAct + } + if targetAverages.EggWeightCount > 0 { + eggWeight := targetAverages.EggWeightAvg + performance.EggWeight = &eggWeight + } + if targetAverages.EggMassCount > 0 { + eggMass := targetAverages.EggMassAvg + performance.EggMass = &eggMass + } + } + performance.DeffFcr = performance.FcrStd - performance.FcrAct + if productionStandardDetail != nil { + if productionStandardDetail.StandardFCR != nil { + performance.FcrStd = *productionStandardDetail.StandardFCR + } + if !isGrowing { + if productionStandardDetail.TargetHenDayProduction != nil { + performance.HendayStd = productionStandardDetail.TargetHenDayProduction + } + if productionStandardDetail.TargetHenHouseProduction != nil { + performance.HenHouseStd = productionStandardDetail.TargetHenHouseProduction + } + if productionStandardDetail.TargetEggWeight != nil { + performance.EggWeightStd = productionStandardDetail.TargetEggWeight + } + if productionStandardDetail.TargetEggMass != nil { + performance.EggMassStd = productionStandardDetail.TargetEggMass + } + } } result := dto.ClosingProductionReportDTO{ @@ -738,6 +836,64 @@ func (s closingService) calculateAverageSalesAge(ctx context.Context, projectFlo return totalAgeWeeks / totalQty, nil } +func (s closingService) determineProductionWeek(ctx context.Context, projectFlockKandangIDs []uint) (int, error) { + if len(projectFlockKandangIDs) == 0 { + return 0, nil + } + + firstKandangID := projectFlockKandangIDs[0] + + var chickin entity.ProjectChickin + if err := s.Repository.DB().WithContext(ctx). + Where("project_flock_kandang_id = ?", firstKandangID). + Order("chick_in_date ASC"). + First(&chickin).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return 0, nil + } + return 0, err + } + + recording, err := s.RecordingRepo.GetLatestByProjectFlockKandangID(ctx, firstKandangID) + if err != nil { + return 0, err + } + if recording == nil { + return 0, nil + } + + if recording.RecordDatetime.Before(chickin.ChickInDate) { + return 0, nil + } + + elapsed := recording.RecordDatetime.Sub(chickin.ChickInDate) + weekFloat := elapsed.Hours() / (24 * 7) + week := int(math.Ceil(weekFloat)) + if week <= 0 { + week = 1 + } + + return week, nil +} + +func (s closingService) calculateFeedIntakeStd(ctx context.Context, productionStandardID uint, week int) (float64, error) { + if productionStandardID == 0 || week <= 0 || s.StandardGrowthDetailRepo == nil { + return 0, nil + } + + detail, err := s.StandardGrowthDetailRepo.GetByStandardIDAndWeek(ctx, productionStandardID, week) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return 0, nil + } + return 0, err + } + if detail == nil || detail.FeedIntake == nil { + return 0, nil + } + return *detail.FeedIntake, nil +} + func calculatePerformanceMetrics(averageWeight, totalWeight, feedUsed, basePopulation, depletion, age float64, standards []entity.FcrStandard) dto.ClosingPerformanceDTO { mortalityStd, fcrStd := closestFcrValues(standards, averageWeight) @@ -768,7 +924,7 @@ func calculatePerformanceMetrics(averageWeight, totalWeight, feedUsed, basePopul FcrStd: fcrStd, FcrAct: fcrAct, DeffFcr: deffFcr, - Awg: awg, + AwgAct: awg, } } diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index 941d4507..d15b934d 100644 --- a/internal/modules/production/recordings/repositories/recording.repository.go +++ b/internal/modules/production/recordings/repositories/recording.repository.go @@ -48,12 +48,28 @@ type RecordingRepository interface { GetTotalDepletionByProjectFlockID(ctx context.Context, projectFlockID uint) (totalDepletion float64, err error) GetLatestAvgWeightByProjectFlockID(ctx context.Context, projectFlockID uint) (avgWeight float64, err error) GetTotalEggProductionWeightByProjectFlockID(ctx context.Context, projectFlockID uint) (totalWeightKg float64, err error) + GetAverageTargetMetricsByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint, includeTargets bool) (RecordingTargetAverages, error) } type RecordingRepositoryImpl struct { *repository.BaseRepositoryImpl[entity.Recording] } +type RecordingTargetAverages struct { + HenDayAvg float64 + HenDayCount int64 + HenHouseAvg float64 + HenHouseCount int64 + EggWeightAvg float64 + EggWeightCount int64 + EggMassAvg float64 + EggMassCount int64 + FeedIntakeAvg float64 + FeedIntakeCount int64 + FcrAvg float64 + FcrCount int64 +} + func NewRecordingRepository(db *gorm.DB) RecordingRepository { return &RecordingRepositoryImpl{ BaseRepositoryImpl: repository.NewBaseRepository[entity.Recording](db), @@ -433,6 +449,63 @@ func (r *RecordingRepositoryImpl) GetTotalEggProductionWeightByProjectFlockID(ct return result, err } +func (r *RecordingRepositoryImpl) GetAverageTargetMetricsByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint, includeTargets bool) (RecordingTargetAverages, error) { + var row struct { + HenDayTotal float64 + HenHouseTotal float64 + EggWeightTotal float64 + EggMassTotal float64 + FeedIntakeTotal float64 + FcrTotal float64 + TotalCount int64 + } + + selectParts := []string{ + "COALESCE(SUM(feed_intake), 0) AS feed_intake_total", + "COALESCE(SUM(fcr_value), 0) AS fcr_total", + "COUNT(*) AS total_count", + } + if includeTargets { + selectParts = append([]string{ + "COALESCE(SUM(hen_day), 0) AS hen_day_total", + "COALESCE(SUM(hen_house), 0) AS hen_house_total", + "COALESCE(SUM(egg_weight), 0) AS egg_weight_total", + "COALESCE(SUM(egg_mass), 0) AS egg_mass_total", + }, selectParts...) + } + + if err := r.DB().WithContext(ctx). + Table("recordings"). + Select(strings.Join(selectParts, ", ")). + Where("project_flock_kandangs_id = ? AND deleted_at IS NULL", projectFlockKandangID). + Scan(&row).Error; err != nil { + return RecordingTargetAverages{}, err + } + + result := RecordingTargetAverages{ + FeedIntakeCount: row.TotalCount, + FcrCount: row.TotalCount, + } + if includeTargets { + result.HenDayCount = row.TotalCount + result.HenHouseCount = row.TotalCount + result.EggWeightCount = row.TotalCount + result.EggMassCount = row.TotalCount + } + if row.TotalCount > 0 { + if includeTargets { + result.HenDayAvg = row.HenDayTotal / float64(row.TotalCount) + result.HenHouseAvg = row.HenHouseTotal / float64(row.TotalCount) + result.EggWeightAvg = row.EggWeightTotal / float64(row.TotalCount) + result.EggMassAvg = row.EggMassTotal / float64(row.TotalCount) + } + result.FeedIntakeAvg = row.FeedIntakeTotal / float64(row.TotalCount) + result.FcrAvg = row.FcrTotal / float64(row.TotalCount) + } + + return result, nil +} + func nextRecordingDay(days []int) int { if len(days) == 0 { return 1 From 0f4cc6e3791ead16b42cf1fd698bd53e594dff97 Mon Sep 17 00:00:00 2001 From: MacBook Air M1 Date: Tue, 13 Jan 2026 15:32:43 +0700 Subject: [PATCH 05/15] adjust api closing data produksi --- .../closings/services/closing.service.go | 44 ++++++++--------- .../repositories/recording.repository.go | 48 +++++++++++-------- 2 files changed, 49 insertions(+), 43 deletions(-) diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index 8b00475f..76f3e024 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -586,11 +586,21 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint averageFeedIntake := targetAverages.FeedIntakeAvg feedIntakeStd := 0.0 + var mortalityStdFromGrowth *float64 if project.ProductionStandardId > 0 && currentWeek > 0 && s.StandardGrowthDetailRepo != nil { - feedIntakeStd, err = s.calculateFeedIntakeStd(c.Context(), project.ProductionStandardId, currentWeek) - if err != nil { - s.Log.Errorf("Failed to compute feed intake std for project flock %d: %+v", projectFlockID, err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch feed intake standard data") + growthDetail, growthErr := s.StandardGrowthDetailRepo.GetByStandardIDAndWeek(c.Context(), project.ProductionStandardId, currentWeek) + if growthErr != nil { + if !errors.Is(growthErr, gorm.ErrRecordNotFound) { + s.Log.Errorf("Failed to fetch growth detail for project flock %d: %+v", projectFlockID, growthErr) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch growth standard data") + } + } else if growthDetail != nil { + if growthDetail.FeedIntake != nil { + feedIntakeStd = *growthDetail.FeedIntake + } + if growthDetail.MaxDepletion != nil { + mortalityStdFromGrowth = growthDetail.MaxDepletion + } } } @@ -754,6 +764,14 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint } performance.FeedIntake = averageFeedIntake performance.FeedIntakeStd = feedIntakeStd + if targetAverages.CumDepletionRateCount > 0 { + performance.MortalityAct = targetAverages.CumDepletionRateAvg + performance.DeffMortality = performance.MortalityAct - performance.MortalityStd + } + if mortalityStdFromGrowth != nil { + performance.MortalityStd = *mortalityStdFromGrowth + performance.DeffMortality = performance.MortalityAct - performance.MortalityStd + } if !isGrowing { if targetAverages.HenDayCount > 0 { henDayAct := targetAverages.HenDayAvg @@ -876,24 +894,6 @@ func (s closingService) determineProductionWeek(ctx context.Context, projectFloc return week, nil } -func (s closingService) calculateFeedIntakeStd(ctx context.Context, productionStandardID uint, week int) (float64, error) { - if productionStandardID == 0 || week <= 0 || s.StandardGrowthDetailRepo == nil { - return 0, nil - } - - detail, err := s.StandardGrowthDetailRepo.GetByStandardIDAndWeek(ctx, productionStandardID, week) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return 0, nil - } - return 0, err - } - if detail == nil || detail.FeedIntake == nil { - return 0, nil - } - return *detail.FeedIntake, nil -} - func calculatePerformanceMetrics(averageWeight, totalWeight, feedUsed, basePopulation, depletion, age float64, standards []entity.FcrStandard) dto.ClosingPerformanceDTO { mortalityStd, fcrStd := closestFcrValues(standards, averageWeight) diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index d15b934d..bf08b78b 100644 --- a/internal/modules/production/recordings/repositories/recording.repository.go +++ b/internal/modules/production/recordings/repositories/recording.repository.go @@ -56,18 +56,20 @@ type RecordingRepositoryImpl struct { } type RecordingTargetAverages struct { - HenDayAvg float64 - HenDayCount int64 - HenHouseAvg float64 - HenHouseCount int64 - EggWeightAvg float64 - EggWeightCount int64 - EggMassAvg float64 - EggMassCount int64 - FeedIntakeAvg float64 - FeedIntakeCount int64 - FcrAvg float64 - FcrCount int64 + HenDayAvg float64 + HenDayCount int64 + HenHouseAvg float64 + HenHouseCount int64 + EggWeightAvg float64 + EggWeightCount int64 + EggMassAvg float64 + EggMassCount int64 + FeedIntakeAvg float64 + FeedIntakeCount int64 + FcrAvg float64 + FcrCount int64 + CumDepletionRateAvg float64 + CumDepletionRateCount int64 } func NewRecordingRepository(db *gorm.DB) RecordingRepository { @@ -451,18 +453,20 @@ func (r *RecordingRepositoryImpl) GetTotalEggProductionWeightByProjectFlockID(ct func (r *RecordingRepositoryImpl) GetAverageTargetMetricsByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint, includeTargets bool) (RecordingTargetAverages, error) { var row struct { - HenDayTotal float64 - HenHouseTotal float64 - EggWeightTotal float64 - EggMassTotal float64 - FeedIntakeTotal float64 - FcrTotal float64 - TotalCount int64 + HenDayTotal float64 + HenHouseTotal float64 + EggWeightTotal float64 + EggMassTotal float64 + FeedIntakeTotal float64 + FcrTotal float64 + CumDepletionRateTotal float64 + TotalCount int64 } selectParts := []string{ "COALESCE(SUM(feed_intake), 0) AS feed_intake_total", "COALESCE(SUM(fcr_value), 0) AS fcr_total", + "COALESCE(SUM(cum_depletion_rate), 0) AS cum_depletion_rate_total", "COUNT(*) AS total_count", } if includeTargets { @@ -483,8 +487,9 @@ func (r *RecordingRepositoryImpl) GetAverageTargetMetricsByProjectFlockKandangID } result := RecordingTargetAverages{ - FeedIntakeCount: row.TotalCount, - FcrCount: row.TotalCount, + FeedIntakeCount: row.TotalCount, + FcrCount: row.TotalCount, + CumDepletionRateCount: row.TotalCount, } if includeTargets { result.HenDayCount = row.TotalCount @@ -501,6 +506,7 @@ func (r *RecordingRepositoryImpl) GetAverageTargetMetricsByProjectFlockKandangID } result.FeedIntakeAvg = row.FeedIntakeTotal / float64(row.TotalCount) result.FcrAvg = row.FcrTotal / float64(row.TotalCount) + result.CumDepletionRateAvg = row.CumDepletionRateTotal } return result, nil From 33e89d65ab0e76234a8c2295fb40dc7168d90f79 Mon Sep 17 00:00:00 2001 From: ragilap Date: Tue, 13 Jan 2026 15:37:54 +0700 Subject: [PATCH 06/15] [FIX/BE-US-281] changes calculate fcr egg --- .../modules/production/recordings/services/recording.service.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index ccf360b9..88ed4cf7 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -1178,7 +1178,7 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm var fcrValue float64 if usageInGrams > 0 && totalEggWeightGrams > 0 { - fcrValue = totalEggWeightGrams / usageInGrams + fcrValue = usageInGrams / totalEggWeightGrams updates["fcr_value"] = fcrValue recording.FcrValue = &fcrValue } else { From 7551d11888445d89f02a0aa218a0815f42fbe574 Mon Sep 17 00:00:00 2001 From: MacBook Air M1 Date: Tue, 13 Jan 2026 16:14:11 +0700 Subject: [PATCH 07/15] add filter by kandang id sapronak --- .../modules/closings/controllers/closing.controller.go | 9 +++++++++ internal/modules/closings/services/closing.service.go | 2 +- .../modules/closings/validations/closing.validation.go | 7 ++++--- .../repositories/production_result.repository.go | 1 - 4 files changed, 14 insertions(+), 5 deletions(-) diff --git a/internal/modules/closings/controllers/closing.controller.go b/internal/modules/closings/controllers/closing.controller.go index 8f129521..d837f5d6 100644 --- a/internal/modules/closings/controllers/closing.controller.go +++ b/internal/modules/closings/controllers/closing.controller.go @@ -162,6 +162,14 @@ func (u *ClosingController) GetClosingSapronak(c *fiber.Ctx) error { Page: c.QueryInt("page", 1), Limit: c.QueryInt("limit", 10), } + if raw := c.Query("kandang_id"); raw != "" { + kandangInt, convErr := strconv.Atoi(raw) + if convErr != nil || kandangInt <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid kandang_id") + } + kandangUint := uint(kandangInt) + query.KandangID = &kandangUint + } if query.Page < 1 || query.Limit < 1 { return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") @@ -346,6 +354,7 @@ func (u *ClosingController) GetClosingDataProduksi(c *fiber.Ctx) error { } kandangUint := uint(kandangInt) kandangID = &kandangUint + } result, err := u.ClosingService.GetClosingDataProduksi(c, uint(id), kandangID) diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index 76f3e024..d87568b6 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -236,7 +236,7 @@ func (s closingService) GetClosingSapronak(c *fiber.Ctx, projectFlockID uint, pa var projectFlockKandangIDs []uint if params.Type == validation.SapronakTypeOutgoing { - projectFlockKandangIDs, err = s.getProjectFlockKandangIDs(c.Context(), projectFlockID, nil) + projectFlockKandangIDs, err = s.getProjectFlockKandangIDs(c.Context(), projectFlockID, params.KandangID) if err != nil { s.Log.Errorf("Failed to fetch project flock kandang IDs for project flock %d: %+v", projectFlockID, err) return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandang") diff --git a/internal/modules/closings/validations/closing.validation.go b/internal/modules/closings/validations/closing.validation.go index 610e89b8..0c738407 100644 --- a/internal/modules/closings/validations/closing.validation.go +++ b/internal/modules/closings/validations/closing.validation.go @@ -20,7 +20,8 @@ const ( ) type ClosingSapronakQuery struct { - Type string `query:"type" validate:"required,oneof=incoming outgoing"` - Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` - Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + Type string `query:"type" validate:"required,oneof=incoming outgoing"` + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + KandangID *uint `query:"kandang_id" validate:"omitempty,gt=0"` } diff --git a/internal/modules/repports/repositories/production_result.repository.go b/internal/modules/repports/repositories/production_result.repository.go index f2decedf..19007d0f 100644 --- a/internal/modules/repports/repositories/production_result.repository.go +++ b/internal/modules/repports/repositories/production_result.repository.go @@ -59,7 +59,6 @@ func (r *productionResultRepositoryImpl) GetRecordingsByProjectFlockKandang( dataQuery := r.db.WithContext(ctx). Model(&entity.Recording{}). Where("project_flock_kandangs_id = ?", projectFlockKandangID). - Preload("BodyWeights"). Preload("Eggs", func(db *gorm.DB) *gorm.DB { return db.Select("recording_eggs.*, f.name AS product_flag_name"). Joins("LEFT JOIN product_warehouses pw ON pw.id = recording_eggs.product_warehouse_id"). From 5730053e045d7b8d0d5476869a82568374a8c15a Mon Sep 17 00:00:00 2001 From: ragilap Date: Tue, 13 Jan 2026 19:54:47 +0700 Subject: [PATCH 08/15] [FIX/BE-US-390] changes counting debt supplier --- .../repositories/debt_supplier.repository.go | 6 +- .../repports/services/repport.service.go | 97 +++++++------------ .../validations/repport.validation.go | 2 +- 3 files changed, 36 insertions(+), 69 deletions(-) diff --git a/internal/modules/repports/repositories/debt_supplier.repository.go b/internal/modules/repports/repositories/debt_supplier.repository.go index 3d415606..e8f548d6 100644 --- a/internal/modules/repports/repositories/debt_supplier.repository.go +++ b/internal/modules/repports/repositories/debt_supplier.repository.go @@ -31,11 +31,9 @@ func NewDebtSupplierRepository(db *gorm.DB) DebtSupplierRepository { func resolveDebtSupplierDateColumn(filterBy string) string { switch strings.ToLower(strings.TrimSpace(filterBy)) { - case "receive_date": - return "purchases.receive_date" case "po_date": return "purchases.po_date" - case "do_date", "received_date", "": + case "received_date", "": return "purchase_items.received_date" default: return "purchase_items.received_date" @@ -130,7 +128,7 @@ func (r *debtSupplierRepositoryImpl) GetPurchasesBySuppliers(ctx context.Context Preload("Warehouse.Area"). Order("purchase_items.id ASC") - if strings.EqualFold(strings.TrimSpace(filters.FilterBy), "do_date") || strings.EqualFold(strings.TrimSpace(filters.FilterBy), "received_date") || strings.TrimSpace(filters.FilterBy) == "" { + if strings.EqualFold(strings.TrimSpace(filters.FilterBy), "received_date") || strings.TrimSpace(filters.FilterBy) == "" { if filters.StartDate != "" { if dateFrom, err := utils.ParseDateString(filters.StartDate); err == nil { db = db.Where("DATE(purchase_items.received_date) >= ?", dateFrom) diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 5f3cbbad..c4883b72 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -642,7 +642,7 @@ func (s *repportService) GetPurchaseSupplier(c *fiber.Ctx, params *validation.Pu } func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSupplierQuery) ([]dto.DebtSupplierDTO, int64, error) { - if params.FilterBy == "" || strings.EqualFold(strings.TrimSpace(params.FilterBy), "do_date") { + if params.FilterBy == "" { params.FilterBy = "received_date" } @@ -681,25 +681,8 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu } purchasesBySupplier := make(map[uint][]entity.Purchase, len(supplierIDs)) - references := make([]string, 0) - seenRefs := make(map[string]struct{}) for _, purchase := range purchases { - supplierID := purchase.SupplierId - purchasesBySupplier[supplierID] = append(purchasesBySupplier[supplierID], purchase) - - reference := purchase.PrNumber - if purchase.PoNumber != nil && strings.TrimSpace(*purchase.PoNumber) != "" { - reference = *purchase.PoNumber - } - if _, exists := seenRefs[reference]; !exists { - seenRefs[reference] = struct{}{} - references = append(references, reference) - } - } - - paymentTotals, err := s.DebtSupplierRepo.GetPaymentTotalsByReferences(c.Context(), supplierIDs, references) - if err != nil { - return nil, 0, err + purchasesBySupplier[purchase.SupplierId] = append(purchasesBySupplier[purchase.SupplierId], purchase) } paymentsBySupplier := make(map[uint][]entity.Payment, len(supplierIDs)) @@ -724,6 +707,14 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu now := time.Now().In(location) result := make([]dto.DebtSupplierDTO, 0, len(supplierIDs)) + type debtSupplierRowItem struct { + Row dto.DebtSupplierRowDTO + SortTime time.Time + Order int + DeltaBalance float64 + CountTotals bool + } + for _, supplierID := range supplierIDs { supplier, exists := supplierMap[supplierID] if !exists { @@ -731,23 +722,13 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu } initialBalance := initialPaymentTotals[supplierID] - initialPurchaseTotals[supplierID] - items := purchasesBySupplier[supplierID] paymentItems := paymentsBySupplier[supplierID] - rows := make([]dto.DebtSupplierRowDTO, 0, len(items)+len(paymentItems)) total := dto.DebtSupplierTotalDTO{} - type debtSupplierRowItem struct { - Row dto.DebtSupplierRowDTO - SortTime time.Time - Order int - DeltaBalance float64 - CountTotals bool - } - combinedRows := make([]debtSupplierRowItem, 0, len(items)+len(paymentItems)) for _, purchase := range items { - row := buildDebtSupplierRow(purchase, paymentTotals, now, location) + row := buildDebtSupplierRow(purchase, now, location) sortTime := resolveDebtSupplierSortTime(purchase, params.FilterBy, location) combinedRows = append(combinedRows, debtSupplierRowItem{ Row: row, @@ -780,6 +761,7 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu balance := initialBalance for i := range combinedRows { balance += combinedRows[i].DeltaBalance + combinedRows[i].Row.DebtPrice = balance combinedRows[i].Row.Balance = balance if combinedRows[i].CountTotals { @@ -788,13 +770,13 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu total.Aging = row.Aging } total.TotalPrice += row.TotalPrice - total.PaymentPrice += row.PaymentPrice - total.DebtPrice += row.DebtPrice } else { - combinedRows[i].Row.DebtPrice = balance + total.PaymentPrice += combinedRows[i].Row.PaymentPrice } } + total.DebtPrice = balance + rows := make([]dto.DebtSupplierRowDTO, 0, len(combinedRows)) sortDesc := strings.EqualFold(params.SortOrder, "desc") if sortDesc { for i := len(combinedRows) - 1; i >= 0; i-- { @@ -823,18 +805,13 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu return result, totalSuppliers, nil } -func buildDebtSupplierRow(purchase entity.Purchase, paymentTotals map[string]float64, now time.Time, loc *time.Location) dto.DebtSupplierRowDTO { +func buildDebtSupplierRow(purchase entity.Purchase, now time.Time, loc *time.Location) dto.DebtSupplierRowDTO { prNumber := purchase.PrNumber poNumber := "" if purchase.PoNumber != nil { poNumber = *purchase.PoNumber } - reference := prNumber - if strings.TrimSpace(poNumber) != "" { - reference = poNumber - } - prDate := purchase.CreatedAt.In(loc) startDate := time.Date(prDate.Year(), prDate.Month(), prDate.Day(), 0, 0, 0, 0, loc) endDate := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc) @@ -877,9 +854,6 @@ func buildDebtSupplierRow(purchase entity.Purchase, paymentTotals map[string]flo } } - paymentPrice := paymentTotals[reference] - debtPrice := paymentPrice - totalPrice - dueDate := "" dueStatus := "-" if purchase.DueDate != nil && !purchase.DueDate.IsZero() { @@ -893,10 +867,6 @@ func buildDebtSupplierRow(purchase entity.Purchase, paymentTotals map[string]flo } status := "Belum Lunas" - if debtPrice >= 0 { - status = "Lunas" - } - poDate := "" if purchase.PoDate != nil && !purchase.PoDate.IsZero() { poDate = purchase.PoDate.In(loc).Format("2006-01-02") @@ -913,10 +883,11 @@ func buildDebtSupplierRow(purchase entity.Purchase, paymentTotals map[string]flo DueDate: dueDate, DueStatus: dueStatus, TotalPrice: totalPrice, - PaymentPrice: paymentPrice, - DebtPrice: debtPrice, + PaymentPrice: 0, + DebtPrice: 0, Status: status, TravelNumber: travelNumber, + Balance: 0, } } @@ -946,32 +917,30 @@ func buildDebtSupplierPaymentRow(payment entity.Payment, loc *time.Location) dto DebtPrice: 0, Status: "Pembayaran", TravelNumber: "-", + Balance: 0, } } func resolveDebtSupplierSortTime(purchase entity.Purchase, filterBy string, loc *time.Location) time.Time { - switch strings.ToLower(strings.TrimSpace(filterBy)) { - case "po_date": + if strings.EqualFold(strings.TrimSpace(filterBy), "po_date") { if purchase.PoDate != nil && !purchase.PoDate.IsZero() { return purchase.PoDate.In(loc) } - case "pr_date": - return purchase.CreatedAt.In(loc) - default: - earliest := time.Time{} - for _, item := range purchase.Items { - if item.ReceivedDate == nil || item.ReceivedDate.IsZero() { - continue - } - received := item.ReceivedDate.In(loc) - if earliest.IsZero() || received.Before(earliest) { - earliest = received - } + } + + earliest := time.Time{} + for _, item := range purchase.Items { + if item.ReceivedDate == nil || item.ReceivedDate.IsZero() { + continue } - if !earliest.IsZero() { - return earliest + received := item.ReceivedDate.In(loc) + if earliest.IsZero() || received.Before(earliest) { + earliest = received } } + if !earliest.IsZero() { + return earliest + } return purchase.CreatedAt.In(loc) } diff --git a/internal/modules/repports/validations/repport.validation.go b/internal/modules/repports/validations/repport.validation.go index 5b60a31f..5f166248 100644 --- a/internal/modules/repports/validations/repport.validation.go +++ b/internal/modules/repports/validations/repport.validation.go @@ -49,7 +49,7 @@ type DebtSupplierQuery struct { SupplierIDs []int64 `query:"-" validate:"omitempty,dive,gt=0"` StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"` EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"` - FilterBy string `query:"filter_by" validate:"omitempty,oneof=received_date po_date pr_date do_date"` + FilterBy string `query:"filter_by" validate:"omitempty,oneof=received_date po_date"` SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"` } From 7545e9b37d871c057e97cf5044fc792f40d92c5c Mon Sep 17 00:00:00 2001 From: ragilap Date: Tue, 13 Jan 2026 19:57:47 +0700 Subject: [PATCH 09/15] [FIX/BE-US] add ignore for dockerfile.local --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 4a814ebe..24887418 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ Makefile docker-compose.local.yml docker-compose.yaml Dockerfile +Dockerfile.local .gitlab-ci.yml # Go build cache .gocache/ From bd9d41e161e573b86382cfc9f0412a1f69bf5352 Mon Sep 17 00:00:00 2001 From: MacBook Air M1 Date: Wed, 14 Jan 2026 11:20:42 +0700 Subject: [PATCH 10/15] fix(BE): add .air.toml --- .air.toml | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .air.toml diff --git a/.air.toml b/.air.toml new file mode 100644 index 00000000..c463b5b2 --- /dev/null +++ b/.air.toml @@ -0,0 +1,13 @@ +# .air.toml +root = "." +tmp_dir = "tmp" + +[build] +cmd = "go build -buildvcs=false -o ./tmp/main ./cmd/api" +bin = "tmp/main" +full_bin = "APP_ENV=dev ./tmp/main" +include_ext = ["go", "tpl", "tmpl", "html"] +exclude_dir = ["vendor", "tmp"] + +[log] +time = true From 8a639f127c8aaf84546893f1811b445971c870d5 Mon Sep 17 00:00:00 2001 From: MacBook Air M1 Date: Wed, 14 Jan 2026 11:41:47 +0700 Subject: [PATCH 11/15] adjust api informasi umum filter per kandang --- .../controllers/closing.controller.go | 12 +- internal/modules/closings/dto/closing.dto.go | 41 ++++-- .../closings/services/closing.service.go | 127 +++++++++++++++++- 3 files changed, 164 insertions(+), 16 deletions(-) diff --git a/internal/modules/closings/controllers/closing.controller.go b/internal/modules/closings/controllers/closing.controller.go index c78fd15f..3e64f89b 100644 --- a/internal/modules/closings/controllers/closing.controller.go +++ b/internal/modules/closings/controllers/closing.controller.go @@ -116,7 +116,17 @@ func (u *ClosingController) GetClosingSummary(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, "Invalid projectFlockId") } - result, err := u.ClosingService.GetClosingSummary(c, uint(id)) + var kandangID *uint + if raw := c.Query("kandang_id"); raw != "" { + kandangInt, convErr := strconv.Atoi(raw) + if convErr != nil || kandangInt <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid kandang_id") + } + kandangUint := uint(kandangInt) + kandangID = &kandangUint + } + + result, err := u.ClosingService.GetClosingSummary(c, uint(id), kandangID) if err != nil { return err } diff --git a/internal/modules/closings/dto/closing.dto.go b/internal/modules/closings/dto/closing.dto.go index 05a606ca..470f506d 100644 --- a/internal/modules/closings/dto/closing.dto.go +++ b/internal/modules/closings/dto/closing.dto.go @@ -59,6 +59,21 @@ type ClosingSummaryDTO struct { StatusClosing string `json:"closing_status"` } +type ClosingSummaryKandangDTO struct { + FlockID uint `json:"flock_id"` + Period int `json:"period"` + LocationName string `json:"location_name"` + Population int `json:"population"` + PopulationFormatted string `json:"population_formatted"` + ProjectType string `json:"project_type"` + ClosingDate string `json:"closing_date"` + KandangName string `json:"kandang_name"` + ChickInDate string `json:"chick_in_date"` + PicName string `json:"pic_name"` + ApprovalDate string `json:"approval_date"` + ProjectStatus string `json:"project_status"` +} + type ClosingPurchaseDTO struct { InitialPopulation int `json:"initial_population"` ClaimCulling int `json:"claim_culling"` @@ -83,18 +98,18 @@ type ClosingEggSalesDTO struct { } type ClosingPerformanceDTO struct { - Depletion float64 `json:"depletion"` - Age float64 `json:"age_day"` - MortalityStd float64 `json:"mor_std"` - MortalityAct float64 `json:"mor_act"` - DeffMortality float64 `json:"mor_diff"` - FcrStd float64 `json:"fcr_std"` - FcrAct float64 `json:"fcr_act"` - DeffFcr float64 `json:"fcr_diff"` - AwgAct float64 `json:"awg_act"` - AwgStd float64 `json:"awg_std"` - FeedIntake float64 `json:"feed_intake"` - FeedIntakeStd float64 `json:"feed_intake_std"` + Depletion float64 `json:"depletion"` + Age float64 `json:"age_day"` + MortalityStd float64 `json:"mor_std"` + MortalityAct float64 `json:"mor_act"` + DeffMortality float64 `json:"mor_diff"` + FcrStd float64 `json:"fcr_std"` + FcrAct float64 `json:"fcr_act"` + DeffFcr float64 `json:"fcr_diff"` + AwgAct float64 `json:"awg_act"` + AwgStd float64 `json:"awg_std"` + FeedIntake float64 `json:"feed_intake"` + FeedIntakeStd float64 `json:"feed_intake_std"` HenDayAct *float64 `json:"hen_day_act,omitempty"` HendayStd *float64 `json:"hen_day_std,omitempty"` EggMass *float64 `json:"egg_mass,omitempty"` @@ -175,7 +190,7 @@ func sumPopulation(history []entity.ProjectFlockKandang) float64 { var total float64 for _, h := range history { for _, chickin := range h.Chickins { - total += chickin.UsageQty + chickin.PendingUsageQty + total += chickin.UsageQty } } return total diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index 0c543d05..38529b0d 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "math" "strconv" "strings" @@ -35,7 +36,7 @@ type ClosingService interface { GetAll(ctx *fiber.Ctx, params *validation.Query) ([]dto.ClosingListItemDTO, int64, error) GetProjectFlockByID(ctx *fiber.Ctx, id uint) (*entity.ProjectFlock, error) GetPenjualan(ctx *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) ([]entity.MarketingDeliveryProduct, error) - GetClosingSummary(ctx *fiber.Ctx, projectFlockID uint) (*dto.ClosingSummaryDTO, error) + GetClosingSummary(ctx *fiber.Ctx, projectFlockID uint, kandangID *uint) (any, error) GetClosingDataProduksi(ctx *fiber.Ctx, projectFlockID uint, kandangID *uint) (*dto.ClosingProductionReportDTO, error) GetOverhead(ctx *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) (*dto.OverheadListDTO, error) GetClosingSapronak(ctx *fiber.Ctx, projectFlockID uint, params *validation.ClosingSapronakQuery) ([]dto.ClosingSapronakItemDTO, int64, error) @@ -150,11 +151,15 @@ func (s closingService) GetPenjualan(c *fiber.Ctx, projectFlockID uint, projectF return realisasi, nil } -func (s closingService) GetClosingSummary(c *fiber.Ctx, projectFlockID uint) (*dto.ClosingSummaryDTO, error) { +func (s closingService) GetClosingSummary(c *fiber.Ctx, projectFlockID uint, kandangID *uint) (any, error) { if projectFlockID == 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project flock id") } + if kandangID != nil { + return s.getClosingSummaryByKandang(c.Context(), projectFlockID, *kandangID) + } + project, err := s.Repository.GetByID(c.Context(), projectFlockID, s.withClosingRelations) if errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusNotFound, "Project flock not found") @@ -175,6 +180,124 @@ func (s closingService) GetClosingSummary(c *fiber.Ctx, projectFlockID uint) (*d return &summary, nil } +func (s closingService) getClosingSummaryByKandang(ctx context.Context, projectFlockID uint, kandangID uint) (*dto.ClosingSummaryKandangDTO, error) { + if projectFlockID == 0 || kandangID == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project flock id or kandang id") + } + + db := s.Repository.DB().WithContext(ctx) + + var kandang entity.ProjectFlockKandang + if err := db. + Preload("Kandang"). + Preload("Kandang.Location"). + Preload("Kandang.Pic"). + Where("project_flock_id = ?", projectFlockID). + Where("kandang_id = ?", kandangID). + First(&kandang).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Project flock kandang not found") + } + s.Log.Errorf("Failed get project flock kandang %d/%d: %+v", projectFlockID, kandangID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandang") + } + + var project entity.ProjectFlock + if err := db. + Select("id", "category"). + First(&project, projectFlockID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Project flock not found") + } + s.Log.Errorf("Failed get project flock %d: %+v", projectFlockID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock") + } + + var population float64 + if err := db. + Table("project_flock_populations pfp"). + Joins("JOIN project_chickins pc ON pc.id = pfp.project_chickin_id"). + Where("pc.project_flock_kandang_id = ?", kandang.Id). + Select("COALESCE(SUM(pfp.total_qty), 0)"). + Scan(&population).Error; err != nil { + s.Log.Errorf("Failed to sum population for project flock kandang %d: %+v", kandang.Id, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch population data") + } + + var chickInDate time.Time + if err := db. + Table("project_chickins"). + Where("project_flock_kandang_id = ?", kandang.Id). + Select("MIN(chick_in_date)"). + Scan(&chickInDate).Error; err != nil { + s.Log.Errorf("Failed to fetch chick in date for project flock kandang %d: %+v", kandang.Id, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch chick in date") + } + + statusProject := "Belum Selesai" + var approvalDate string + if s.ApprovalSvc != nil { + records, _, err := s.ApprovalSvc.List(ctx, utils.ApprovalWorkflowProjectFlockKandang.String(), &kandang.Id, 1, 1000, "") + if err != nil { + s.Log.Errorf("Failed to fetch approvals for project flock kandang %d: %+v", kandang.Id, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch approval data") + } + + var ( + minStep uint16 + latestActionAt time.Time + ) + + for _, rec := range records { + if minStep == 0 || rec.StepNumber < minStep { + minStep = rec.StepNumber + } + + if latestActionAt.IsZero() || rec.ActionAt.After(latestActionAt) { + latestActionAt = rec.ActionAt + statusProject = rec.StepName + } + } + + if statusProject == "" && minStep > 0 { + if label, ok := approvalutils.ApprovalStepName(utils.ApprovalWorkflowProjectFlockKandang, approvalutils.ApprovalStep(minStep)); ok { + statusProject = label + } + } + + if !latestActionAt.IsZero() { + approvalDate = latestActionAt.Format("2006-01-02") + } + } + + closingDate := "" + if kandang.ClosedAt != nil { + closingDate = kandang.ClosedAt.Format("2006-01-02") + } + + chickInDateStr := "" + if !chickInDate.IsZero() { + chickInDateStr = chickInDate.Format("2006-01-02") + } + + populationInt := int(population) + + return &dto.ClosingSummaryKandangDTO{ + FlockID: projectFlockID, + Period: kandang.Period, + LocationName: kandang.Kandang.Location.Name, + Population: populationInt, + PopulationFormatted: fmt.Sprintf("%d Ekor", populationInt), + ProjectType: project.Category, + ClosingDate: closingDate, + KandangName: kandang.Kandang.Name, + ChickInDate: chickInDateStr, + PicName: kandang.Kandang.Pic.Name, + ApprovalDate: approvalDate, + ProjectStatus: statusProject, + }, nil +} + func (s closingService) GetClosingSapronak(c *fiber.Ctx, projectFlockID uint, params *validation.ClosingSapronakQuery) ([]dto.ClosingSapronakItemDTO, int64, error) { if projectFlockID == 0 { return nil, 0, fiber.NewError(fiber.StatusBadRequest, "Invalid project flock id") From 3012d260ec00a782fa1dff96d3188a94a816e524 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Wed, 14 Jan 2026 11:57:56 +0700 Subject: [PATCH 12/15] FIX[BE]: fixing error on report marketing dto --- .../modules/repports/dto/repportMarketing.dto.go | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/internal/modules/repports/dto/repportMarketing.dto.go b/internal/modules/repports/dto/repportMarketing.dto.go index 36df7a05..92ee9a77 100644 --- a/internal/modules/repports/dto/repportMarketing.dto.go +++ b/internal/modules/repports/dto/repportMarketing.dto.go @@ -302,6 +302,16 @@ func (p ProductRelationDTOFixed) MarshalJSON() ([]byte, error) { Suppliers []supplierDTO.SupplierRelationDTO `json:"suppliers"` } + suppliers := make([]supplierDTO.SupplierRelationDTO, len(p.ProductRelationDTO.Suppliers)) + for i, ps := range p.ProductRelationDTO.Suppliers { + suppliers[i] = supplierDTO.SupplierRelationDTO{ + Id: ps.Id, + Name: ps.Name, + Alias: ps.Alias, + Category: ps.Category, + } + } + return json.Marshal(&Alias{ Id: p.ProductRelationDTO.Id, Name: p.ProductRelationDTO.Name, @@ -310,6 +320,6 @@ func (p ProductRelationDTOFixed) MarshalJSON() ([]byte, error) { Uom: p.ProductRelationDTO.Uom, Flags: p.ProductRelationDTO.Flags, ProductCategory: p.ProductRelationDTO.ProductCategory, - Suppliers: p.ProductRelationDTO.Suppliers, + Suppliers: suppliers, }) } From e12c34db133a8deb086d4a147e01bbf0e71b89dd Mon Sep 17 00:00:00 2001 From: kris Date: Wed, 14 Jan 2026 06:18:47 +0000 Subject: [PATCH 13/15] Update .gitlab-ci.yml file --- .gitlab-ci.yml | 245 ++++++++++++++++--------------------------------- 1 file changed, 81 insertions(+), 164 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index b0e3883e..53f28b3e 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,173 +1,90 @@ stages: - - build - - migrate - deploy - - seed -default: - tags: - - self-hosted-stg - -workflow: - rules: - - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' - when: always - - when: never - -variables: - DOCKER_BUILDKIT: "1" - - IMAGE_TAG: "staging_${CI_COMMIT_SHORT_SHA}" - IMAGE_NAME: "${CI_REGISTRY_IMAGE}:${IMAGE_TAG}" - IMAGE_LATEST: "${CI_REGISTRY_IMAGE}:staging_latest" - - DEPLOY_DIR: "/opt/deploy/stg-lti-api" - COMPOSE_FILE: "docker-compose.yaml" - -# ========================= -# BUILD (AUTO) -# ========================= -build_staging: - stage: build - rules: - - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' - script: | - set -e - docker info - - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY" - - echo "✅ Build image: $IMAGE_NAME" - docker build -t "$IMAGE_NAME" -f Dockerfile . - - echo "✅ Push image: $IMAGE_NAME" - docker push "$IMAGE_NAME" - - echo "✅ Tag latest: $IMAGE_LATEST" - docker tag "$IMAGE_NAME" "$IMAGE_LATEST" - docker push "$IMAGE_LATEST" - - -# ========================= -# MIGRATE (AUTO) -# ========================= -migrate_staging: - stage: migrate - rules: - - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' - needs: - - job: build_staging - artifacts: false - script: | - set -e - echo "✅ Running migrations (staging) ..." - - cd "$DEPLOY_DIR" - test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found in $DEPLOY_DIR" && exit 1) - test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1) - - # ✅ load env dari server - set -a - . ./.env - set +a - - # ✅ validasi - test -n "$DB_HOST" || (echo "❌ DB_HOST empty" && exit 1) - test -n "$DB_PORT" || (echo "❌ DB_PORT empty" && exit 1) - test -n "$DB_USER" || (echo "❌ DB_USER empty" && exit 1) - test -n "$DB_PASSWORD" || (echo "❌ DB_PASSWORD empty" && exit 1) - test -n "$DB_NAME" || (echo "❌ DB_NAME empty" && exit 1) - - export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE:-disable}" - echo "✅ DATABASE_URL=$DATABASE_URL" - - # ✅ Pastikan postgres & redis ON (sesuaikan nama service compose kamu!) - echo "✅ Ensuring postgres & redis running ..." - docker compose -f "$COMPOSE_FILE" up -d stg-postgres-lti stg-redis-lti || true - - # ✅ Ambil network key dari compose - COMPOSE_NETWORK_KEY="$(docker compose -f "$COMPOSE_FILE" config | awk '/networks:/ {getline; print $1}' | tr -d ':')" - echo "✅ Compose network key: $COMPOSE_NETWORK_KEY" - - # ✅ Cari network name yang dipakai docker - NETWORK_NAME="$(docker network ls --format '{{.Name}}' | grep "_${COMPOSE_NETWORK_KEY}$" | head -n 1)" - test -n "$NETWORK_NAME" || (echo "❌ Cannot find docker network for compose ($COMPOSE_NETWORK_KEY)" && exit 1) - - echo "✅ Docker network detected: $NETWORK_NAME" - - # ✅ Migrations dari repo (CI workspace) - echo "✅ Checking migrations from repo..." - ls -lah "$CI_PROJECT_DIR/internal/database/migrations" - - echo "✅ Running migrations via migrate/migrate container" - set +e - out=$(docker run --rm \ - --network "$NETWORK_NAME" \ - -v "$CI_PROJECT_DIR/internal/database/migrations:/migrations:ro" \ - migrate/migrate:v4.15.2 \ - -path=/migrations -database "$DATABASE_URL" up 2>&1) - code=$? - set -e - - echo "$out" - - # ✅ Handle no change dengan benar (tidak false-success) - if echo "$out" | grep -qi "no change"; then - echo "✅ No change (already up to date)" - exit 0 - fi - - if [ $code -ne 0 ]; then - echo "❌ Migration failed with exit code $code" - exit $code - fi - - echo "✅ Migration applied successfully" - - -# ========================= -# DEPLOY (AUTO) -# ========================= -deploy_staging: +deploy-dev: stage: deploy - rules: - - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' - needs: - - job: migrate_staging - artifacts: false - - job: build_staging - artifacts: false - script: | - set -e - docker info - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY" + image: alpine:3.20 + variables: + DEPLOY_APP: "LTI-MBUGROUP" + # Opsional: kalau pakai submodule, ini bikin clone submodule pakai SSH juga + GIT_SUBMODULE_STRATEGY: recursive + GIT_DEPTH: "1" - cd "$DEPLOY_DIR" - test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found in $DEPLOY_DIR" && exit 1) - test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1) + before_script: + - echo "🧰 Installing dependencies..." + - apk update && apk add --no-cache openssh git curl bash - docker compose -f "$COMPOSE_FILE" pull - docker compose -f "$COMPOSE_FILE" up -d --force-recreate - docker image prune -f + # Setup SSH di runner + - mkdir -p ~/.ssh + - echo "$SSH_PRIVATE_KEY" | tr -d '\r' > ~/.ssh/id_rsa + - chmod 600 ~/.ssh/id_rsa + - eval "$(ssh-agent -s)" + - ssh-add ~/.ssh/id_rsa + # Trust host keys (server + gitlab) biar SSH gak nanya interaktif + - ssh-keyscan -H "$SERVER_IP" >> ~/.ssh/known_hosts + - ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts -# ========================= -# SEED (MANUAL) -# ========================= -seed_staging: - stage: seed - rules: - - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"' - needs: - - job: deploy_staging - artifacts: false - when: manual - allow_failure: false - script: | - set -e - cd "$DEPLOY_DIR" - test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found" && exit 1) - test -f .env || (echo "❌ .env not found" && exit 1) + script: + - echo "🚀 Deploying latest code to $SERVER_USER@$SERVER_IP" - docker compose -f "$COMPOSE_FILE" pull seed || true - docker compose -f "$COMPOSE_FILE" run --rm seed \ No newline at end of file + - > + if ssh -o StrictHostKeyChecking=no "$SERVER_USER@$SERVER_IP" " + set -e + + cd /home/devops/docker/deployment/development/lti-api + + # Pastikan remote origin SSH (antisipasi kalau pernah ke-set HTTPS) + git remote set-url origin git@gitlab.com:mbugroup/lti-api.git + + # Pastikan server percaya gitlab.com juga (untuk git fetch via SSH) + mkdir -p ~/.ssh + ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts + + # Fetch/reset pakai SSH + GIT_SSH_COMMAND='ssh -o StrictHostKeyChecking=no' git fetch origin development + git reset --hard origin/development + + docker compose restart dev-api-lti || docker compose up -d dev-api-lti + "; then + STATUS='success'; + else + STATUS='failed'; + fi; + + RUN_URL="${CI_PROJECT_URL}/-/pipelines/${CI_PIPELINE_ID}"; + + if [ "$STATUS" = "success" ]; then + COLOR=3066993; + TITLE="✅ Deployment API Succeeded"; + DESC="Deployment job on branch \`${CI_COMMIT_REF_NAME}\` completed successfully."; + else + COLOR=15158332; + TITLE="❌ Deployment API Failed Gaes"; + DESC="Deployment job on branch \`${CI_COMMIT_REF_NAME}\` failed."; + fi; + + echo "{ + \"username\": \"CI Bot\", + \"embeds\": [{ + \"title\": \"$TITLE\", + \"description\": \"$DESC\", + \"color\": $COLOR, + \"fields\": [ + {\"name\": \"Repository\", \"value\": \"${CI_PROJECT_PATH}\", \"inline\": true}, + {\"name\": \"Actor\", \"value\": \"${GITLAB_USER_LOGIN}\", \"inline\": true}, + {\"name\": \"Commit\", \"value\": \"${CI_COMMIT_SHA}\", \"inline\": false}, + {\"name\": \"Pipeline\", \"value\": \"[Open run](${RUN_URL})\", \"inline\": false} + ] + }] + }" > payload.json; + + echo "📡 Sending notification to Discord..."; + curl -sS -H "Content-Type: application/json" \ + -d @payload.json "$DISCORD_WEBHOOK_URL"; + + only: + - development + + environment: + name: development \ No newline at end of file From c2a89910fbc832666c32de653bc3056ad1dbd7ca Mon Sep 17 00:00:00 2001 From: Adnan Zahir Date: Wed, 14 Jan 2026 13:46:15 +0700 Subject: [PATCH 14/15] chore: test staging from development --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index da8394f1..5b502da1 100644 --- a/README.md +++ b/README.md @@ -110,4 +110,4 @@ IT Development PT Mitra Berlian Unggas Group ## 📃 License -This project is private. All rights reserved. +> This project is private. All rights reserved. From 2a884a8d09a069f1841096fea86fe87dd957003a Mon Sep 17 00:00:00 2001 From: kris Date: Wed, 14 Jan 2026 07:35:57 +0000 Subject: [PATCH 15/15] Edit .gitignore (Tom haye) --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 24887418..3522e1f6 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,7 @@ main bin/ *.exe *.out - +.air.toml Makefile docker-compose.local.yml docker-compose.yaml