From 80f190b69bdd99fe33f7a654d5a7dee926ff1bde Mon Sep 17 00:00:00 2001 From: giovanni Date: Wed, 8 Apr 2026 13:41:17 +0700 Subject: [PATCH 01/17] fix get detail marketing delivery --- .../salesorder_delivery_product.repository.go | 31 +++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go index f0216570..f9f00273 100644 --- a/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go +++ b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go @@ -31,6 +31,8 @@ type MarketingDeliveryProductRepositoryImpl struct { *commonRepo.BaseRepositoryImpl[entity.MarketingDeliveryProduct] } +const marketingDeliveryProductSelectWithNullAttributed = "marketing_delivery_products.*, NULL AS attributed_project_flock_kandang_id" + func NewMarketingDeliveryProductRepository(db *gorm.DB) MarketingDeliveryProductRepository { return &MarketingDeliveryProductRepositoryImpl{ BaseRepositoryImpl: commonRepo.NewBaseRepository[entity.MarketingDeliveryProduct](db), @@ -43,9 +45,9 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetDeliveryProductsByProjectFlo attributionQuery := commonRepo.MarketingDeliveryAttributionRowsQuery(r.DB().WithContext(ctx)) db := r.DB().WithContext(ctx). + Select("DISTINCT "+marketingDeliveryProductSelectWithNullAttributed). Joins("JOIN (?) AS mda ON mda.marketing_delivery_product_id = marketing_delivery_products.id", attributionQuery). - Where("mda.project_flock_id = ?", projectFlockID). - Distinct("marketing_delivery_products.*") + Where("mda.project_flock_id = ?", projectFlockID) if callback != nil { db = callback(db) @@ -110,6 +112,7 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetByMarketingId(ctx context.Co // JOIN untuk filter by marketing_id yang ada di related table db := r.DB().WithContext(ctx). + Select(marketingDeliveryProductSelectWithNullAttributed). Joins("JOIN marketing_products ON marketing_products.id = marketing_delivery_products.marketing_product_id"). Where("marketing_products.marketing_id = ?", marketingId) @@ -124,6 +127,8 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetByMarketingProductID(ctx con var deliveryProduct entity.MarketingDeliveryProduct if err := r.DB().WithContext(ctx). + Model(&entity.MarketingDeliveryProduct{}). + Select(marketingDeliveryProductSelectWithNullAttributed). Where("marketing_product_id = ?", marketingProductID). First(&deliveryProduct).Error; err != nil { return nil, err @@ -132,6 +137,27 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetByMarketingProductID(ctx con return &deliveryProduct, nil } +func (r *MarketingDeliveryProductRepositoryImpl) GetByID( + ctx context.Context, + id uint, + modifier func(*gorm.DB) *gorm.DB, +) (*entity.MarketingDeliveryProduct, error) { + var deliveryProduct entity.MarketingDeliveryProduct + + q := r.DB().WithContext(ctx). + Model(&entity.MarketingDeliveryProduct{}). + Select(marketingDeliveryProductSelectWithNullAttributed) + if modifier != nil { + q = modifier(q) + } + + if err := q.First(&deliveryProduct, id).Error; err != nil { + return nil, err + } + + return &deliveryProduct, nil +} + func (r *MarketingDeliveryProductRepositoryImpl) GetAttributionRowsByDeliveryProductIDs(ctx context.Context, deliveryProductIDs []uint) ([]commonRepo.MarketingDeliveryAttributionRow, error) { if len(deliveryProductIDs) == 0 { return []commonRepo.MarketingDeliveryAttributionRow{}, nil @@ -211,6 +237,7 @@ func (r *MarketingDeliveryProductRepositoryImpl) fetchClosingDeliveryProducts( } query := r.closingDeliveryProductsQuery(ctx). + Select(marketingDeliveryProductSelectWithNullAttributed). Where("marketing_delivery_products.id IN ?", deliveryIDs). Order("marketing_delivery_products.delivery_date DESC") From 2a3154042cc1aa75f77b99ca45a191e4c0c282b7 Mon Sep 17 00:00:00 2001 From: ragilap Date: Wed, 1 Apr 2026 15:58:00 +0700 Subject: [PATCH 02/17] fix filter purchase query param and search --- .../purchases/services/purchase.service.go | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index a48e103d..5afc24a0 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -175,6 +175,7 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti for i := range approvalStatuses { approvalStatuses[i] = normalizeApprovalStatusFilter(approvalStatuses[i]) } + approvalStatus := normalizeApprovalStatusFilter(params.ApprovalStatus) purchases, total, err := s.PurchaseRepo.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) @@ -195,9 +196,14 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti db = db.Where("purchases.po_date >= ?", *poDateStart) } + if poDateStart != nil { + db = db.Where("purchases.po_date >= ?", *poDateStart) + } + if poDateEnd != nil { db = db.Where("purchases.po_date < ?", *poDateEnd) } + if scope.Restrict { if len(scope.IDs) == 0 { return db.Where("1 = 0") @@ -320,6 +326,70 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti ) } + if approvalStatus != "" { + approvalLike := "%" + approvalStatus + "%" + db = db.Where( + `EXISTS ( + SELECT 1 + FROM approvals a + WHERE a.approvable_type = ? + AND a.approvable_id = purchases.id + AND a.id = ( + SELECT a2.id + FROM approvals a2 + WHERE a2.approvable_type = ? + AND a2.approvable_id = purchases.id + ORDER BY a2.action_at DESC, a2.id DESC + LIMIT 1 + ) + AND ( + LOWER(COALESCE(a.step_name, '')) LIKE ? + OR LOWER(COALESCE(CAST(a.action AS TEXT), '')) LIKE ? + OR CAST(a.step_number AS TEXT) = ? + ) + )`, + utils.ApprovalWorkflowPurchase.String(), + utils.ApprovalWorkflowPurchase.String(), + approvalLike, + approvalLike, + approvalStatus, + ) + } + + if search != "" { + like := "%" + search + "%" + db = db.Where( + `( + LOWER(COALESCE(purchases.pr_number, '')) LIKE ? + OR LOWER(COALESCE(purchases.po_number, '')) LIKE ? + OR EXISTS ( + SELECT 1 + FROM suppliers s + WHERE s.id = purchases.supplier_id + AND LOWER(COALESCE(s.name, '')) LIKE ? + ) + OR EXISTS ( + SELECT 1 + FROM users u + WHERE u.id = purchases.created_by + AND LOWER(COALESCE(u.name, '')) LIKE ? + ) + OR EXISTS ( + SELECT 1 + FROM purchase_items pi + JOIN products p ON p.id = pi.product_id + WHERE pi.purchase_id = purchases.id + AND LOWER(COALESCE(p.name, '')) LIKE ? + ) + )`, + like, + like, + like, + like, + like, + ) + } + return db.Order("created_at DESC").Order("purchases.id DESC") }) From aa9863646ecf582d00d5cde822d930002a75200a Mon Sep 17 00:00:00 2001 From: ragilap Date: Wed, 1 Apr 2026 16:06:57 +0700 Subject: [PATCH 03/17] fix filter purchase ?approval_status=approved,rejected and ?product_category_id=1,2,3 --- .../purchases/services/purchase.service.go | 64 ++++++++++--------- 1 file changed, 35 insertions(+), 29 deletions(-) diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index 5afc24a0..3617ceff 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -175,7 +175,6 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti for i := range approvalStatuses { approvalStatuses[i] = normalizeApprovalStatusFilter(approvalStatuses[i]) } - approvalStatus := normalizeApprovalStatusFilter(params.ApprovalStatus) purchases, total, err := s.PurchaseRepo.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) @@ -326,34 +325,41 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti ) } - if approvalStatus != "" { - approvalLike := "%" + approvalStatus + "%" - db = db.Where( - `EXISTS ( - SELECT 1 - FROM approvals a - WHERE a.approvable_type = ? - AND a.approvable_id = purchases.id - AND a.id = ( - SELECT a2.id - FROM approvals a2 - WHERE a2.approvable_type = ? - AND a2.approvable_id = purchases.id - ORDER BY a2.action_at DESC, a2.id DESC - LIMIT 1 - ) - AND ( - LOWER(COALESCE(a.step_name, '')) LIKE ? - OR LOWER(COALESCE(CAST(a.action AS TEXT), '')) LIKE ? - OR CAST(a.step_number AS TEXT) = ? - ) - )`, - utils.ApprovalWorkflowPurchase.String(), - utils.ApprovalWorkflowPurchase.String(), - approvalLike, - approvalLike, - approvalStatus, - ) + if len(approvalStatuses) > 0 { + approvalConditions := make([]string, 0, len(approvalStatuses)) + approvalArgs := make([]any, 0, 2+(len(approvalStatuses)*3)) + approvalArgs = append(approvalArgs, utils.ApprovalWorkflowPurchase.String(), utils.ApprovalWorkflowPurchase.String()) + for _, status := range approvalStatuses { + if status == "" { + continue + } + like := "%" + status + "%" + approvalConditions = append(approvalConditions, `(LOWER(COALESCE(a.step_name, '')) LIKE ? OR LOWER(COALESCE(CAST(a.action AS TEXT), '')) LIKE ? OR CAST(a.step_number AS TEXT) = ?)`) + approvalArgs = append(approvalArgs, like, like, status) + } + + if len(approvalConditions) > 0 { + approvalClause := strings.Join(approvalConditions, " OR ") + approvalQuery := fmt.Sprintf( + `EXISTS ( + SELECT 1 + FROM approvals a + WHERE a.approvable_type = ? + AND a.approvable_id = purchases.id + AND a.id = ( + SELECT a2.id + FROM approvals a2 + WHERE a2.approvable_type = ? + AND a2.approvable_id = purchases.id + ORDER BY a2.action_at DESC, a2.id DESC + LIMIT 1 + ) + AND (%s) + )`, + approvalClause, + ) + db = db.Where(approvalQuery, approvalArgs...) + } } if search != "" { From b58e9a10b1c9372e2f49f9cff82b16e2b0c58c17 Mon Sep 17 00:00:00 2001 From: ragilap Date: Wed, 1 Apr 2026 16:25:04 +0700 Subject: [PATCH 04/17] fix filter purchase supplier repport --- internal/modules/repports/controllers/repport.controller.go | 1 + .../repports/repositories/purchase_supplier.repository.go | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/modules/repports/controllers/repport.controller.go b/internal/modules/repports/controllers/repport.controller.go index 5e33d2a0..5d85a53e 100644 --- a/internal/modules/repports/controllers/repport.controller.go +++ b/internal/modules/repports/controllers/repport.controller.go @@ -432,6 +432,7 @@ func (c *RepportController) GetProductionResult(ctx *fiber.Ctx) error { func parseCommaSeparatedInt64s(raw string) ([]int64, error) { return parseCommaSeparatedInt64sWithField(raw, "supplier_ids") } + func parseCommaSeparatedInt64sWithField(raw, field string) ([]int64, error) { raw = strings.TrimSpace(raw) if raw == "" { diff --git a/internal/modules/repports/repositories/purchase_supplier.repository.go b/internal/modules/repports/repositories/purchase_supplier.repository.go index d4860d3d..f484a6f1 100644 --- a/internal/modules/repports/repositories/purchase_supplier.repository.go +++ b/internal/modules/repports/repositories/purchase_supplier.repository.go @@ -71,7 +71,7 @@ func (r *purchaseSupplierRepositoryImpl) baseSupplierQuery(ctx context.Context, if len(filters.ProductCategoryIDs) > 0 { db = db. Joins("JOIN products ON products.id = purchase_items.product_id"). - Where("products.product_category_id IN ?", filters.ProductCategoryIDs) + Where("products.product_category_id IN ?", filters.ProductCategoryIDs) } if len(filters.AreaIDs) > 0 || filters.AllowedAreaIDs != nil { @@ -194,7 +194,7 @@ func (r *purchaseSupplierRepositoryImpl) GetItemsBySuppliers(ctx context.Context if len(filters.ProductCategoryIDs) > 0 { db = db. Joins("JOIN products ON products.id = purchase_items.product_id"). - Where("products.product_category_id IN ?", filters.ProductCategoryIDs) + Where("products.product_category_id IN ?", filters.ProductCategoryIDs) } if len(filters.AreaIDs) > 0 || filters.AllowedAreaIDs != nil { db = db.Joins("JOIN warehouses ON warehouses.id = purchase_items.warehouse_id") From 450d1e8ceed6cd8de055c921d222ce7810d7fbd7 Mon Sep 17 00:00:00 2001 From: giovanni Date: Wed, 8 Apr 2026 14:24:04 +0700 Subject: [PATCH 05/17] add filter lokasi and bop to purchase order --- .../purchases/services/purchase.service.go | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index 3617ceff..0b360b1e 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -316,12 +316,30 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti WHERE pi.purchase_id = purchases.id AND LOWER(COALESCE(p.name, '')) LIKE ? ) + OR EXISTS ( + SELECT 1 + FROM purchase_items pi + JOIN warehouses w ON w.id = pi.warehouse_id + JOIN locations l ON l.id = w.location_id + WHERE pi.purchase_id = purchases.id + AND LOWER(COALESCE(l.name, '')) LIKE ? + ) + OR EXISTS ( + SELECT 1 + FROM purchase_items pi + JOIN expense_nonstocks en ON en.id = pi.expense_nonstock_id + JOIN expenses e ON e.id = en.expense_id + WHERE pi.purchase_id = purchases.id + AND LOWER(COALESCE(e.reference_number, '')) LIKE ? + ) )`, like, like, like, like, like, + like, + like, ) } @@ -387,12 +405,30 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti WHERE pi.purchase_id = purchases.id AND LOWER(COALESCE(p.name, '')) LIKE ? ) + OR EXISTS ( + SELECT 1 + FROM purchase_items pi + JOIN warehouses w ON w.id = pi.warehouse_id + JOIN locations l ON l.id = w.location_id + WHERE pi.purchase_id = purchases.id + AND LOWER(COALESCE(l.name, '')) LIKE ? + ) + OR EXISTS ( + SELECT 1 + FROM purchase_items pi + JOIN expense_nonstocks en ON en.id = pi.expense_nonstock_id + JOIN expenses e ON e.id = en.expense_id + WHERE pi.purchase_id = purchases.id + AND LOWER(COALESCE(e.reference_number, '')) LIKE ? + ) )`, like, like, like, like, like, + like, + like, ) } From a6995f8e1857dc328604f27619744104c2fe6259 Mon Sep 17 00:00:00 2001 From: giovanni Date: Wed, 8 Apr 2026 16:18:55 +0700 Subject: [PATCH 06/17] fix edit receipt purchase --- .../repositories/purchase.repository.go | 3 + .../purchases/services/expense_bridge.go | 92 +++++++++++++++++++ .../purchases/services/purchase.service.go | 19 ++-- 3 files changed, 108 insertions(+), 6 deletions(-) diff --git a/internal/modules/purchases/repositories/purchase.repository.go b/internal/modules/purchases/repositories/purchase.repository.go index 56e6b8c6..2f5818c4 100644 --- a/internal/modules/purchases/repositories/purchase.repository.go +++ b/internal/modules/purchases/repositories/purchase.repository.go @@ -169,6 +169,7 @@ type PurchaseReceivingUpdate struct { TravelNumber *string TravelDocumentPath *string VehicleNumber *string + ClearVehicleNumber bool ReceivedQty *float64 WarehouseID *uint ProductWarehouseID *uint @@ -246,6 +247,8 @@ func (r *PurchaseRepositoryImpl) UpdateReceivingDetails( } if upd.VehicleNumber != nil { data["vehicle_number"] = upd.VehicleNumber + } else if upd.ClearVehicleNumber { + data["vehicle_number"] = gorm.Expr("NULL") } if upd.WarehouseID != nil && *upd.WarehouseID != 0 { data["warehouse_id"] = upd.WarehouseID diff --git a/internal/modules/purchases/services/expense_bridge.go b/internal/modules/purchases/services/expense_bridge.go index d3bf2bbf..ec6c51d2 100644 --- a/internal/modules/purchases/services/expense_bridge.go +++ b/internal/modules/purchases/services/expense_bridge.go @@ -183,15 +183,102 @@ func (b *expenseBridge) markExpensesUpdated(ctx context.Context, expenseIDs map[ return nil } +func (b *expenseBridge) clearExpenseLinksForItems(ctx context.Context, itemIDs []uint) error { + if len(itemIDs) == 0 { + return nil + } + + unique := make(map[uint]struct{}, len(itemIDs)) + normalized := make([]uint, 0, len(itemIDs)) + for _, id := range itemIDs { + if id == 0 { + continue + } + if _, exists := unique[id]; exists { + continue + } + unique[id] = struct{}{} + normalized = append(normalized, id) + } + if len(normalized) == 0 { + return nil + } + + rows := make([]struct { + ItemID uint + ExpenseNonstockID *uint64 + ExpenseID *uint64 + }, 0, len(normalized)) + if err := b.db.WithContext(ctx). + Table("purchase_items pi"). + Select("pi.id as item_id, pi.expense_nonstock_id, en.expense_id"). + Joins("LEFT JOIN expense_nonstocks en ON en.id = pi.expense_nonstock_id"). + Where("pi.id IN ?", normalized). + Scan(&rows).Error; err != nil { + return err + } + + expenseNonstockIDs := make([]uint64, 0, len(rows)) + expenseIDs := make(map[uint64]struct{}) + for _, row := range rows { + if row.ExpenseNonstockID != nil && *row.ExpenseNonstockID != 0 { + expenseNonstockIDs = append(expenseNonstockIDs, *row.ExpenseNonstockID) + } + if row.ExpenseID != nil && *row.ExpenseID != 0 { + expenseIDs[*row.ExpenseID] = struct{}{} + } + } + + return b.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&entity.PurchaseItem{}). + Where("id IN ?", normalized). + Update("expense_nonstock_id", gorm.Expr("NULL")).Error; err != nil { + return err + } + + if len(expenseNonstockIDs) > 0 { + if err := tx.Where("id IN ?", expenseNonstockIDs).Delete(&entity.ExpenseNonstock{}).Error; err != nil { + return err + } + } + + approvalRepoTx := commonRepo.NewApprovalRepository(tx) + for expenseID := range expenseIDs { + var count int64 + if err := tx.Model(&entity.ExpenseNonstock{}). + Where("expense_id = ?", expenseID). + Count(&count).Error; err != nil { + return err + } + if count > 0 { + continue + } + + if err := approvalRepoTx.DeleteByTarget(ctx, utils.ApprovalWorkflowExpense.String(), uint(expenseID)); err != nil { + return err + } + if err := tx.Delete(&entity.Expense{}, expenseID).Error; err != nil { + return err + } + } + return nil + }) +} + func (b *expenseBridge) OnItemsReceived(c *fiber.Ctx, purchaseID uint, updates []ExpenseReceivingPayload) error { if purchaseID == 0 || len(updates) == 0 { return nil } ctx := c.Context() + clearExpenseLinks := make([]uint, 0, len(updates)) filtered := make([]ExpenseReceivingPayload, 0, len(updates)) for _, upd := range updates { + if upd.PurchaseItemID == 0 { + continue + } if upd.SupplierID == 0 { + clearExpenseLinks = append(clearExpenseLinks, upd.PurchaseItemID) continue } if upd.TransportPerItem == nil || *upd.TransportPerItem <= 0 { @@ -202,6 +289,11 @@ func (b *expenseBridge) OnItemsReceived(c *fiber.Ctx, purchaseID uint, updates [ } filtered = append(filtered, upd) } + if len(clearExpenseLinks) > 0 { + if err := b.clearExpenseLinksForItems(ctx, clearExpenseLinks); err != nil { + return err + } + } if len(filtered) == 0 { return nil } diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index a48e103d..bd2f9f0d 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -949,6 +949,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation supplierID uint transportPerItem *float64 vehicleNumber *string + clearVehicle bool overrideWarehouse bool receivedQty float64 } @@ -1041,12 +1042,16 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation } var vehicleNumber *string - if payload.VehicleNumber != nil && strings.TrimSpace(*payload.VehicleNumber) != "" { + clearVehicle := false + if payload.VehicleNumber != nil { val := strings.TrimSpace(*payload.VehicleNumber) - vehicleNumber = &val - } else if item.VehicleNumber != nil && strings.TrimSpace(*item.VehicleNumber) != "" { - val := strings.TrimSpace(*item.VehicleNumber) - vehicleNumber = &val + if val != "" { + vehicleNumber = &val + } else { + clearVehicle = true + } + } else { + clearVehicle = true } prepared = append(prepared, preparedReceiving{ @@ -1057,6 +1062,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation supplierID: supplierID, transportPerItem: transportPerItem, vehicleNumber: vehicleNumber, + clearVehicle: clearVehicle, overrideWarehouse: overrideWarehouse, receivedQty: receivedQty, }) @@ -1170,7 +1176,8 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation ReceivedDate: &dateCopy, TravelNumber: prep.payload.TravelNumber, TravelDocumentPath: prep.payload.TravelDocumentPath, - VehicleNumber: prep.payload.VehicleNumber, + VehicleNumber: prep.vehicleNumber, + ClearVehicleNumber: prep.clearVehicle, ReceivedQty: &qtyCopy, ProductWarehouseID: newPWID, ClearProductWarehouse: false, From abc0ac8258347d75a07ac90237e12b2d7d6e509b Mon Sep 17 00:00:00 2001 From: giovanni Date: Thu, 9 Apr 2026 11:18:05 +0700 Subject: [PATCH 07/17] add export excel to get all recording --- .../controllers/recording.controller.go | 8 +- .../controllers/recording.export.go | 517 ++++++++++++++++++ 2 files changed, 524 insertions(+), 1 deletion(-) create mode 100644 internal/modules/production/recordings/controllers/recording.export.go diff --git a/internal/modules/production/recordings/controllers/recording.controller.go b/internal/modules/production/recordings/controllers/recording.controller.go index 7801b16f..be26fd44 100644 --- a/internal/modules/production/recordings/controllers/recording.controller.go +++ b/internal/modules/production/recordings/controllers/recording.controller.go @@ -26,6 +26,7 @@ func NewRecordingController(recordingService service.RecordingService) *Recordin func (u *RecordingController) GetAll(c *fiber.Ctx) error { projectFlockID := c.QueryInt("project_flock_kandang_id", 0) + exportType := strings.TrimSpace(c.Query("export")) page := c.QueryInt("page", 1) limit := c.QueryInt("limit", 10) @@ -46,6 +47,11 @@ func (u *RecordingController) GetAll(c *fiber.Ctx) error { return err } + listDTO := dto.ToRecordingListDTOs(result) + if strings.EqualFold(exportType, "excel") { + return exportRecordingListExcel(c, listDTO) + } + return c.Status(fiber.StatusOK). JSON(response.SuccessWithPaginate[dto.RecordingListDTO]{ Code: fiber.StatusOK, @@ -57,7 +63,7 @@ func (u *RecordingController) GetAll(c *fiber.Ctx) error { TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), TotalResults: totalResults, }, - Data: dto.ToRecordingListDTOs(result), + Data: listDTO, }) } diff --git a/internal/modules/production/recordings/controllers/recording.export.go b/internal/modules/production/recordings/controllers/recording.export.go new file mode 100644 index 00000000..3f5294ab --- /dev/null +++ b/internal/modules/production/recordings/controllers/recording.export.go @@ -0,0 +1,517 @@ +package controller + +import ( + "fmt" + "math" + "strconv" + "strings" + "time" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/dto" + + "github.com/gofiber/fiber/v2" + "github.com/xuri/excelize/v2" +) + +func exportRecordingListExcel(c *fiber.Ctx, items []dto.RecordingListDTO) error { + file := excelize.NewFile() + defer file.Close() + + const sheetName = "Recordings" + defaultSheet := file.GetSheetName(file.GetActiveSheetIndex()) + if defaultSheet != sheetName { + if err := file.SetSheetName(defaultSheet, sheetName); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "failed to prepare excel sheet") + } + } + + if err := setRecordingExportColumns(file, sheetName); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "failed to prepare excel columns") + } + if err := setRecordingExportHeaders(file, sheetName); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "failed to prepare excel headers") + } + if err := setRecordingExportRows(file, sheetName, items); err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "failed to prepare excel rows") + } + + buffer, err := file.WriteToBuffer() + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "failed to generate excel file") + } + + filename := fmt.Sprintf("recordings_%s.xlsx", time.Now().Format("20060102_150405")) + c.Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) + + return c.Status(fiber.StatusOK).Send(buffer.Bytes()) +} + +func setRecordingExportColumns(file *excelize.File, sheet string) error { + columnWidths := map[string]float64{ + "A": 6, + "B": 18, + "C": 24, + "D": 18, + "E": 10, + "F": 12, + "G": 20, + "H": 18, + "I": 16, + "J": 12, + "K": 12, + "L": 16, + "M": 16, + "N": 18, + "O": 18, + "P": 16, + "Q": 16, + "R": 16, + "S": 16, + "T": 16, + "U": 16, + "V": 16, + "W": 18, + "X": 18, + "Y": 18, + "Z": 22, + "AA": 16, + "AB": 18, + } + + for col, width := range columnWidths { + if err := file.SetColWidth(sheet, col, col, width); err != nil { + return err + } + } + + if err := file.SetRowHeight(sheet, 1, 30); err != nil { + return err + } + if err := file.SetRowHeight(sheet, 2, 30); err != nil { + return err + } + + return nil +} + +func setRecordingExportHeaders(file *excelize.File, sheet string) error { + verticalHeaderCols := []string{"A", "B", "C", "D", "E", "F", "G", "H", "I", "Y", "Z", "AA", "AB"} + for _, col := range verticalHeaderCols { + if err := file.MergeCell(sheet, col+"1", col+"2"); err != nil { + return err + } + } + + headerValues := map[string]string{ + "A1": "No", + "B1": "Lokasi", + "C1": "Flock", + "D1": "Kandang", + "E1": "Periode", + "F1": "Kategori", + "G1": "Umur (hari)", + "H1": "Waktu Recording", + "I1": "Populasi Akhir", + "Y1": "Status Approval", + "Z1": "Catatan Approval", + "AA1": "Dibuat Oleh", + "AB1": "Tanggal Submit", + } + for cell, value := range headerValues { + if err := file.SetCellValue(sheet, cell, value); err != nil { + return err + } + } + + if err := file.MergeCell(sheet, "J1", "K1"); err != nil { + return err + } + if err := file.SetCellValue(sheet, "J1", "FCR"); err != nil { + return err + } + if err := file.SetCellValue(sheet, "J2", "Actual"); err != nil { + return err + } + if err := file.SetCellValue(sheet, "K2", "Standard"); err != nil { + return err + } + + if err := file.MergeCell(sheet, "L1", "M1"); err != nil { + return err + } + if err := file.SetCellValue(sheet, "L1", "Feed Intake (KG)"); err != nil { + return err + } + if err := file.SetCellValue(sheet, "L2", "Actual"); err != nil { + return err + } + if err := file.SetCellValue(sheet, "M2", "Standard"); err != nil { + return err + } + + if err := file.MergeCell(sheet, "N1", "P1"); err != nil { + return err + } + if err := file.SetCellValue(sheet, "N1", "Mortality"); err != nil { + return err + } + if err := file.SetCellValue(sheet, "N2", "Cum Depletion Rate"); err != nil { + return err + } + if err := file.SetCellValue(sheet, "O2", "Max Depletion Std"); err != nil { + return err + } + if err := file.SetCellValue(sheet, "P2", "Total Depletion"); err != nil { + return err + } + + if err := file.MergeCell(sheet, "Q1", "T1"); err != nil { + return err + } + if err := file.SetCellValue(sheet, "Q1", "Egg Production"); err != nil { + return err + } + if err := file.SetCellValue(sheet, "Q2", "Egg Mass Actual"); err != nil { + return err + } + if err := file.SetCellValue(sheet, "R2", "Egg Mass Standar"); err != nil { + return err + } + if err := file.SetCellValue(sheet, "S2", "Egg Weight Actual"); err != nil { + return err + } + if err := file.SetCellValue(sheet, "T2", "Egg Weight Standar"); err != nil { + return err + } + + if err := file.MergeCell(sheet, "U1", "X1"); err != nil { + return err + } + if err := file.SetCellValue(sheet, "U1", "Hen Performance"); err != nil { + return err + } + if err := file.SetCellValue(sheet, "U2", "Hen Day Actual"); err != nil { + return err + } + if err := file.SetCellValue(sheet, "V2", "Hen Day Standar"); err != nil { + return err + } + if err := file.SetCellValue(sheet, "W2", "Hen House Actual"); err != nil { + return err + } + if err := file.SetCellValue(sheet, "X2", "Hen House Standar"); err != nil { + return err + } + + headerStyle, err := file.NewStyle(&excelize.Style{ + Font: &excelize.Font{ + Bold: true, + Color: "7A7A7A", + }, + Fill: excelize.Fill{ + Type: "pattern", + Pattern: 1, + Color: []string{"F5F5F5"}, + }, + Alignment: &excelize.Alignment{ + Horizontal: "center", + Vertical: "center", + WrapText: true, + }, + Border: []excelize.Border{ + {Type: "left", Color: "DDDDDD", Style: 1}, + {Type: "top", Color: "DDDDDD", Style: 1}, + {Type: "bottom", Color: "DDDDDD", Style: 1}, + {Type: "right", Color: "DDDDDD", Style: 1}, + }, + }) + if err != nil { + return err + } + + return file.SetCellStyle(sheet, "A1", "AB2", headerStyle) +} + +func setRecordingExportRows(file *excelize.File, sheet string, items []dto.RecordingListDTO) error { + if len(items) == 0 { + return nil + } + + columns := []string{ + "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", + "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "AA", "AB", + } + + for i, item := range items { + rowNumber := i + 3 + + fcrStd := 0.0 + if item.ProjectFlock.Fcr != nil { + fcrStd = item.ProjectFlock.Fcr.FcrStd + } + + maxDepletionStd := 0.0 + eggMassStd := 0.0 + eggWeightStd := 0.0 + henDayStd := 0.0 + henHouseStd := 0.0 + feedIntakeStd := 0.0 + if item.ProjectFlock.ProductionStandart != nil { + maxDepletionStd = item.ProjectFlock.ProductionStandart.MaxDepletionStd + eggMassStd = item.ProjectFlock.ProductionStandart.EggMassStd + eggWeightStd = item.ProjectFlock.ProductionStandart.EggWeightStd + henDayStd = item.ProjectFlock.ProductionStandart.HenDayStd + henHouseStd = item.ProjectFlock.ProductionStandart.HenHouseStd + feedIntakeStd = item.ProjectFlock.ProductionStandart.FeedIntakeStd + } + + locationName := "-" + if item.Location != nil { + locationName = safeExportText(item.Location.Name) + } + + kandangName := "-" + if item.Kandang != nil { + kandangName = safeExportText(item.Kandang.Name) + } + + createdBy := "-" + if item.CreatedUser != nil { + createdBy = safeExportText(item.CreatedUser.Name) + } else if strings.TrimSpace(item.Approval.ActionBy.Name) != "" { + createdBy = safeExportText(item.Approval.ActionBy.Name) + } + + rowValues := []interface{}{ + i + 1, + locationName, + safeExportText(item.ProjectFlock.FlockName), + kandangName, + item.ProjectFlock.Period, + formatCategoryLabel(item.ProjectFlock.ProjectFlockCategory), + formatAgeLabel(item), + formatDateIndonesian(item.RecordDatetime), + formatNumberID(item.ProjectFlock.TotalChickQty, 0, false), + formatNumberID(item.FcrValue, 2, true), + formatNumberID(fcrStd, 2, true), + formatNumberID(item.FeedIntake, 2, true), + formatNumberID(feedIntakeStd, 2, true), + formatPercentID(item.CumDepletionRate, 2), + formatPercentID(maxDepletionStd, 2), + formatNumberID(item.TotalDepletionQty, 2, true), + formatNumberID(item.EggMass, 2, true), + formatNumberID(eggMassStd, 2, true), + formatNumberID(item.EggWeight, 2, true), + formatNumberID(eggWeightStd, 2, true), + formatPercentID(item.HenDay, 2), + formatPercentID(henDayStd, 2), + formatPercentID(item.HenHouse, 2), + formatPercentID(henHouseStd, 2), + formatApprovalStatus(item), + safeExportText(pointerString(item.Approval.Notes)), + createdBy, + formatDateIndonesian(item.CreatedAt), + } + + for idx, col := range columns { + cell := fmt.Sprintf("%s%d", col, rowNumber) + if err := file.SetCellValue(sheet, cell, rowValues[idx]); err != nil { + return err + } + } + } + + lastRow := len(items) + 2 + dataCenterStyle, err := file.NewStyle(&excelize.Style{ + Alignment: &excelize.Alignment{ + Horizontal: "center", + Vertical: "center", + WrapText: true, + }, + Border: []excelize.Border{ + {Type: "left", Color: "E6E6E6", Style: 1}, + {Type: "top", Color: "E6E6E6", Style: 1}, + {Type: "bottom", Color: "E6E6E6", Style: 1}, + {Type: "right", Color: "E6E6E6", Style: 1}, + }, + }) + if err != nil { + return err + } + if err := file.SetCellStyle(sheet, "A3", fmt.Sprintf("AB%d", lastRow), dataCenterStyle); err != nil { + return err + } + + dataLeftStyle, err := file.NewStyle(&excelize.Style{ + Alignment: &excelize.Alignment{ + Horizontal: "left", + Vertical: "center", + WrapText: true, + }, + Border: []excelize.Border{ + {Type: "left", Color: "E6E6E6", Style: 1}, + {Type: "top", Color: "E6E6E6", Style: 1}, + {Type: "bottom", Color: "E6E6E6", Style: 1}, + {Type: "right", Color: "E6E6E6", Style: 1}, + }, + }) + if err != nil { + return err + } + + leftColumns := []string{"B", "C", "D", "F", "G", "H", "Y", "Z", "AA", "AB"} + for _, col := range leftColumns { + if err := file.SetCellStyle(sheet, col+"3", fmt.Sprintf("%s%d", col, lastRow), dataLeftStyle); err != nil { + return err + } + } + + return nil +} + +func formatAgeLabel(item dto.RecordingListDTO) string { + if item.Day <= 0 { + return "-" + } + + week := 0 + if item.ProjectFlock.ProductionStandart != nil && item.ProjectFlock.ProductionStandart.Week > 0 { + week = item.ProjectFlock.ProductionStandart.Week + } else { + week = ((item.Day - 1) / 7) + 1 + } + + return fmt.Sprintf("%d (Minggu ke-%d)", item.Day, week) +} + +func formatDateIndonesian(t time.Time) string { + if t.IsZero() { + return "-" + } + + loc, err := time.LoadLocation("Asia/Jakarta") + if err == nil { + t = t.In(loc) + } + + monthNames := []string{ + "", + "Januari", + "Februari", + "Maret", + "April", + "Mei", + "Juni", + "Juli", + "Agustus", + "September", + "Oktober", + "November", + "Desember", + } + + month := int(t.Month()) + monthLabel := strconv.Itoa(month) + if month > 0 && month < len(monthNames) { + monthLabel = monthNames[month] + } + + return fmt.Sprintf("%02d %s %d", t.Day(), monthLabel, t.Year()) +} + +func formatCategoryLabel(value string) string { + normalized := strings.TrimSpace(strings.ReplaceAll(value, "_", " ")) + if normalized == "" { + return "-" + } + + parts := strings.Fields(strings.ToLower(normalized)) + for i, part := range parts { + if len(part) == 0 { + continue + } + parts[i] = strings.ToUpper(part[:1]) + part[1:] + } + + return strings.Join(parts, " ") +} + +func formatPercentID(value float64, decimals int) string { + return fmt.Sprintf("%s%%", formatNumberID(value, decimals, false)) +} + +func formatNumberID(value float64, decimals int, trim bool) string { + if math.IsNaN(value) || math.IsInf(value, 0) { + return "0" + } + if decimals < 0 { + decimals = 0 + } + + raw := strconv.FormatFloat(value, 'f', decimals, 64) + if trim && strings.Contains(raw, ".") { + raw = strings.TrimRight(raw, "0") + raw = strings.TrimRight(raw, ".") + } + + parts := strings.SplitN(raw, ".", 2) + intPart := parts[0] + sign := "" + if strings.HasPrefix(intPart, "-") { + sign = "-" + intPart = strings.TrimPrefix(intPart, "-") + } + if intPart == "" { + intPart = "0" + } + + var grouped strings.Builder + rem := len(intPart) % 3 + if rem > 0 { + grouped.WriteString(intPart[:rem]) + if len(intPart) > rem { + grouped.WriteString(".") + } + } + for i := rem; i < len(intPart); i += 3 { + grouped.WriteString(intPart[i : i+3]) + if i+3 < len(intPart) { + grouped.WriteString(".") + } + } + + result := sign + grouped.String() + if len(parts) == 2 && parts[1] != "" { + result += "," + parts[1] + } + + return result +} + +func safeExportText(value string) string { + normalized := strings.TrimSpace(value) + if normalized == "" { + return "-" + } + return normalized +} + +func pointerString(value *string) string { + if value == nil { + return "" + } + return *value +} + +func formatApprovalStatus(item dto.RecordingListDTO) string { + action := strings.ToUpper(strings.TrimSpace(pointerString(item.Approval.Action))) + switch action { + case "UPDATED": + return "Diperbarui" + case "CREATED": + return safeExportText(item.Approval.StepName) + default: + return safeExportText(item.Approval.StepName) + } +} From e8c33f818b91c9fe11302fbc333c1a0415aab5bc Mon Sep 17 00:00:00 2001 From: giovanni Date: Thu, 9 Apr 2026 15:28:26 +0700 Subject: [PATCH 08/17] adjust dashboard uniformity and validation add uniformity --- .../dashboard_stats.repository.go | 4 +- .../dashboards/services/dashboard.service.go | 74 +++++++++++++++++++ .../services/uniformity.service.go | 6 +- 3 files changed, 80 insertions(+), 4 deletions(-) diff --git a/internal/modules/dashboards/repositories/dashboard_stats.repository.go b/internal/modules/dashboards/repositories/dashboard_stats.repository.go index 363e6aa5..72f54185 100644 --- a/internal/modules/dashboards/repositories/dashboard_stats.repository.go +++ b/internal/modules/dashboards/repositories/dashboard_stats.repository.go @@ -35,6 +35,7 @@ type UniformityWeeklyMetric struct { Week int Uniformity float64 AverageWeight float64 + UniformDate time.Time } type StandardWeeklyMetric struct { @@ -144,7 +145,8 @@ func (r *DashboardRepositoryImpl) GetUniformityWeeklyMetrics(ctx context.Context Table("project_flock_kandang_uniformity AS u"). Select(`u.week AS week, COALESCE(AVG(u.uniformity), 0) AS uniformity, - COALESCE(AVG((u.chart_data->'statistics'->>'average_weight')::numeric), 0) AS average_weight`). + COALESCE(AVG((u.chart_data->'statistics'->>'average_weight')::numeric), 0) AS average_weight, + MAX(u.uniform_date) AS uniform_date`). Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = u.project_flock_kandang_id"). Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id"). Where("u.uniform_date IS NOT NULL"). diff --git a/internal/modules/dashboards/services/dashboard.service.go b/internal/modules/dashboards/services/dashboard.service.go index 928205d2..454537f6 100644 --- a/internal/modules/dashboards/services/dashboard.service.go +++ b/internal/modules/dashboards/services/dashboard.service.go @@ -265,6 +265,7 @@ func (s dashboardService) buildPerformanceCharts(ctx context.Context, params *va } bodyWeightDataset := make([]map[string]interface{}, 0, len(weeks)) + bodyWeightDatasetIndexByWeek := make(map[int]int, len(weeks)) performanceDataset := make([]map[string]interface{}, 0, len(weeks)) fcrDataset := make([]map[string]interface{}, 0, len(weeks)) deplesiDataset := make([]map[string]interface{}, 0, len(weeks)) @@ -298,6 +299,7 @@ func (s dashboardService) buildPerformanceCharts(ctx context.Context, params *va "body_weight": roundTo(uni.AverageWeight, 2), "std_body_weight": roundTo(std.StdBodyWeight, 2), }) + bodyWeightDatasetIndexByWeek[week] = len(bodyWeightDataset) - 1 performanceDataset = append(performanceDataset, map[string]interface{}{ "week": week, @@ -326,6 +328,15 @@ func (s dashboardService) buildPerformanceCharts(ctx context.Context, params *va }) } + bodyWeightDataset = extendBodyWeightDatasetUntilEndDate( + bodyWeightDataset, + bodyWeightDatasetIndexByWeek, + uniformities, + uniformityMap, + standardMap, + params.PeriodEnd, + ) + qualityRows, err := s.Repository.GetEggQualityWeeklyMetrics(ctx, startDate, endExclusive, filter) if err != nil { return nil, err @@ -1049,6 +1060,69 @@ func (s dashboardService) avgSellingPrice(ctx context.Context, filter *validatio return result.TotalPrice / result.TotalWeight, nil } +func extendBodyWeightDatasetUntilEndDate( + dataset []map[string]interface{}, + indexByWeek map[int]int, + uniformities []repository.UniformityWeeklyMetric, + uniformityMap map[int]repository.UniformityWeeklyMetric, + standardMap map[int]repository.StandardWeeklyMetric, + periodEnd time.Time, +) []map[string]interface{} { + latestUniformityWeek := 0 + var latestUniformityDate time.Time + for _, row := range uniformities { + if row.Week <= 0 || row.UniformDate.IsZero() { + continue + } + if latestUniformityDate.IsZero() || row.UniformDate.After(latestUniformityDate) || (row.UniformDate.Equal(latestUniformityDate) && row.Week > latestUniformityWeek) { + latestUniformityDate = row.UniformDate + latestUniformityWeek = row.Week + } + } + + if latestUniformityWeek <= 0 || latestUniformityDate.IsZero() || periodEnd.IsZero() || !periodEnd.After(latestUniformityDate) { + return dataset + } + + additionalWeeks := int(math.Ceil(periodEnd.Sub(latestUniformityDate).Hours() / (24 * 7))) + if additionalWeeks <= 0 { + return dataset + } + + lastUniformity := uniformityMap[latestUniformityWeek] + lastStandard := standardMap[latestUniformityWeek] + latestBodyWeight := roundTo(lastUniformity.AverageWeight, 2) + latestStdBodyWeight := roundTo(lastStandard.StdBodyWeight, 2) + + targetWeek := latestUniformityWeek + additionalWeeks + for week := latestUniformityWeek + 1; week <= targetWeek; week++ { + row := map[string]interface{}{ + "week": week, + "body_weight": latestBodyWeight, + "std_body_weight": latestStdBodyWeight, + } + + if idx, ok := indexByWeek[week]; ok { + dataset[idx] = row + continue + } + + dataset = append(dataset, row) + indexByWeek[week] = len(dataset) - 1 + } + + sort.Slice(dataset, func(i, j int) bool { + return datasetWeek(dataset[i]) < datasetWeek(dataset[j]) + }) + + return dataset +} + +func datasetWeek(row map[string]interface{}) int { + week, _ := row["week"].(int) + return week +} + func feedUsageToGrams(rows []repository.FeedUsageByUom) float64 { total := 0.0 for _, row := range rows { diff --git a/internal/modules/production/uniformities/services/uniformity.service.go b/internal/modules/production/uniformities/services/uniformity.service.go index 7de39ef8..5c28ce78 100644 --- a/internal/modules/production/uniformities/services/uniformity.service.go +++ b/internal/modules/production/uniformities/services/uniformity.service.go @@ -406,9 +406,9 @@ func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file } return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 1 for growing projects") } - if latestWeek > 0 && req.Week > latestWeek+1 { - return nil, fiber.NewError(fiber.StatusBadRequest, "week must be sequential without skipping") - } + // if latestWeek > 0 && req.Week > latestWeek+1 { + // return nil, fiber.NewError(fiber.StatusBadRequest, "week must be sequential without skipping") + // } if err := s.ensureUniqueUniformity(c.Context(), 0, req.ProjectFlockKandangId, req.Week); err != nil { return nil, err From 7c848bc50df56f20438a7dcaa9b4c2762f4cad24 Mon Sep 17 00:00:00 2001 From: giovanni Date: Thu, 9 Apr 2026 17:00:03 +0700 Subject: [PATCH 09/17] adjust validation from week 19 --- .../services/uniformity.service.go | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/internal/modules/production/uniformities/services/uniformity.service.go b/internal/modules/production/uniformities/services/uniformity.service.go index 5c28ce78..1bb72ae4 100644 --- a/internal/modules/production/uniformities/services/uniformity.service.go +++ b/internal/modules/production/uniformities/services/uniformity.service.go @@ -386,10 +386,10 @@ func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file weekBase = config.LayingWeekStart() } if req.Week < weekBase { - if isLayingCategory { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("week must start from %d for laying projects", weekBase)) + if !isLayingCategory { + return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 1 for growing projects") } - return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 1 for growing projects") + // return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("week must start from %d for laying projects", weekBase)) } var latestWeek int @@ -401,10 +401,10 @@ func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate uniformity week sequence") } if latestWeek == 0 && req.Week != weekBase { - if isLayingCategory { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("week must start from %d for laying projects", weekBase)) + if !isLayingCategory { + return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 1 for growing projects") } - return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 1 for growing projects") + // return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("week must start from %d for laying projects", weekBase)) } // if latestWeek > 0 && req.Week > latestWeek+1 { // return nil, fiber.NewError(fiber.StatusBadRequest, "week must be sequential without skipping") @@ -582,10 +582,10 @@ func (s uniformityService) UpdateOne(c *fiber.Ctx, req *validation.Update, id ui weekBase = config.LayingWeekStart() } if targetWeek < weekBase { - if isLayingCategory { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("week must start from %d for laying projects", weekBase)) + if !isLayingCategory { + return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 1 for growing projects") } - return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 1 for growing projects") + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("week must start from %d for laying projects", weekBase)) } } if targetDate != nil { From 3d75251c9611694a604d9efd199cf0b55b978b44 Mon Sep 17 00:00:00 2001 From: giovanni Date: Fri, 10 Apr 2026 14:09:31 +0700 Subject: [PATCH 10/17] adjust api get all project flock kandang with periode --- .../project_flock_kandang.controller.go | 47 +++++++-- .../dto/project_flock_kandang.dto.go | 29 +++++- .../services/project_flock_kandang.service.go | 37 +++++++ .../project_flock_kandang.validation.go | 23 ++--- .../projectflock_kandang.repository.go | 98 +++++++++++++++++++ 5 files changed, 210 insertions(+), 24 deletions(-) diff --git a/internal/modules/production/project-flock-kandangs/controllers/project_flock_kandang.controller.go b/internal/modules/production/project-flock-kandangs/controllers/project_flock_kandang.controller.go index 32ac0e38..9333410f 100644 --- a/internal/modules/production/project-flock-kandangs/controllers/project_flock_kandang.controller.go +++ b/internal/modules/production/project-flock-kandangs/controllers/project_flock_kandang.controller.go @@ -24,22 +24,49 @@ func NewProjectFlockKandangController(projectFlockKandangService service.Project func (u *ProjectFlockKandangController) GetAll(c *fiber.Ctx) error { query := &validation.Query{ - Page: c.QueryInt("page", 1), - Limit: c.QueryInt("limit", 10), - Search: c.Query("search", ""), - ProjectFlockId: uint(c.QueryInt("project_flock_id", 0)), - KandangId: uint(c.QueryInt("kandang_id", 0)), - Category: c.Query("category", ""), - AreaId: uint(c.QueryInt("area_id", 0)), - SortBy: c.Query("sort_by", ""), - SortOrder: c.Query("sort_order", ""), - StepName: c.Query("step_name", ""), + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: c.Query("search", ""), + NameWithPeriode: c.QueryBool("name_with_periode", false), + ProjectFlockId: uint(c.QueryInt("project_flock_id", 0)), + KandangId: uint(c.QueryInt("kandang_id", 0)), + Category: c.Query("category", ""), + AreaId: uint(c.QueryInt("area_id", 0)), + SortBy: c.Query("sort_by", ""), + SortOrder: c.Query("sort_order", ""), + StepName: c.Query("step_name", ""), } if query.Page < 1 || query.Limit < 1 { return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") } + if query.NameWithPeriode { + results, totalResults, err := u.ProjectFlockKandangService.GetAllNameWithPeriode(c, query) + if err != nil { + return err + } + + data := make([]dto.ProjectFlockKandangNameWithPeriodDTO, 0, len(results)) + for _, result := range results { + data = append(data, dto.ToProjectFlockKandangNameWithPeriodDTOValues(result.Id, result.KandangName, result.Period)) + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.ProjectFlockKandangNameWithPeriodDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get all projectFlockKandangs successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: data, + }) + } + results, totalResults, err := u.ProjectFlockKandangService.GetAll(c, query) if err != nil { return err diff --git a/internal/modules/production/project-flock-kandangs/dto/project_flock_kandang.dto.go b/internal/modules/production/project-flock-kandangs/dto/project_flock_kandang.dto.go index 8231a551..b914aa10 100644 --- a/internal/modules/production/project-flock-kandangs/dto/project_flock_kandang.dto.go +++ b/internal/modules/production/project-flock-kandangs/dto/project_flock_kandang.dto.go @@ -60,6 +60,11 @@ type ProjectFlockKandangListDTO struct { ChickinApproval *approvalDTO.ApprovalRelationDTO `json:"chickin_approval,omitempty"` } +type ProjectFlockKandangNameWithPeriodDTO struct { + Id uint `json:"id"` + NameWithPeriod string `json:"name_with_period"` +} + type ProjectFlockKandangDetailDTO struct { ProjectFlockKandangListDTO Chickins []chickinDTO.ChickinRelationDTO `json:"chickins,omitempty"` @@ -129,13 +134,17 @@ func toKandangRelation(kandang entity.Kandang) *kandangDTO.KandangRelationDTO { } func toNameWithPeriod(kandang entity.Kandang, period int) string { - if kandang.Name == "" { + return toNameWithPeriodValue(kandang.Name, period) +} + +func toNameWithPeriodValue(kandangName string, period int) string { + if kandangName == "" { return "" } if period == 0 { - return kandang.Name + return kandangName } - return kandang.Name + " Period " + strconv.Itoa(period) + return kandangName + " Period " + strconv.Itoa(period) } func toApprovalDTOSelector( @@ -167,6 +176,20 @@ func ToProjectFlockKandangListDTO(e entity.ProjectFlockKandang) ProjectFlockKand } } +func ToProjectFlockKandangNameWithPeriodDTO(e entity.ProjectFlockKandang) ProjectFlockKandangNameWithPeriodDTO { + return ProjectFlockKandangNameWithPeriodDTO{ + Id: e.Id, + NameWithPeriod: toNameWithPeriod(e.Kandang, e.Period), + } +} + +func ToProjectFlockKandangNameWithPeriodDTOValues(id uint, kandangName string, period int) ProjectFlockKandangNameWithPeriodDTO { + return ProjectFlockKandangNameWithPeriodDTO{ + Id: id, + NameWithPeriod: toNameWithPeriodValue(kandangName, period), + } +} + func toCreatedUserDTO(pf entity.ProjectFlock) *userDTO.UserRelationDTO { if pf.CreatedUser.Id != 0 { mapped := userDTO.ToUserRelationDTO(pf.CreatedUser) diff --git a/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go b/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go index 1dc62062..f80022b4 100644 --- a/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go +++ b/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go @@ -26,6 +26,7 @@ import ( type ProjectFlockKandangService interface { GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlockKandang, int64, error) + GetAllNameWithPeriode(ctx *fiber.Ctx, params *validation.Query) ([]ProjectFlockKandangNameWithPeriode, int64, error) GetOne(ctx *fiber.Ctx, id uint) (*entity.ProjectFlockKandang, map[uint]float64, []entity.ProductWarehouse, error) CheckClosing(ctx *fiber.Ctx, id uint) (*ClosingCheckResult, error) Closing(ctx *fiber.Ctx, id uint, req *validation.Closing) (*entity.ProjectFlockKandang, error) @@ -51,6 +52,12 @@ type ClosingCheckResult struct { Expenses []ExpenseSummary `json:"expenses"` } +type ProjectFlockKandangNameWithPeriode struct { + Id uint + KandangName string + Period int +} + type StockRemainingDetail struct { FlagName string `json:"flag_name"` ProductWarehouseId uint `json:"product_warehouse_id"` @@ -133,6 +140,36 @@ func (s projectFlockKandangService) GetAll(c *fiber.Ctx, params *validation.Quer return projectFlockKandangs, total, nil } +func (s projectFlockKandangService) GetAllNameWithPeriode(c *fiber.Ctx, params *validation.Query) ([]ProjectFlockKandangNameWithPeriode, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + scope, err := m.ResolveLocationScope(c, s.Repository.DB()) + if err != nil { + return nil, 0, err + } + + offset := (params.Page - 1) * params.Limit + + rows, total, err := s.Repository.GetAllNameWithPeriodeScoped(c.Context(), offset, params.Limit, params, scope.IDs, scope.Restrict) + if err != nil { + s.Log.Errorf("Failed to get projectFlockKandangs name_with_periode: %+v", err) + return nil, 0, err + } + + results := make([]ProjectFlockKandangNameWithPeriode, 0, len(rows)) + for _, row := range rows { + results = append(results, ProjectFlockKandangNameWithPeriode{ + Id: row.Id, + KandangName: row.KandangName, + Period: row.Period, + }) + } + + return results, total, nil +} + func (s projectFlockKandangService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectFlockKandang, map[uint]float64, []entity.ProductWarehouse, error) { scope, err := m.ResolveLocationScope(c, s.Repository.DB()) if err != nil { diff --git a/internal/modules/production/project-flock-kandangs/validations/project_flock_kandang.validation.go b/internal/modules/production/project-flock-kandangs/validations/project_flock_kandang.validation.go index 729d8329..1fc392ec 100644 --- a/internal/modules/production/project-flock-kandangs/validations/project_flock_kandang.validation.go +++ b/internal/modules/production/project-flock-kandangs/validations/project_flock_kandang.validation.go @@ -11,19 +11,20 @@ type Update struct { } type Query struct { - Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` - Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` - Search string `query:"search" validate:"omitempty,max=50"` - ProjectFlockId uint `query:"project_flock_id" validate:"omitempty"` - KandangId uint `query:"kandang_id" validate:"omitempty"` - Category string `query:"category" validate:"omitempty,oneof=Growing Laying"` - AreaId uint `query:"area_id" validate:"omitempty"` - SortBy string `query:"sort_by" validate:"omitempty,oneof=created_at period"` - SortOrder string `query:"sort_order" validate:"omitempty,oneof=ASC DESC"` - StepName string `query:"step_name" validate:"omitempty,max=50"` + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` + Search string `query:"search" validate:"omitempty,max=50"` + NameWithPeriode bool `query:"name_with_periode"` + ProjectFlockId uint `query:"project_flock_id" validate:"omitempty"` + KandangId uint `query:"kandang_id" validate:"omitempty"` + Category string `query:"category" validate:"omitempty,oneof=Growing Laying"` + AreaId uint `query:"area_id" validate:"omitempty"` + SortBy string `query:"sort_by" validate:"omitempty,oneof=created_at period"` + SortOrder string `query:"sort_order" validate:"omitempty,oneof=ASC DESC"` + StepName string `query:"step_name" validate:"omitempty,max=50"` } type Closing struct { Action string `json:"action" validate:"required,oneof=close unclose"` ClosedDate *string `json:"closed_date,omitempty"` -} \ No newline at end of file +} diff --git a/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go index b200cb18..29b06fe4 100644 --- a/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go +++ b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go @@ -22,6 +22,7 @@ type ProjectFlockKandangRepository interface { GetAll(ctx context.Context, offset int, limit int, modifier func(*gorm.DB) *gorm.DB) ([]entity.ProjectFlockKandang, int64, error) GetAllWithFilters(ctx context.Context, offset int, limit int, params interface{}) ([]entity.ProjectFlockKandang, int64, error) GetAllWithFiltersScoped(ctx context.Context, offset int, limit int, params interface{}, locationIDs []uint, restrict bool) ([]entity.ProjectFlockKandang, int64, error) + GetAllNameWithPeriodeScoped(ctx context.Context, offset int, limit int, params *validation.Query, locationIDs []uint, restrict bool) ([]ProjectFlockKandangNameWithPeriode, int64, error) GetByProjectFlockID(ctx context.Context, projectFlockID uint) ([]entity.ProjectFlockKandang, error) ListExistingKandangIDs(ctx context.Context, projectFlockID uint, kandangIDs []uint) ([]uint, error) HasKandangsLinkedToOtherProject(ctx context.Context, kandangIDs []uint, exceptProjectID *uint) (bool, error) @@ -40,6 +41,12 @@ type projectFlockKandangRepositoryImpl struct { db *gorm.DB } +type ProjectFlockKandangNameWithPeriode struct { + Id uint `gorm:"column:id"` + KandangName string `gorm:"column:kandang_name"` + Period int `gorm:"column:period"` +} + const flockBaseNameExpression = "LOWER(TRIM(regexp_replace(project_flocks.flock_name, '\\s+\\d+(\\s+\\d+)*$', '', 'g')))" func NewProjectFlockKandangRepository(db *gorm.DB) ProjectFlockKandangRepository { @@ -297,6 +304,97 @@ func (r *projectFlockKandangRepositoryImpl) GetAllWithFiltersScoped(ctx context. return records, total, nil } +func (r *projectFlockKandangRepositoryImpl) GetAllNameWithPeriodeScoped(ctx context.Context, offset int, limit int, params *validation.Query, locationIDs []uint, restrict bool) ([]ProjectFlockKandangNameWithPeriode, int64, error) { + var records []ProjectFlockKandangNameWithPeriode + var total int64 + + q := r.db.WithContext(ctx). + Table("project_flock_kandangs"). + Joins("JOIN \"kandangs\" ON \"project_flock_kandangs\".\"kandang_id\" = \"kandangs\".\"id\""). + Joins("JOIN \"project_flocks\" ON \"project_flock_kandangs\".\"project_flock_id\" = \"project_flocks\".\"id\"") + + if restrict { + if len(locationIDs) == 0 { + return []ProjectFlockKandangNameWithPeriode{}, 0, nil + } + q = q.Where("\"project_flocks\".\"location_id\" IN ?", locationIDs) + } + + if params != nil && params.StepName != "" { + q = q.Where(` + EXISTS ( + SELECT 1 FROM "approvals" + WHERE "approvals"."approvable_id" = "project_flock_kandangs"."id" + AND "approvals"."approvable_type" = ? + AND LOWER("approvals"."step_name") = LOWER(?) + AND "approvals"."id" IN ( + SELECT "approvals"."id" FROM "approvals" + WHERE "approvals"."approvable_id" = "project_flock_kandangs"."id" + AND "approvals"."approvable_type" = ? + ORDER BY "approvals"."id" DESC + LIMIT 1 + ) + ) + `, "PROJECT_FLOCK_KANDANGS", params.StepName, "PROJECT_FLOCK_KANDANGS") + } + + if params != nil { + if params.Search != "" { + escapedSearch := strings.NewReplacer("\\", "\\\\", "%", "\\%", "_", "\\_").Replace(params.Search) + q = q.Where( + r.db.Where("LOWER(\"kandangs\".\"name\") LIKE LOWER(?) ESCAPE '\\'", "%"+escapedSearch+"%"). + Or("LOWER(\"project_flocks\".\"flock_name\") LIKE LOWER(?) ESCAPE '\\'", "%"+escapedSearch+"%"), + ) + } + + if params.ProjectFlockId > 0 { + q = q.Where("\"project_flock_kandangs\".\"project_flock_id\" = ?", params.ProjectFlockId) + } + + if params.KandangId > 0 { + q = q.Where("\"project_flock_kandangs\".\"kandang_id\" = ?", params.KandangId) + } + + if params.Category != "" { + q = q.Where("\"project_flocks\".\"category\" = ?", params.Category) + } + + if params.AreaId > 0 { + q = q.Where("\"project_flocks\".\"area_id\" = ?", params.AreaId) + } + } + + if err := q.Count(&total).Error; err != nil { + return nil, 0, err + } + + sortBy := "\"project_flock_kandangs\".\"created_at\" DESC" + if params != nil && params.SortBy != "" { + sortOrder := "DESC" + if params.SortOrder == "ASC" { + sortOrder = "ASC" + } + + switch params.SortBy { + case "created_at": + sortBy = "\"project_flock_kandangs\".\"created_at\" " + sortOrder + case "period": + sortBy = "\"project_flocks\".\"period\" " + sortOrder + } + } + + if err := q. + Select("\"project_flock_kandangs\".\"id\", \"project_flock_kandangs\".\"period\", \"kandangs\".\"name\" AS kandang_name"). + Order(sortBy). + Offset(offset). + Limit(limit). + Scan(&records).Error; err != nil { + return nil, 0, err + } + + return records, total, nil +} + func (r *projectFlockKandangRepositoryImpl) WithTx(tx *gorm.DB) ProjectFlockKandangRepository { return &projectFlockKandangRepositoryImpl{db: tx} } From 3702d41954a332d78b80e45bac3171ae8c7fb0ac Mon Sep 17 00:00:00 2001 From: giovanni Date: Fri, 10 Apr 2026 17:16:28 +0700 Subject: [PATCH 11/17] fix calculate egg mass and hen house recordings --- .../main.go | 380 ++++++++++++++++++ .../repositories/recording.repository.go | 30 +- .../recordings/services/recording.service.go | 6 +- 3 files changed, 410 insertions(+), 6 deletions(-) create mode 100644 cmd/normalize-data-egg-mass-and-hen-house/main.go diff --git a/cmd/normalize-data-egg-mass-and-hen-house/main.go b/cmd/normalize-data-egg-mass-and-hen-house/main.go new file mode 100644 index 00000000..b34fb7e2 --- /dev/null +++ b/cmd/normalize-data-egg-mass-and-hen-house/main.go @@ -0,0 +1,380 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "math" + "os" + "strings" + "time" + + "gitlab.com/mbugroup/lti-api.git/internal/config" + "gitlab.com/mbugroup/lti-api.git/internal/database" + recordingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" + "gorm.io/gorm" +) + +const metricEpsilon = 1e-9 + +type normalizeOptions struct { + Apply bool + RecordingID uint + ProjectFlockKandangID uint + From *time.Time + To *time.Time + BatchSize int + Limit int +} + +type normalizeStats struct { + Processed int + Changed int + Updated int + Skipped int + Failed int +} + +type recordingMetricRow struct { + ID uint `gorm:"column:id"` + ProjectFlockKandangID uint `gorm:"column:project_flock_kandangs_id"` + RecordDatetime time.Time `gorm:"column:record_datetime"` + HenHouse *float64 `gorm:"column:hen_house"` + EggMass *float64 `gorm:"column:egg_mass"` +} + +func main() { + var ( + apply bool + recordingID uint + projectFlockKandangID uint + fromRaw string + toRaw string + batchSize int + limit int + ) + + flag.BoolVar(&apply, "apply", false, "Apply update. If false, run as dry-run") + flag.UintVar(&recordingID, "recording-id", 0, "Target a single recording ID") + flag.UintVar(&projectFlockKandangID, "project-flock-kandang-id", 0, "Filter by project_flock_kandangs_id") + flag.StringVar(&fromRaw, "from", "", "Lower bound record_datetime (RFC3339 / YYYY-MM-DD)") + flag.StringVar(&toRaw, "to", "", "Upper bound record_datetime (RFC3339 / YYYY-MM-DD)") + flag.IntVar(&batchSize, "batch-size", 200, "Batch size when scanning recordings") + flag.IntVar(&limit, "limit", 0, "Max recordings to process (0 = no limit)") + flag.Parse() + + if batchSize <= 0 { + log.Fatal("--batch-size must be > 0") + } + if limit < 0 { + log.Fatal("--limit cannot be negative") + } + + from, err := parseTimeBound(strings.TrimSpace(fromRaw), false) + if err != nil { + log.Fatalf("invalid --from: %v", err) + } + to, err := parseTimeBound(strings.TrimSpace(toRaw), true) + if err != nil { + log.Fatalf("invalid --to: %v", err) + } + if from != nil && to != nil && to.Before(*from) { + log.Fatal("--to cannot be before --from") + } + + opts := normalizeOptions{ + Apply: apply, + RecordingID: recordingID, + ProjectFlockKandangID: projectFlockKandangID, + From: from, + To: to, + BatchSize: batchSize, + Limit: limit, + } + + ctx := context.Background() + db := database.Connect(config.DBHost, config.DBName) + repo := recordingRepo.NewRecordingRepository(db) + + fmt.Printf("Mode: %s\n", modeLabel(opts.Apply)) + fmt.Printf("Filter recording_id: %s\n", displayUint(opts.RecordingID)) + fmt.Printf("Filter project_flock_kandangs_id: %s\n", displayUint(opts.ProjectFlockKandangID)) + fmt.Printf("Filter from: %s\n", displayTime(opts.From)) + fmt.Printf("Filter to: %s\n", displayTime(opts.To)) + fmt.Printf("Batch size: %d\n", opts.BatchSize) + fmt.Printf("Limit: %d\n\n", opts.Limit) + + stats, err := normalizeRecordings(ctx, db, repo, opts) + if err != nil { + log.Fatalf("normalize failed: %v", err) + } + + fmt.Println() + fmt.Printf( + "Summary: processed=%d changed=%d updated=%d skipped=%d failed=%d\n", + stats.Processed, + stats.Changed, + stats.Updated, + stats.Skipped, + stats.Failed, + ) + + if stats.Failed > 0 { + os.Exit(1) + } +} + +func normalizeRecordings( + ctx context.Context, + db *gorm.DB, + repo recordingRepo.RecordingRepository, + opts normalizeOptions, +) (normalizeStats, error) { + stats := normalizeStats{} + lastID := uint(0) + initialChickCache := make(map[uint]float64) + + for { + batchLimit := opts.BatchSize + if opts.Limit > 0 { + remaining := opts.Limit - stats.Processed + if remaining <= 0 { + break + } + if remaining < batchLimit { + batchLimit = remaining + } + } + + rows, err := loadRecordingBatch(ctx, db, opts, lastID, batchLimit) + if err != nil { + return stats, err + } + if len(rows) == 0 { + break + } + + for _, row := range rows { + stats.Processed++ + lastID = row.ID + + initialChick, ok := initialChickCache[row.ProjectFlockKandangID] + if !ok { + initialChick, err = repo.GetTotalChickinByProjectFlockKandang(db.WithContext(ctx), row.ProjectFlockKandangID) + if err != nil { + fmt.Printf("FAIL rec=%d error=getTotalChickinByProjectFlockKandang: %v\n", row.ID, err) + stats.Failed++ + continue + } + initialChickCache[row.ProjectFlockKandangID] = initialChick + } + + _, totalEggWeightGrams, err := repo.GetEggSummaryByRecording(db.WithContext(ctx), row.ID) + if err != nil { + fmt.Printf("FAIL rec=%d error=getEggSummaryByRecording: %v\n", row.ID, err) + stats.Failed++ + continue + } + + cumulativeEggQty, err := repo.GetCumulativeEggQtyByProjectFlockKandang(db.WithContext(ctx), row.ProjectFlockKandangID, row.RecordDatetime) + if err != nil { + fmt.Printf("FAIL rec=%d error=getCumulativeEggQtyByProjectFlockKandang: %v\n", row.ID, err) + stats.Failed++ + continue + } + + newHenHouse, newEggMass := computeNormalizedMetrics(initialChick, cumulativeEggQty, totalEggWeightGrams) + henHouseChanged := metricChanged(row.HenHouse, newHenHouse) + eggMassChanged := metricChanged(row.EggMass, newEggMass) + + if !henHouseChanged && !eggMassChanged { + stats.Skipped++ + continue + } + + stats.Changed++ + fmt.Printf( + "PLAN rec=%d pfk=%d at=%s hen_house:%s->%s egg_mass:%s->%s\n", + row.ID, + row.ProjectFlockKandangID, + row.RecordDatetime.UTC().Format(time.RFC3339), + displayFloat(row.HenHouse), + displayFloat(newHenHouse), + displayFloat(row.EggMass), + displayFloat(newEggMass), + ) + + if !opts.Apply { + continue + } + + if err := updateRecordingMetrics(ctx, db, row.ID, newHenHouse, newEggMass); err != nil { + fmt.Printf("FAIL rec=%d error=updateRecordingMetrics: %v\n", row.ID, err) + stats.Failed++ + continue + } + + fmt.Printf( + "DONE rec=%d hen_house=%s egg_mass=%s\n", + row.ID, + displayFloat(newHenHouse), + displayFloat(newEggMass), + ) + stats.Updated++ + } + + if opts.RecordingID > 0 { + break + } + } + + return stats, nil +} + +func loadRecordingBatch( + ctx context.Context, + db *gorm.DB, + opts normalizeOptions, + lastID uint, + limit int, +) ([]recordingMetricRow, error) { + query := db.WithContext(ctx). + Table("recordings"). + Select("id, project_flock_kandangs_id, record_datetime, hen_house, egg_mass"). + Where("recordings.deleted_at IS NULL") + + if opts.RecordingID > 0 { + query = query.Where("recordings.id = ?", opts.RecordingID) + } + if opts.ProjectFlockKandangID > 0 { + query = query.Where("recordings.project_flock_kandangs_id = ?", opts.ProjectFlockKandangID) + } + if opts.From != nil { + query = query.Where("recordings.record_datetime >= ?", *opts.From) + } + if opts.To != nil { + query = query.Where("recordings.record_datetime <= ?", *opts.To) + } + if opts.RecordingID == 0 && lastID > 0 { + query = query.Where("recordings.id > ?", lastID) + } + + var rows []recordingMetricRow + err := query. + Order("recordings.id ASC"). + Limit(limit). + Scan(&rows).Error + return rows, err +} + +func computeNormalizedMetrics(initialChick, cumulativeEggQty, totalEggWeightGrams float64) (*float64, *float64) { + var henHouse *float64 + if initialChick > 0 && cumulativeEggQty >= 0 { + value := cumulativeEggQty / initialChick + henHouse = &value + } + + var eggMass *float64 + if initialChick > 0 && totalEggWeightGrams > 0 { + value := totalEggWeightGrams / initialChick + eggMass = &value + } + + return henHouse, eggMass +} + +func updateRecordingMetrics(ctx context.Context, db *gorm.DB, recordingID uint, henHouse, eggMass *float64) error { + updates := map[string]any{} + if henHouse == nil { + updates["hen_house"] = gorm.Expr("NULL") + } else { + updates["hen_house"] = *henHouse + } + if eggMass == nil { + updates["egg_mass"] = gorm.Expr("NULL") + } else { + updates["egg_mass"] = *eggMass + } + + return db.WithContext(ctx). + Table("recordings"). + Where("id = ?", recordingID). + Updates(updates).Error +} + +func metricChanged(oldValue, newValue *float64) bool { + if oldValue == nil && newValue == nil { + return false + } + if oldValue == nil || newValue == nil { + return true + } + return !nearlyEqual(*oldValue, *newValue) +} + +func nearlyEqual(a, b float64) bool { + scale := math.Max(1, math.Max(math.Abs(a), math.Abs(b))) + return math.Abs(a-b) <= metricEpsilon*scale +} + +func parseTimeBound(raw string, isUpper bool) (*time.Time, error) { + if raw == "" { + return nil, nil + } + + layouts := []string{ + time.RFC3339Nano, + time.RFC3339, + "2006-01-02 15:04:05", + "2006-01-02", + } + + for _, layout := range layouts { + parsed, err := time.Parse(layout, raw) + if err != nil { + continue + } + if layout == "2006-01-02" { + if isUpper { + endOfDay := time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 23, 59, 59, int(time.Second-time.Nanosecond), time.UTC) + return &endOfDay, nil + } + startOfDay := time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 0, 0, 0, 0, time.UTC) + return &startOfDay, nil + } + + t := parsed.UTC() + return &t, nil + } + + return nil, fmt.Errorf("unsupported format %q", raw) +} + +func modeLabel(apply bool) string { + if apply { + return "APPLY" + } + return "DRY-RUN" +} + +func displayFloat(v *float64) string { + if v == nil { + return "NULL" + } + return fmt.Sprintf("%.6f", *v) +} + +func displayTime(v *time.Time) string { + if v == nil { + return "" + } + return v.UTC().Format(time.RFC3339) +} + +func displayUint(v uint) string { + if v == 0 { + return "" + } + return fmt.Sprintf("%d", v) +} diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index 6ea4c473..1f01f50b 100644 --- a/internal/modules/production/recordings/repositories/recording.repository.go +++ b/internal/modules/production/recordings/repositories/recording.repository.go @@ -758,15 +758,39 @@ func (r *RecordingRepositoryImpl) GetCumulativeEggQtyByProjectFlockKandang( return 0, nil } - var result float64 + var cumulativeEggQty float64 err := tx. Table("recording_eggs"). Select("COALESCE(SUM(recording_eggs.qty), 0)"). Joins("JOIN recordings ON recordings.id = recording_eggs.recording_id"). Where("recordings.project_flock_kandangs_id = ?", projectFlockKandangId). Where("recordings.record_datetime <= ?", recordTime). - Scan(&result).Error - return result, err + Scan(&cumulativeEggQty).Error + if err != nil { + return 0, err + } + + productWarehouseSubQuery := tx. + Table("recording_eggs"). + Select("DISTINCT recording_eggs.product_warehouse_id"). + Joins("JOIN recordings ON recordings.id = recording_eggs.recording_id"). + Where("recordings.project_flock_kandangs_id = ?", projectFlockKandangId). + Where("recordings.record_datetime <= ?", recordTime) + + var adjustmentEggQty float64 + err = tx. + Table("adjustment_stocks"). + Select("COALESCE(SUM(adjustment_stocks.total_qty), 0)"). + Where("adjustment_stocks.product_warehouse_id IN (?)", productWarehouseSubQuery). + Where("adjustment_stocks.function_code = ?", "RECORDING_EGG_IN"). + Where("adjustment_stocks.transaction_type = ?", "RECORDING"). + Where("adjustment_stocks.created_at <= ?", recordTime). + Scan(&adjustmentEggQty).Error + if err != nil { + return 0, err + } + + return cumulativeEggQty + adjustmentEggQty, nil } func (r *RecordingRepositoryImpl) GetProductionWeightAndQtyByProjectFlockID(ctx context.Context, projectFlockID uint) (totalWeight float64, totalQty float64, err error) { // Body-weight tracking is removed; keep stub for report compatibility. diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 6f5fddb6..5c4d6a9c 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -1989,9 +1989,9 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm } var eggMass float64 - if remainingChick > 0 && totalEggWeightGrams > 0 { - // totalEggWeightGrams is in grams; egg mass is grams per hen. - eggMass = totalEggWeightGrams / remainingChick + if initialChickin > 0 && totalEggWeightGrams > 0 { + // totalEggWeightGrams is in grams; egg mass uses initial chick population. + eggMass = totalEggWeightGrams / initialChickin updates["egg_mass"] = eggMass recording.EggMass = &eggMass } else { From 3eb225cca896eb4ee94c1d048a8463f6defeb592 Mon Sep 17 00:00:00 2001 From: giovanni Date: Thu, 9 Apr 2026 17:00:03 +0700 Subject: [PATCH 12/17] adjust validation from week 19 --- .../services/uniformity.service.go | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/internal/modules/production/uniformities/services/uniformity.service.go b/internal/modules/production/uniformities/services/uniformity.service.go index 5c28ce78..1bb72ae4 100644 --- a/internal/modules/production/uniformities/services/uniformity.service.go +++ b/internal/modules/production/uniformities/services/uniformity.service.go @@ -386,10 +386,10 @@ func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file weekBase = config.LayingWeekStart() } if req.Week < weekBase { - if isLayingCategory { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("week must start from %d for laying projects", weekBase)) + if !isLayingCategory { + return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 1 for growing projects") } - return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 1 for growing projects") + // return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("week must start from %d for laying projects", weekBase)) } var latestWeek int @@ -401,10 +401,10 @@ func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate uniformity week sequence") } if latestWeek == 0 && req.Week != weekBase { - if isLayingCategory { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("week must start from %d for laying projects", weekBase)) + if !isLayingCategory { + return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 1 for growing projects") } - return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 1 for growing projects") + // return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("week must start from %d for laying projects", weekBase)) } // if latestWeek > 0 && req.Week > latestWeek+1 { // return nil, fiber.NewError(fiber.StatusBadRequest, "week must be sequential without skipping") @@ -582,10 +582,10 @@ func (s uniformityService) UpdateOne(c *fiber.Ctx, req *validation.Update, id ui weekBase = config.LayingWeekStart() } if targetWeek < weekBase { - if isLayingCategory { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("week must start from %d for laying projects", weekBase)) + if !isLayingCategory { + return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 1 for growing projects") } - return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 1 for growing projects") + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("week must start from %d for laying projects", weekBase)) } } if targetDate != nil { From b79738dbe1ff3138cd00653f1c5f16d766dd8254 Mon Sep 17 00:00:00 2001 From: giovanni Date: Fri, 10 Apr 2026 17:16:28 +0700 Subject: [PATCH 13/17] fix calculate egg mass and hen house recordings --- .../main.go | 380 ++++++++++++++++++ .../repositories/recording.repository.go | 30 +- .../recordings/services/recording.service.go | 6 +- 3 files changed, 410 insertions(+), 6 deletions(-) create mode 100644 cmd/normalize-data-egg-mass-and-hen-house/main.go diff --git a/cmd/normalize-data-egg-mass-and-hen-house/main.go b/cmd/normalize-data-egg-mass-and-hen-house/main.go new file mode 100644 index 00000000..b34fb7e2 --- /dev/null +++ b/cmd/normalize-data-egg-mass-and-hen-house/main.go @@ -0,0 +1,380 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "math" + "os" + "strings" + "time" + + "gitlab.com/mbugroup/lti-api.git/internal/config" + "gitlab.com/mbugroup/lti-api.git/internal/database" + recordingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" + "gorm.io/gorm" +) + +const metricEpsilon = 1e-9 + +type normalizeOptions struct { + Apply bool + RecordingID uint + ProjectFlockKandangID uint + From *time.Time + To *time.Time + BatchSize int + Limit int +} + +type normalizeStats struct { + Processed int + Changed int + Updated int + Skipped int + Failed int +} + +type recordingMetricRow struct { + ID uint `gorm:"column:id"` + ProjectFlockKandangID uint `gorm:"column:project_flock_kandangs_id"` + RecordDatetime time.Time `gorm:"column:record_datetime"` + HenHouse *float64 `gorm:"column:hen_house"` + EggMass *float64 `gorm:"column:egg_mass"` +} + +func main() { + var ( + apply bool + recordingID uint + projectFlockKandangID uint + fromRaw string + toRaw string + batchSize int + limit int + ) + + flag.BoolVar(&apply, "apply", false, "Apply update. If false, run as dry-run") + flag.UintVar(&recordingID, "recording-id", 0, "Target a single recording ID") + flag.UintVar(&projectFlockKandangID, "project-flock-kandang-id", 0, "Filter by project_flock_kandangs_id") + flag.StringVar(&fromRaw, "from", "", "Lower bound record_datetime (RFC3339 / YYYY-MM-DD)") + flag.StringVar(&toRaw, "to", "", "Upper bound record_datetime (RFC3339 / YYYY-MM-DD)") + flag.IntVar(&batchSize, "batch-size", 200, "Batch size when scanning recordings") + flag.IntVar(&limit, "limit", 0, "Max recordings to process (0 = no limit)") + flag.Parse() + + if batchSize <= 0 { + log.Fatal("--batch-size must be > 0") + } + if limit < 0 { + log.Fatal("--limit cannot be negative") + } + + from, err := parseTimeBound(strings.TrimSpace(fromRaw), false) + if err != nil { + log.Fatalf("invalid --from: %v", err) + } + to, err := parseTimeBound(strings.TrimSpace(toRaw), true) + if err != nil { + log.Fatalf("invalid --to: %v", err) + } + if from != nil && to != nil && to.Before(*from) { + log.Fatal("--to cannot be before --from") + } + + opts := normalizeOptions{ + Apply: apply, + RecordingID: recordingID, + ProjectFlockKandangID: projectFlockKandangID, + From: from, + To: to, + BatchSize: batchSize, + Limit: limit, + } + + ctx := context.Background() + db := database.Connect(config.DBHost, config.DBName) + repo := recordingRepo.NewRecordingRepository(db) + + fmt.Printf("Mode: %s\n", modeLabel(opts.Apply)) + fmt.Printf("Filter recording_id: %s\n", displayUint(opts.RecordingID)) + fmt.Printf("Filter project_flock_kandangs_id: %s\n", displayUint(opts.ProjectFlockKandangID)) + fmt.Printf("Filter from: %s\n", displayTime(opts.From)) + fmt.Printf("Filter to: %s\n", displayTime(opts.To)) + fmt.Printf("Batch size: %d\n", opts.BatchSize) + fmt.Printf("Limit: %d\n\n", opts.Limit) + + stats, err := normalizeRecordings(ctx, db, repo, opts) + if err != nil { + log.Fatalf("normalize failed: %v", err) + } + + fmt.Println() + fmt.Printf( + "Summary: processed=%d changed=%d updated=%d skipped=%d failed=%d\n", + stats.Processed, + stats.Changed, + stats.Updated, + stats.Skipped, + stats.Failed, + ) + + if stats.Failed > 0 { + os.Exit(1) + } +} + +func normalizeRecordings( + ctx context.Context, + db *gorm.DB, + repo recordingRepo.RecordingRepository, + opts normalizeOptions, +) (normalizeStats, error) { + stats := normalizeStats{} + lastID := uint(0) + initialChickCache := make(map[uint]float64) + + for { + batchLimit := opts.BatchSize + if opts.Limit > 0 { + remaining := opts.Limit - stats.Processed + if remaining <= 0 { + break + } + if remaining < batchLimit { + batchLimit = remaining + } + } + + rows, err := loadRecordingBatch(ctx, db, opts, lastID, batchLimit) + if err != nil { + return stats, err + } + if len(rows) == 0 { + break + } + + for _, row := range rows { + stats.Processed++ + lastID = row.ID + + initialChick, ok := initialChickCache[row.ProjectFlockKandangID] + if !ok { + initialChick, err = repo.GetTotalChickinByProjectFlockKandang(db.WithContext(ctx), row.ProjectFlockKandangID) + if err != nil { + fmt.Printf("FAIL rec=%d error=getTotalChickinByProjectFlockKandang: %v\n", row.ID, err) + stats.Failed++ + continue + } + initialChickCache[row.ProjectFlockKandangID] = initialChick + } + + _, totalEggWeightGrams, err := repo.GetEggSummaryByRecording(db.WithContext(ctx), row.ID) + if err != nil { + fmt.Printf("FAIL rec=%d error=getEggSummaryByRecording: %v\n", row.ID, err) + stats.Failed++ + continue + } + + cumulativeEggQty, err := repo.GetCumulativeEggQtyByProjectFlockKandang(db.WithContext(ctx), row.ProjectFlockKandangID, row.RecordDatetime) + if err != nil { + fmt.Printf("FAIL rec=%d error=getCumulativeEggQtyByProjectFlockKandang: %v\n", row.ID, err) + stats.Failed++ + continue + } + + newHenHouse, newEggMass := computeNormalizedMetrics(initialChick, cumulativeEggQty, totalEggWeightGrams) + henHouseChanged := metricChanged(row.HenHouse, newHenHouse) + eggMassChanged := metricChanged(row.EggMass, newEggMass) + + if !henHouseChanged && !eggMassChanged { + stats.Skipped++ + continue + } + + stats.Changed++ + fmt.Printf( + "PLAN rec=%d pfk=%d at=%s hen_house:%s->%s egg_mass:%s->%s\n", + row.ID, + row.ProjectFlockKandangID, + row.RecordDatetime.UTC().Format(time.RFC3339), + displayFloat(row.HenHouse), + displayFloat(newHenHouse), + displayFloat(row.EggMass), + displayFloat(newEggMass), + ) + + if !opts.Apply { + continue + } + + if err := updateRecordingMetrics(ctx, db, row.ID, newHenHouse, newEggMass); err != nil { + fmt.Printf("FAIL rec=%d error=updateRecordingMetrics: %v\n", row.ID, err) + stats.Failed++ + continue + } + + fmt.Printf( + "DONE rec=%d hen_house=%s egg_mass=%s\n", + row.ID, + displayFloat(newHenHouse), + displayFloat(newEggMass), + ) + stats.Updated++ + } + + if opts.RecordingID > 0 { + break + } + } + + return stats, nil +} + +func loadRecordingBatch( + ctx context.Context, + db *gorm.DB, + opts normalizeOptions, + lastID uint, + limit int, +) ([]recordingMetricRow, error) { + query := db.WithContext(ctx). + Table("recordings"). + Select("id, project_flock_kandangs_id, record_datetime, hen_house, egg_mass"). + Where("recordings.deleted_at IS NULL") + + if opts.RecordingID > 0 { + query = query.Where("recordings.id = ?", opts.RecordingID) + } + if opts.ProjectFlockKandangID > 0 { + query = query.Where("recordings.project_flock_kandangs_id = ?", opts.ProjectFlockKandangID) + } + if opts.From != nil { + query = query.Where("recordings.record_datetime >= ?", *opts.From) + } + if opts.To != nil { + query = query.Where("recordings.record_datetime <= ?", *opts.To) + } + if opts.RecordingID == 0 && lastID > 0 { + query = query.Where("recordings.id > ?", lastID) + } + + var rows []recordingMetricRow + err := query. + Order("recordings.id ASC"). + Limit(limit). + Scan(&rows).Error + return rows, err +} + +func computeNormalizedMetrics(initialChick, cumulativeEggQty, totalEggWeightGrams float64) (*float64, *float64) { + var henHouse *float64 + if initialChick > 0 && cumulativeEggQty >= 0 { + value := cumulativeEggQty / initialChick + henHouse = &value + } + + var eggMass *float64 + if initialChick > 0 && totalEggWeightGrams > 0 { + value := totalEggWeightGrams / initialChick + eggMass = &value + } + + return henHouse, eggMass +} + +func updateRecordingMetrics(ctx context.Context, db *gorm.DB, recordingID uint, henHouse, eggMass *float64) error { + updates := map[string]any{} + if henHouse == nil { + updates["hen_house"] = gorm.Expr("NULL") + } else { + updates["hen_house"] = *henHouse + } + if eggMass == nil { + updates["egg_mass"] = gorm.Expr("NULL") + } else { + updates["egg_mass"] = *eggMass + } + + return db.WithContext(ctx). + Table("recordings"). + Where("id = ?", recordingID). + Updates(updates).Error +} + +func metricChanged(oldValue, newValue *float64) bool { + if oldValue == nil && newValue == nil { + return false + } + if oldValue == nil || newValue == nil { + return true + } + return !nearlyEqual(*oldValue, *newValue) +} + +func nearlyEqual(a, b float64) bool { + scale := math.Max(1, math.Max(math.Abs(a), math.Abs(b))) + return math.Abs(a-b) <= metricEpsilon*scale +} + +func parseTimeBound(raw string, isUpper bool) (*time.Time, error) { + if raw == "" { + return nil, nil + } + + layouts := []string{ + time.RFC3339Nano, + time.RFC3339, + "2006-01-02 15:04:05", + "2006-01-02", + } + + for _, layout := range layouts { + parsed, err := time.Parse(layout, raw) + if err != nil { + continue + } + if layout == "2006-01-02" { + if isUpper { + endOfDay := time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 23, 59, 59, int(time.Second-time.Nanosecond), time.UTC) + return &endOfDay, nil + } + startOfDay := time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 0, 0, 0, 0, time.UTC) + return &startOfDay, nil + } + + t := parsed.UTC() + return &t, nil + } + + return nil, fmt.Errorf("unsupported format %q", raw) +} + +func modeLabel(apply bool) string { + if apply { + return "APPLY" + } + return "DRY-RUN" +} + +func displayFloat(v *float64) string { + if v == nil { + return "NULL" + } + return fmt.Sprintf("%.6f", *v) +} + +func displayTime(v *time.Time) string { + if v == nil { + return "" + } + return v.UTC().Format(time.RFC3339) +} + +func displayUint(v uint) string { + if v == 0 { + return "" + } + return fmt.Sprintf("%d", v) +} diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index 6ea4c473..1f01f50b 100644 --- a/internal/modules/production/recordings/repositories/recording.repository.go +++ b/internal/modules/production/recordings/repositories/recording.repository.go @@ -758,15 +758,39 @@ func (r *RecordingRepositoryImpl) GetCumulativeEggQtyByProjectFlockKandang( return 0, nil } - var result float64 + var cumulativeEggQty float64 err := tx. Table("recording_eggs"). Select("COALESCE(SUM(recording_eggs.qty), 0)"). Joins("JOIN recordings ON recordings.id = recording_eggs.recording_id"). Where("recordings.project_flock_kandangs_id = ?", projectFlockKandangId). Where("recordings.record_datetime <= ?", recordTime). - Scan(&result).Error - return result, err + Scan(&cumulativeEggQty).Error + if err != nil { + return 0, err + } + + productWarehouseSubQuery := tx. + Table("recording_eggs"). + Select("DISTINCT recording_eggs.product_warehouse_id"). + Joins("JOIN recordings ON recordings.id = recording_eggs.recording_id"). + Where("recordings.project_flock_kandangs_id = ?", projectFlockKandangId). + Where("recordings.record_datetime <= ?", recordTime) + + var adjustmentEggQty float64 + err = tx. + Table("adjustment_stocks"). + Select("COALESCE(SUM(adjustment_stocks.total_qty), 0)"). + Where("adjustment_stocks.product_warehouse_id IN (?)", productWarehouseSubQuery). + Where("adjustment_stocks.function_code = ?", "RECORDING_EGG_IN"). + Where("adjustment_stocks.transaction_type = ?", "RECORDING"). + Where("adjustment_stocks.created_at <= ?", recordTime). + Scan(&adjustmentEggQty).Error + if err != nil { + return 0, err + } + + return cumulativeEggQty + adjustmentEggQty, nil } func (r *RecordingRepositoryImpl) GetProductionWeightAndQtyByProjectFlockID(ctx context.Context, projectFlockID uint) (totalWeight float64, totalQty float64, err error) { // Body-weight tracking is removed; keep stub for report compatibility. diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 6f5fddb6..5c4d6a9c 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -1989,9 +1989,9 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm } var eggMass float64 - if remainingChick > 0 && totalEggWeightGrams > 0 { - // totalEggWeightGrams is in grams; egg mass is grams per hen. - eggMass = totalEggWeightGrams / remainingChick + if initialChickin > 0 && totalEggWeightGrams > 0 { + // totalEggWeightGrams is in grams; egg mass uses initial chick population. + eggMass = totalEggWeightGrams / initialChickin updates["egg_mass"] = eggMass recording.EggMass = &eggMass } else { From d1612e5c659544d04379e29a8f9d4460a1fd5bbf Mon Sep 17 00:00:00 2001 From: giovanni Date: Mon, 13 Apr 2026 10:51:12 +0700 Subject: [PATCH 14/17] add query param location id --- .../controllers/project_flock_kandang.controller.go | 1 + .../validations/project_flock_kandang.validation.go | 1 + .../repositories/projectflock_kandang.repository.go | 12 ++++++++++++ 3 files changed, 14 insertions(+) diff --git a/internal/modules/production/project-flock-kandangs/controllers/project_flock_kandang.controller.go b/internal/modules/production/project-flock-kandangs/controllers/project_flock_kandang.controller.go index 9333410f..8593b992 100644 --- a/internal/modules/production/project-flock-kandangs/controllers/project_flock_kandang.controller.go +++ b/internal/modules/production/project-flock-kandangs/controllers/project_flock_kandang.controller.go @@ -32,6 +32,7 @@ func (u *ProjectFlockKandangController) GetAll(c *fiber.Ctx) error { KandangId: uint(c.QueryInt("kandang_id", 0)), Category: c.Query("category", ""), AreaId: uint(c.QueryInt("area_id", 0)), + LocationId: uint(c.QueryInt("location_id", 0)), SortBy: c.Query("sort_by", ""), SortOrder: c.Query("sort_order", ""), StepName: c.Query("step_name", ""), diff --git a/internal/modules/production/project-flock-kandangs/validations/project_flock_kandang.validation.go b/internal/modules/production/project-flock-kandangs/validations/project_flock_kandang.validation.go index 1fc392ec..a1d71596 100644 --- a/internal/modules/production/project-flock-kandangs/validations/project_flock_kandang.validation.go +++ b/internal/modules/production/project-flock-kandangs/validations/project_flock_kandang.validation.go @@ -19,6 +19,7 @@ type Query struct { KandangId uint `query:"kandang_id" validate:"omitempty"` Category string `query:"category" validate:"omitempty,oneof=Growing Laying"` AreaId uint `query:"area_id" validate:"omitempty"` + LocationId uint `query:"location_id" validate:"omitempty,number,gt=0"` SortBy string `query:"sort_by" validate:"omitempty,oneof=created_at period"` SortOrder string `query:"sort_order" validate:"omitempty,oneof=ASC DESC"` StepName string `query:"step_name" validate:"omitempty,max=50"` diff --git a/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go index 29b06fe4..194d1157 100644 --- a/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go +++ b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go @@ -178,6 +178,10 @@ func (r *projectFlockKandangRepositoryImpl) GetAllWithFilters(ctx context.Contex if query.AreaId > 0 { q = q.Where("\"project_flocks\".\"area_id\" = ?", query.AreaId) } + + if query.LocationId > 0 { + q = q.Where("\"kandangs\".\"location_id\" = ?", query.LocationId) + } } if err := q.Model(&entity.ProjectFlockKandang{}).Count(&total).Error; err != nil { @@ -276,6 +280,10 @@ func (r *projectFlockKandangRepositoryImpl) GetAllWithFiltersScoped(ctx context. if query.AreaId > 0 { q = q.Where("\"project_flocks\".\"area_id\" = ?", query.AreaId) } + + if query.LocationId > 0 { + q = q.Where("\"kandangs\".\"location_id\" = ?", query.LocationId) + } } if err := q.Model(&entity.ProjectFlockKandang{}).Count(&total).Error; err != nil { @@ -362,6 +370,10 @@ func (r *projectFlockKandangRepositoryImpl) GetAllNameWithPeriodeScoped(ctx cont if params.AreaId > 0 { q = q.Where("\"project_flocks\".\"area_id\" = ?", params.AreaId) } + + if params.LocationId > 0 { + q = q.Where("\"kandangs\".\"location_id\" = ?", params.LocationId) + } } if err := q.Count(&total).Error; err != nil { From 5e2187c46bfe6e11b3ee838fde8ea6cc5467cc5e Mon Sep 17 00:00:00 2001 From: giovanni Date: Mon, 13 Apr 2026 11:49:41 +0700 Subject: [PATCH 15/17] adjust default order by dan sort by --- .../repositories/projectflock_kandang.repository.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go index 194d1157..e70a77fb 100644 --- a/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go +++ b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go @@ -188,7 +188,7 @@ func (r *projectFlockKandangRepositoryImpl) GetAllWithFilters(ctx context.Contex return nil, 0, err } - sortBy := "\"project_flock_kandangs\".\"created_at\" DESC" + sortBy := "\"project_flock_kandangs\".\"id\" ASC" if ok && query != nil && query.SortBy != "" { sortOrder := "DESC" if query.SortOrder == "ASC" { @@ -290,7 +290,7 @@ func (r *projectFlockKandangRepositoryImpl) GetAllWithFiltersScoped(ctx context. return nil, 0, err } - sortBy := "\"project_flock_kandangs\".\"created_at\" DESC" + sortBy := "\"project_flock_kandangs\".\"id\" ASC" if ok && query != nil && query.SortBy != "" { sortOrder := "DESC" if query.SortOrder == "ASC" { From cff5837ff946cfd599a182d2500c5638e682f266 Mon Sep 17 00:00:00 2001 From: giovanni Date: Mon, 13 Apr 2026 12:14:54 +0700 Subject: [PATCH 16/17] adjust default order dan sort by --- internal/modules/master/areas/services/area.service.go | 4 ++-- .../modules/master/locations/services/location.service.go | 2 +- .../project_flocks/repositories/projectflock.repository.go | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/modules/master/areas/services/area.service.go b/internal/modules/master/areas/services/area.service.go index 6110aaef..c0e351d7 100644 --- a/internal/modules/master/areas/services/area.service.go +++ b/internal/modules/master/areas/services/area.service.go @@ -55,9 +55,9 @@ func (s areaService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Ar db = s.withRelations(db) db, scopeErr = m.ApplyAreaScope(c, db, "id") if params.Search != "" { - return db.Where("name ILIKE ?", "%"+params.Search+"%") + db = db.Where("name ILIKE ?", "%"+params.Search+"%") } - return db.Order("created_at DESC").Order("updated_at DESC") + return db.Order("name ASC").Order("id ASC") }) if scopeErr != nil { diff --git a/internal/modules/master/locations/services/location.service.go b/internal/modules/master/locations/services/location.service.go index 8aa01dbf..138c33b9 100644 --- a/internal/modules/master/locations/services/location.service.go +++ b/internal/modules/master/locations/services/location.service.go @@ -71,7 +71,7 @@ func (s locationService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit ) `, utils.ProjectFlockCategoryLaying) } - return db.Order("created_at DESC").Order("updated_at DESC") + return db.Order("locations.name ASC").Order("locations.id ASC") }) if scopeErr != nil { diff --git a/internal/modules/production/project_flocks/repositories/projectflock.repository.go b/internal/modules/production/project_flocks/repositories/projectflock.repository.go index 6fab653f..60b5fc01 100644 --- a/internal/modules/production/project_flocks/repositories/projectflock.repository.go +++ b/internal/modules/production/project_flocks/repositories/projectflock.repository.go @@ -322,8 +322,8 @@ func (r *ProjectflockRepositoryImpl) buildOrderExpressions(sortBy, sortOrder str } default: return []string{ - "project_flocks.created_at DESC", - "project_flocks.updated_at DESC", + "project_flocks.flock_name ASC", + "project_flocks.id ASC", } } } From bca02800d651fb15aba2de8bf3ab97d07e863322 Mon Sep 17 00:00:00 2001 From: giovanni Date: Mon, 13 Apr 2026 14:47:00 +0700 Subject: [PATCH 17/17] add query param get customer has marketing --- .../controllers/customer.controller.go | 8 +++++++ .../customers/services/customer.service.go | 23 ++++++++++++++++++- .../validations/customer.validation.go | 7 +++--- 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/internal/modules/master/customers/controllers/customer.controller.go b/internal/modules/master/customers/controllers/customer.controller.go index 02805f6f..d85a7a13 100644 --- a/internal/modules/master/customers/controllers/customer.controller.go +++ b/internal/modules/master/customers/controllers/customer.controller.go @@ -33,6 +33,14 @@ func (u *CustomerController) GetAll(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") } + if hasMarketingParam := c.Query("has_marketing", ""); hasMarketingParam != "" { + value, err := strconv.ParseBool(hasMarketingParam) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "invalid has_marketing value") + } + query.HasMarketing = &value + } + result, totalResults, err := u.CustomerService.GetAll(c, query) if err != nil { return err diff --git a/internal/modules/master/customers/services/customer.service.go b/internal/modules/master/customers/services/customer.service.go index 6156dc8c..416d65b3 100644 --- a/internal/modules/master/customers/services/customer.service.go +++ b/internal/modules/master/customers/services/customer.service.go @@ -53,7 +53,28 @@ func (s customerService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit customers, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) if params.Search != "" { - return db.Where("name ILIKE ?", "%"+params.Search+"%") + db = db.Where("name ILIKE ?", "%"+params.Search+"%") + if params.HasMarketing != nil && *params.HasMarketing { + db = db.Where(` + EXISTS ( + SELECT 1 + FROM marketings + WHERE marketings.customer_id = customers.id + AND marketings.deleted_at IS NULL + ) + `) + } + return db + } + if params.HasMarketing != nil && *params.HasMarketing { + db = db.Where(` + EXISTS ( + SELECT 1 + FROM marketings + WHERE marketings.customer_id = customers.id + AND marketings.deleted_at IS NULL + ) + `) } return db.Order("created_at DESC").Order("updated_at DESC") }) diff --git a/internal/modules/master/customers/validations/customer.validation.go b/internal/modules/master/customers/validations/customer.validation.go index 457bbf9a..e04c33aa 100644 --- a/internal/modules/master/customers/validations/customer.validation.go +++ b/internal/modules/master/customers/validations/customer.validation.go @@ -21,7 +21,8 @@ type Update struct { } type Query struct { - Page int `query:"page" validate:"omitempty,number,min=1"` - Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` - Search string `query:"search" validate:"omitempty,max=50"` + Page int `query:"page" validate:"omitempty,number,min=1"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` + Search string `query:"search" validate:"omitempty,max=50"` + HasMarketing *bool `query:"has_marketing" validate:"omitempty"` }