From 80f190b69bdd99fe33f7a654d5a7dee926ff1bde Mon Sep 17 00:00:00 2001 From: giovanni Date: Wed, 8 Apr 2026 13:41:17 +0700 Subject: [PATCH 1/8] 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 2/8] 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 3/8] 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 4/8] 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 5/8] 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 6/8] 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 7/8] 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 8/8] 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