From f910d165e4d1354c4c7ab4066103d577bb434702 Mon Sep 17 00:00:00 2001 From: giovanni Date: Tue, 5 May 2026 14:06:41 +0700 Subject: [PATCH 01/23] fix laporan daily checklist kandang kosong --- .../services/daily-checklist.service.go | 407 +++++++++++++----- 1 file changed, 291 insertions(+), 116 deletions(-) diff --git a/internal/modules/daily-checklists/services/daily-checklist.service.go b/internal/modules/daily-checklists/services/daily-checklist.service.go index fdbe4c8b..2f0c3157 100644 --- a/internal/modules/daily-checklists/services/daily-checklist.service.go +++ b/internal/modules/daily-checklists/services/daily-checklist.service.go @@ -1459,11 +1459,12 @@ func (s dailyChecklistService) GetReport(c *fiber.Ctx, params *validation.Report Group("a.id, a.name, loc.id, loc.name, k.id, k.name, e.id, e.name, p.id, p.name") } - var total int64 + // --- Count approved rows --- + var approvedTotal int64 groupedForCount := buildGroupedQuery() if err := s.Repository.DB().WithContext(c.Context()). Table("(?) AS grouped", groupedForCount). - Count(&total).Error; err != nil { + Count(&approvedTotal).Error; err != nil { s.Log.Errorf("Failed to count report data: %+v", err) return nil, 0, err } @@ -1483,19 +1484,246 @@ func (s dailyChecklistService) GetReport(c *fiber.Ctx, params *validation.Report TotalAssignments int64 } - rows := make([]reportRow, 0) - if err := buildGroupedQuery(). - Order("a.name, loc.name, k.name, e.name"). - Offset(offset). - Limit(params.Limit). - Scan(&rows).Error; err != nil { - s.Log.Errorf("Failed to fetch report data: %+v", err) + type fallbackRowType struct { + AreaID uint + AreaName string + LocationID uint + LocationName string + KandangID uint + KandangName string + EmployeeID uint + EmployeeName string + } + + // buildFallbackQ returns employees in kandangs that have NO approved checklist data + // for the filtered period. Applies the same scope/area/location/kandang/employee filters. + buildFallbackQ := func() *gorm.DB { + approvedKandangSubQ := buildBase().Select("DISTINCT dc.kandang_id") + q := s.Repository.DB().WithContext(c.Context()). + Table("employee_kandangs ek"). + Joins("JOIN employees e ON e.id = ek.employee_id AND e.deleted_at IS NULL"). + Joins("JOIN kandang_groups k ON k.id = ek.kandang_id AND k.deleted_at IS NULL"). + Joins("JOIN locations loc ON loc.id = k.location_id AND loc.deleted_at IS NULL"). + Joins("JOIN areas a ON a.id = loc.area_id AND a.deleted_at IS NULL"). + Where("ek.kandang_id NOT IN (?)", approvedKandangSubQ). + Select("e.id AS employee_id, e.name AS employee_name, k.id AS kandang_id, k.name AS kandang_name, loc.id AS location_id, loc.name AS location_name, a.id AS area_id, a.name AS area_name") + q = m.ApplyScopeFilter(q, locationScope, "loc.id") + q = m.ApplyScopeFilter(q, areaScope, "a.id") + if params.AreaID != nil { + q = q.Where("a.id = ?", *params.AreaID) + } + if params.LocationID != nil { + q = q.Where("loc.id = ?", *params.LocationID) + } + if params.KandangID != nil { + q = q.Where("ek.kandang_id = ?", *params.KandangID) + } + if params.EmployeeID != nil { + q = q.Where("ek.employee_id = ?", *params.EmployeeID) + } + // PhaseID not applied: fallback rows have no phase data + return q + } + + // --- Count fallback rows --- + var fallbackTotal int64 + if err := s.Repository.DB().WithContext(c.Context()). + Table("(?) AS fb", buildFallbackQ()). + Count(&fallbackTotal).Error; err != nil { + s.Log.Errorf("Failed to count fallback report data: %+v", err) return nil, 0, err } - if len(rows) == 0 { + total := approvedTotal + fallbackTotal + + // --- Fetch ALL approved rows (pagination done in Go after merging with fallback) --- + allApprovedRows := make([]reportRow, 0) + if approvedTotal > 0 { + if err := buildGroupedQuery(). + Order("a.name, loc.name, k.name, e.name"). + Scan(&allApprovedRows).Error; err != nil { + s.Log.Errorf("Failed to fetch report data: %+v", err) + return nil, 0, err + } + } + + // --- Fetch ALL fallback rows --- + allFallbackRows := make([]fallbackRowType, 0) + if fallbackTotal > 0 { + if err := buildFallbackQ(). + Order("a.name, loc.name, k.name, e.name"). + Scan(&allFallbackRows).Error; err != nil { + s.Log.Errorf("Failed to fetch fallback report data: %+v", err) + return nil, 0, err + } + } + + // --- Merge approved + fallback and sort consistently --- + type mergedEntry struct { + AreaName string + LocationName string + KandangName string + EmployeeName string + IsApproved bool + Idx int + } + + merged := make([]mergedEntry, 0, len(allApprovedRows)+len(allFallbackRows)) + for i, r := range allApprovedRows { + merged = append(merged, mergedEntry{ + AreaName: r.AreaName, LocationName: r.LocationName, + KandangName: r.KandangName, EmployeeName: r.EmployeeName, + IsApproved: true, Idx: i, + }) + } + for i, r := range allFallbackRows { + merged = append(merged, mergedEntry{ + AreaName: r.AreaName, LocationName: r.LocationName, + KandangName: r.KandangName, EmployeeName: r.EmployeeName, + IsApproved: false, Idx: i, + }) + } + sort.Slice(merged, func(i, j int) bool { + a, b := merged[i], merged[j] + if a.AreaName != b.AreaName { + return a.AreaName < b.AreaName + } + if a.LocationName != b.LocationName { + return a.LocationName < b.LocationName + } + if a.KandangName != b.KandangName { + return a.KandangName < b.KandangName + } + return a.EmployeeName < b.EmployeeName + }) + + // --- Apply Go-level pagination --- + end := offset + params.Limit + if end > len(merged) { + end = len(merged) + } + if offset >= len(merged) { return []DailyChecklistReportItem{}, total, nil } + pageData := merged[offset:end] + + // --- Split page into approved vs fallback rows --- + pageApproved := make([]reportRow, 0) + pageFallback := make([]fallbackRowType, 0) + for _, entry := range pageData { + if entry.IsApproved { + pageApproved = append(pageApproved, allApprovedRows[entry.Idx]) + } else { + pageFallback = append(pageFallback, allFallbackRows[entry.Idx]) + } + } + + applyEmptyKandangFlags := func(items []DailyChecklistReportItem, kandangIDs []uint) error { + if len(kandangIDs) == 0 { + return nil + } + firstDay := time.Date(params.Year, time.Month(params.Month), 1, 0, 0, 0, 0, time.UTC) + lastDay := firstDay.AddDate(0, 1, 0).AddDate(0, 0, -1) + today := time.Now().UTC().Truncate(24 * time.Hour) + + type emptyKandangRec struct { + KandangID uint + Date time.Time + } + var emptyRecs []emptyKandangRec + if err := s.Repository.DB().WithContext(c.Context()). + Model(&entity.DailyChecklist{}). + Where("kandang_id IN ? AND category = ? AND date <= ? AND deleted_at IS NULL", + kandangIDs, dailyChecklistCategoryEmptyKandang, lastDay). + Select("kandang_id, date"). + Scan(&emptyRecs).Error; err != nil { + s.Log.Errorf("Failed to get empty kandang records for report: %+v", err) + return err + } + + emptyDaysByKandang := make(map[uint]map[int]struct{}) + + if len(emptyRecs) > 0 { + minEmptyDate := emptyRecs[0].Date + for _, rec := range emptyRecs[1:] { + if rec.Date.Before(minEmptyDate) { + minEmptyDate = rec.Date + } + } + + type checklistDateRec struct { + KandangID uint + Date time.Time + } + var nextDates []checklistDateRec + if err := s.Repository.DB().WithContext(c.Context()). + Model(&entity.DailyChecklist{}). + Where("kandang_id IN ? AND category != ? AND date > ? AND (status IS NULL OR status != ?) AND deleted_at IS NULL", + kandangIDs, dailyChecklistCategoryEmptyKandang, minEmptyDate, dailyChecklistStatusRejected). + Select("kandang_id, date"). + Order("kandang_id ASC, date ASC"). + Scan(&nextDates).Error; err != nil { + s.Log.Errorf("Failed to get next checklist dates for empty kandang: %+v", err) + return err + } + + nextDatesByKandang := make(map[uint][]time.Time) + for _, row := range nextDates { + nextDatesByKandang[row.KandangID] = append(nextDatesByKandang[row.KandangID], row.Date) + } + + for _, rec := range emptyRecs { + var nextDate time.Time + for _, d := range nextDatesByKandang[rec.KandangID] { + if d.After(rec.Date) { + nextDate = d + break + } + } + + // If no next checklist, cap empty period at today (not end of month) + ceiling := lastDay + if today.Before(lastDay) { + ceiling = today + } + periodEnd := ceiling + if !nextDate.IsZero() { + periodEnd = nextDate.AddDate(0, 0, -1) + } + + effectiveStart := rec.Date + if effectiveStart.Before(firstDay) { + effectiveStart = firstDay + } + effectiveEnd := periodEnd + if effectiveEnd.After(lastDay) { + effectiveEnd = lastDay + } + + if effectiveStart.After(effectiveEnd) { + continue + } + + if _, ok := emptyDaysByKandang[rec.KandangID]; !ok { + emptyDaysByKandang[rec.KandangID] = make(map[int]struct{}) + } + for d := effectiveStart; !d.After(effectiveEnd); d = d.AddDate(0, 0, 1) { + emptyDaysByKandang[rec.KandangID][d.Day()] = struct{}{} + } + } + } + + for i, item := range items { + daySet := emptyDaysByKandang[item.KandangID] + for day := range daySet { + key := strconv.Itoa(day) + if _, exists := items[i].DailyActivities[key]; !exists { + items[i].DailyActivities[key] = "Kandang kosong" + } + } + } + return nil + } type comboKey struct { EmployeeID uint @@ -1517,7 +1745,7 @@ func (s dailyChecklistService) GetReport(c *fiber.Ctx, params *validation.Report kandangSet := make(map[uint]struct{}) phaseSet := make(map[uint]struct{}) - for _, row := range rows { + for _, row := range pageApproved { key := comboKey{EmployeeID: row.EmployeeID, KandangID: row.KandangID, PhaseID: row.PhaseID} comboSet[key] = struct{}{} if _, ok := employeeSet[row.EmployeeID]; !ok { @@ -1658,8 +1886,9 @@ func (s dailyChecklistService) GetReport(c *fiber.Ctx, params *validation.Report return selected } - items := make([]DailyChecklistReportItem, len(rows)) - for i, row := range rows { + // --- Build approved items (existing logic) --- + approvedItems := make([]DailyChecklistReportItem, len(pageApproved)) + for i, row := range pageApproved { key := comboKey{EmployeeID: row.EmployeeID, KandangID: row.KandangID, PhaseID: row.PhaseID} activities := dailyActivityMap[key] @@ -1706,7 +1935,7 @@ func (s dailyChecklistService) GetReport(c *fiber.Ctx, params *validation.Report kandangPercentage = int(math.Round(float64(kandangStat.Completed) / float64(kandangStat.Total) * 100)) } - items[i] = DailyChecklistReportItem{ + approvedItems[i] = DailyChecklistReportItem{ AreaID: row.AreaID, AreaName: row.AreaName, LocationID: row.LocationID, @@ -1727,109 +1956,55 @@ func (s dailyChecklistService) GetReport(c *fiber.Ctx, params *validation.Report } } - // Flag empty kandang days within this report month - if len(kandangIDs) > 0 { - firstDay := time.Date(params.Year, time.Month(params.Month), 1, 0, 0, 0, 0, time.UTC) - lastDay := firstDay.AddDate(0, 1, 0).AddDate(0, 0, -1) - today := time.Now().UTC().Truncate(24 * time.Hour) - - type emptyKandangRec struct { - KandangID uint - Date time.Time - } - var emptyRecs []emptyKandangRec - if err := s.Repository.DB().WithContext(c.Context()). - Model(&entity.DailyChecklist{}). - Where("kandang_id IN ? AND category = ? AND date <= ? AND deleted_at IS NULL", - kandangIDs, dailyChecklistCategoryEmptyKandang, lastDay). - Select("kandang_id, date"). - Scan(&emptyRecs).Error; err != nil { - s.Log.Errorf("Failed to get empty kandang records for report: %+v", err) - return nil, 0, err - } - - emptyDaysByKandang := make(map[uint]map[int]struct{}) - - if len(emptyRecs) > 0 { - minEmptyDate := emptyRecs[0].Date - for _, rec := range emptyRecs[1:] { - if rec.Date.Before(minEmptyDate) { - minEmptyDate = rec.Date - } - } - - type checklistDateRec struct { - KandangID uint - Date time.Time - } - var nextDates []checklistDateRec - if err := s.Repository.DB().WithContext(c.Context()). - Model(&entity.DailyChecklist{}). - Where("kandang_id IN ? AND category != ? AND date > ? AND (status IS NULL OR status != ?) AND deleted_at IS NULL", - kandangIDs, dailyChecklistCategoryEmptyKandang, minEmptyDate, dailyChecklistStatusRejected). - Select("kandang_id, date"). - Order("kandang_id ASC, date ASC"). - Scan(&nextDates).Error; err != nil { - s.Log.Errorf("Failed to get next checklist dates for empty kandang: %+v", err) - return nil, 0, err - } - - nextDatesByKandang := make(map[uint][]time.Time) - for _, row := range nextDates { - nextDatesByKandang[row.KandangID] = append(nextDatesByKandang[row.KandangID], row.Date) - } - - for _, rec := range emptyRecs { - var nextDate time.Time - for _, d := range nextDatesByKandang[rec.KandangID] { - if d.After(rec.Date) { - nextDate = d - break - } - } - - // If no next checklist, cap empty period at today (not end of month) - ceiling := lastDay - if today.Before(lastDay) { - ceiling = today - } - periodEnd := ceiling - if !nextDate.IsZero() { - periodEnd = nextDate.AddDate(0, 0, -1) - } - - effectiveStart := rec.Date - if effectiveStart.Before(firstDay) { - effectiveStart = firstDay - } - effectiveEnd := periodEnd - if effectiveEnd.After(lastDay) { - effectiveEnd = lastDay - } - - if effectiveStart.After(effectiveEnd) { - continue - } - - if _, ok := emptyDaysByKandang[rec.KandangID]; !ok { - emptyDaysByKandang[rec.KandangID] = make(map[int]struct{}) - } - for d := effectiveStart; !d.After(effectiveEnd); d = d.AddDate(0, 0, 1) { - emptyDaysByKandang[rec.KandangID][d.Day()] = struct{}{} - } - } - } - - for i, item := range items { - daySet := emptyDaysByKandang[item.KandangID] - for day := range daySet { - key := strconv.Itoa(day) - if _, exists := items[i].DailyActivities[key]; !exists { - items[i].DailyActivities[key] = "Kandang kosong" - } - } + // --- Build fallback items (kandangs with no approved data) --- + fallbackItems := make([]DailyChecklistReportItem, len(pageFallback)) + for i, fb := range pageFallback { + fallbackItems[i] = DailyChecklistReportItem{ + AreaID: fb.AreaID, + AreaName: fb.AreaName, + LocationID: fb.LocationID, + LocationName: fb.LocationName, + KandangID: fb.KandangID, + KandangName: fb.KandangName, + EmployeeID: fb.EmployeeID, + EmployeeName: fb.EmployeeName, + PhaseName: "", + DailyActivities: map[string]any{}, + Summary: DailyChecklistReportSummary{}, } } - return items, total, nil + // --- Reconstruct allItems in the sorted pageData order --- + allItems := make([]DailyChecklistReportItem, len(pageData)) + approvedIdx := 0 + fallbackIdx := 0 + for i, entry := range pageData { + if entry.IsApproved { + allItems[i] = approvedItems[approvedIdx] + approvedIdx++ + } else { + allItems[i] = fallbackItems[fallbackIdx] + fallbackIdx++ + } + } + + // --- Collect all kandangIDs on this page (approved + fallback) for empty_kandang flags --- + allKandangSet := make(map[uint]struct{}) + for _, id := range kandangIDs { + allKandangSet[id] = struct{}{} + } + for _, fb := range pageFallback { + allKandangSet[fb.KandangID] = struct{}{} + } + allKandangIDs := make([]uint, 0, len(allKandangSet)) + for id := range allKandangSet { + allKandangIDs = append(allKandangIDs, id) + } + + // --- Flag empty kandang days within this report month --- + if err := applyEmptyKandangFlags(allItems, allKandangIDs); err != nil { + return nil, 0, err + } + + return allItems, total, nil } From 8900937e711f656da313d6511666df3eae8fec86 Mon Sep 17 00:00:00 2001 From: giovanni Date: Tue, 5 May 2026 16:38:37 +0700 Subject: [PATCH 02/23] fix woa at hasil produksi --- .../modules/repports/services/repport.service.go | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 02bfdf5a..54af581d 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -882,8 +882,6 @@ func (s *repportService) GetProductionResult(ctx *fiber.Ctx, params *validation. defaultBw = 0 defaultUniformText = "90% up" ) - defaultStartWoa := config.LayingWeekStart() - if params.Limit <= 0 { params.Limit = 10 } @@ -933,7 +931,7 @@ func (s *repportService) GetProductionResult(ctx *fiber.Ctx, params *validation. weeks := make([]int, len(weeklyResults)) for i := range weeklyResults { - weeks[i] = defaultStartWoa + i + weeks[i] = int(weeklyResults[i].Woa) } uniformityMap, err := s.getUniformityByWeek(ctx.Context(), params.ProjectFlockKandangID, weeks) if err != nil { @@ -943,13 +941,12 @@ func (s *repportService) GetProductionResult(ctx *fiber.Ctx, params *validation. var cumulativeButir int64 var cumulativeKg float64 for i := range weeklyResults { - weeklyResults[i].Woa = float64(defaultStartWoa + i) weeklyResults[i].StdBw = defaultStdBw weeklyResults[i].Bw = defaultBw if weeklyResults[i].StdUniformity == "" { weeklyResults[i].StdUniformity = defaultUniformText } - if uniformity, ok := uniformityMap[defaultStartWoa+i]; ok { + if uniformity, ok := uniformityMap[int(weeklyResults[i].Woa)]; ok { weeklyResults[i].Uniformity = uniformity.Uniformity if uniformity.AvgWeight != nil { weeklyResults[i].Bw = *uniformity.AvgWeight @@ -1356,8 +1353,8 @@ func mapRecordingToProductionResultDTO(record entity.Recording) dto.ProductionRe Ew: valueOrZero(record.EggWeight), } - if record.Day != nil { - result.Woa = float64(*record.Day) + if record.Day != nil && *record.Day > 0 { + result.Woa = float64((*record.Day + 6) / 7) // ceil(day/7) } avgWeight := 1.0 if avgWeight > 0 { @@ -1588,6 +1585,7 @@ func aggregateProductionResultGroup(group []dto.ProductionResultDTO, groupSize i CreatedAt: group[0].CreatedAt, UpdatedAt: group[0].UpdatedAt, StdUniformity: group[0].StdUniformity, + Woa: group[0].Woa, } var sumBw, sumStdBw, sumUniformity float64 From 7e01d8afb92af23500da909d8ce3cb213bb851d7 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Wed, 6 May 2026 13:13:02 +0700 Subject: [PATCH 03/23] fix: adjust marketing report pdf column and copywriting --- .../controllers/repport.marketing.pdf.go | 96 ++++++++++++------- 1 file changed, 61 insertions(+), 35 deletions(-) diff --git a/internal/modules/repports/controllers/repport.marketing.pdf.go b/internal/modules/repports/controllers/repport.marketing.pdf.go index 8bac933f..f5375a30 100644 --- a/internal/modules/repports/controllers/repport.marketing.pdf.go +++ b/internal/modules/repports/controllers/repport.marketing.pdf.go @@ -55,19 +55,19 @@ type pdfColumn struct { var marketingPdfColumns = []pdfColumn{ {"No", 6, "C"}, - {"Tanggal Sales Order", 16, "C"}, - {"Tanggal Delivery Order", 16, "C"}, + {"Tanggal\nJual", 16, "C"}, + {"Tanggal\nRealisasi", 16, "C"}, {"Aging\n(Hari)", 9, "C"}, - {"Gudang Fisik", 20, "L"}, + {"Gudang\nFisik", 20, "L"}, {"Pelanggan", 20, "L"}, + {"No. DO", 18, "L"}, {"Sales", 18, "L"}, - {"Produk", 16, "L"}, - {"Nomor DO", 14, "C"}, - {"Nomor Polisi", 14, "C"}, + {"No. Polisi", 18, "L"}, {"Tipe\nMarketing", 14, "C"}, - {"Quantity", 13, "R"}, - {"Rata-Rata\n(Kg)", 13, "R"}, - {"Total Berat\n(Kg)", 14, "R"}, + {"Produk", 16, "L"}, + {"Kuantitas", 13, "R"}, + {"Bobot Rata-Rata\n(Kg)", 13, "R"}, + {"Bobot Total Berat\n(Kg)", 14, "R"}, {"Harga Jual\n(Rp)", 17, "R"}, {"HPP\n(Rp)", 17, "R"}, {"Total Jual\n(Rp)", 18, "R"}, @@ -79,14 +79,14 @@ var marketingPdfColumns = []pdfColumn{ // --------------------------------------------------------------------------- const ( - headerR, headerG, headerB = 30, 64, 120 // dark blue header bg + headerR, headerG, headerB = 30, 64, 120 // dark blue header bg headerTextR, headerTextG, headerTextB = 255, 255, 255 // white header text - rowAltR, rowAltG, rowAltB = 245, 247, 250 // alternating row bg - borderR, borderG, borderB = 200, 200, 200 // light border + rowAltR, rowAltG, rowAltB = 245, 247, 250 // alternating row bg + borderR, borderG, borderB = 200, 200, 200 // light border - badgeTelurR, badgeTelurG, badgeTelurB = 59, 130, 246 // blue - badgeAyamR, badgeAyamG, badgeAyamB = 34, 197, 94 // green - badgeTradingR, badgeTradingG, badgeTradingB = 249, 115, 22 // orange + badgeTelurR, badgeTelurG, badgeTelurB = 59, 130, 246 // blue + badgeAyamR, badgeAyamG, badgeAyamB = 34, 197, 94 // green + badgeTradingR, badgeTradingG, badgeTradingB = 249, 115, 22 // orange badgeDefaultR, badgeDefaultG, badgeDefaultB = 107, 114, 128 // gray ) @@ -184,50 +184,76 @@ func writeMarketingPdfRows(pdf *fpdf.Fpdf, items []dto.RepportMarketingItemDTO) pdf.SetDrawColor(borderR, borderG, borderB) pdf.SetLineWidth(0.1) - rowH := 6.0 + lineH := 5.0 for idx, item := range items { - // page break check + values := marketingPdfRowValues(idx+1, item) + rowH := calcMarketingRowHeight(pdf, values, lineH) + if pdf.GetY()+rowH > marketingPdfPageHeight(pdf)-12 { pdf.AddPage() writeMarketingPdfHeader(pdf) pdf.SetFont("Helvetica", "", 6) } - // alternating bg + var fillR, fillG, fillB int if idx%2 == 1 { - pdf.SetFillColor(rowAltR, rowAltG, rowAltB) + fillR, fillG, fillB = rowAltR, rowAltG, rowAltB } else { - pdf.SetFillColor(255, 255, 255) + fillR, fillG, fillB = 255, 255, 255 } pdf.SetTextColor(40, 40, 40) y := pdf.GetY() - writeMarketingPdfRow(pdf, idx+1, item, rowH, y) + writeMarketingPdfRow(pdf, item, values, lineH, rowH, y, fillR, fillG, fillB) } } -func writeMarketingPdfRow(pdf *fpdf.Fpdf, no int, item dto.RepportMarketingItemDTO, h, y float64) { - fill := true // use the fill colour already set - +func calcMarketingRowHeight(pdf *fpdf.Fpdf, values []string, lineH float64) float64 { + margin := pdf.GetCellMargin() cols := marketingPdfColumns - x := 10.0 // left margin + maxLines := 1 + for i, col := range cols { + if i >= len(values) || i == 10 { + continue + } + usableW := col.width - 2*margin + if usableW <= 0 { + continue + } + lines := pdf.SplitLines([]byte(values[i]), usableW) + n := len(lines) + if n == 0 { + n = 1 + } + if n > maxLines { + maxLines = n + } + } + return float64(maxLines) * lineH +} - values := marketingPdfRowValues(no, item) +func writeMarketingPdfRow(pdf *fpdf.Fpdf, item dto.RepportMarketingItemDTO, values []string, lineH, rowH, y float64, fillR, fillG, fillB int) { + cols := marketingPdfColumns + x := 10.0 for i, col := range cols { - pdf.SetXY(x, y) - - if i == 10 { // Tipe Marketing → badge - drawMarketingTypeBadge(pdf, x, y, col.width, h, item.MarketingType) + if i == 10 { + drawMarketingTypeBadge(pdf, x, y, col.width, rowH, item.MarketingType) + pdf.SetDrawColor(borderR, borderG, borderB) + pdf.SetTextColor(40, 40, 40) } else { - pdf.CellFormat(col.width, h, values[i], "1", 0, col.align, fill, 0, "") + pdf.SetFillColor(fillR, fillG, fillB) + pdf.SetDrawColor(borderR, borderG, borderB) + pdf.Rect(x, y, col.width, rowH, "FD") + pdf.SetTextColor(40, 40, 40) + pdf.SetXY(x, y) + pdf.MultiCell(col.width, lineH, values[i], "", col.align, false) } - x += col.width } - pdf.SetXY(10, y+h) + pdf.SetXY(10, y+rowH) } func marketingPdfRowValues(no int, item dto.RepportMarketingItemDTO) []string { @@ -255,11 +281,11 @@ func marketingPdfRowValues(no int, item dto.RepportMarketingItemDTO) []string { strconv.Itoa(item.AgingDays), warehouse, customer, - sales, - product, safeMarketingExportText(item.DoNumber), + sales, safeMarketingExportText(item.VehicleNumber), safeMarketingExportText(item.MarketingType), // index 10, overridden by badge + product, formatMarketingPdfNumber(item.Qty), formatMarketingPdfDecimal(item.AverageWeightKg), formatMarketingPdfDecimal(item.TotalWeightKg), From 90ed035abdd78ff77ebfe4b126d65c25422eadb2 Mon Sep 17 00:00:00 2001 From: giovanni Date: Thu, 7 May 2026 10:56:30 +0700 Subject: [PATCH 04/23] add feed use at export excel recording --- .../controllers/recording.export.go | 70 ++++++++++++++----- .../recordings/dto/recording.dto.go | 48 +++++++++++-- .../repositories/recording.repository.go | 5 +- 3 files changed, 100 insertions(+), 23 deletions(-) diff --git a/internal/modules/production/recordings/controllers/recording.export.go b/internal/modules/production/recordings/controllers/recording.export.go index 3f5294ab..55fd2f61 100644 --- a/internal/modules/production/recordings/controllers/recording.export.go +++ b/internal/modules/production/recordings/controllers/recording.export.go @@ -77,6 +77,10 @@ func setRecordingExportColumns(file *excelize.File, sheet string) error { "Z": 22, "AA": 16, "AB": 18, + "AC": 24, + "AD": 18, + "AE": 18, + "AF": 18, } for col, width := range columnWidths { @@ -96,7 +100,7 @@ func setRecordingExportColumns(file *excelize.File, sheet string) error { } func setRecordingExportHeaders(file *excelize.File, sheet string) error { - verticalHeaderCols := []string{"A", "B", "C", "D", "E", "F", "G", "H", "I", "Y", "Z", "AA", "AB"} + verticalHeaderCols := []string{"A", "B", "C", "D", "E", "F", "G", "H", "I", "Y", "Z", "AA", "AB", "AC", "AD", "AE", "AF"} for _, col := range verticalHeaderCols { if err := file.MergeCell(sheet, col+"1", col+"2"); err != nil { return err @@ -104,19 +108,23 @@ func setRecordingExportHeaders(file *excelize.File, sheet string) error { } 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", + "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", + "AC1": "Nama Pakan", + "AD1": "Jumlah Input Pakan", + "AE1": "Jumlah Penggunaan", + "AF1": "Pending Qty", } for cell, value := range headerValues { if err := file.SetCellValue(sheet, cell, value); err != nil { @@ -230,7 +238,7 @@ func setRecordingExportHeaders(file *excelize.File, sheet string) error { return err } - return file.SetCellStyle(sheet, "A1", "AB2", headerStyle) + return file.SetCellStyle(sheet, "A1", "AF2", headerStyle) } func setRecordingExportRows(file *excelize.File, sheet string, items []dto.RecordingListDTO) error { @@ -241,6 +249,7 @@ func setRecordingExportRows(file *excelize.File, sheet string, items []dto.Recor 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", + "AC", "AD", "AE", "AF", } for i, item := range items { @@ -283,6 +292,29 @@ func setRecordingExportRows(file *excelize.File, sheet string, items []dto.Recor createdBy = safeExportText(item.Approval.ActionBy.Name) } + // Build feed usage columns — concatenate multiple feeds with newline + feedNames := make([]string, 0, len(item.FeedUsage)) + usageAmounts := make([]string, 0, len(item.FeedUsage)) + pendingQtys := make([]string, 0, len(item.FeedUsage)) + inputQtys := make([]string, 0, len(item.FeedUsage)) + for _, fu := range item.FeedUsage { + feedNames = append(feedNames, safeExportText(fu.ProductName)) + usageAmounts = append(usageAmounts, formatNumberID(fu.UsageAmount, 2, true)) + pendingQtys = append(pendingQtys, formatNumberID(fu.PendingQty, 2, true)) + inputQtys = append(inputQtys, formatNumberID(fu.UsageAmount+fu.PendingQty, 2, true)) + } + + feedNameCol := "-" + usageCol := "-" + pendingCol := "-" + inputCol := "-" + if len(feedNames) > 0 { + feedNameCol = strings.Join(feedNames, "\n") + usageCol = strings.Join(usageAmounts, "\n") + pendingCol = strings.Join(pendingQtys, "\n") + inputCol = strings.Join(inputQtys, "\n") + } + rowValues := []interface{}{ i + 1, locationName, @@ -312,6 +344,10 @@ func setRecordingExportRows(file *excelize.File, sheet string, items []dto.Recor safeExportText(pointerString(item.Approval.Notes)), createdBy, formatDateIndonesian(item.CreatedAt), + feedNameCol, // AC + inputCol, // AD - Jumlah Input Pakan + usageCol, // AE - Jumlah Penggunaan + pendingCol, // AF - Pending Qty } for idx, col := range columns { @@ -339,7 +375,7 @@ func setRecordingExportRows(file *excelize.File, sheet string, items []dto.Recor if err != nil { return err } - if err := file.SetCellStyle(sheet, "A3", fmt.Sprintf("AB%d", lastRow), dataCenterStyle); err != nil { + if err := file.SetCellStyle(sheet, "A3", fmt.Sprintf("AF%d", lastRow), dataCenterStyle); err != nil { return err } @@ -360,7 +396,7 @@ func setRecordingExportRows(file *excelize.File, sheet string, items []dto.Recor return err } - leftColumns := []string{"B", "C", "D", "F", "G", "H", "Y", "Z", "AA", "AB"} + leftColumns := []string{"B", "C", "D", "F", "G", "H", "Y", "Z", "AA", "AB", "AC"} for _, col := range leftColumns { if err := file.SetCellStyle(sheet, col+"3", fmt.Sprintf("%s%d", col, lastRow), dataLeftStyle); err != nil { return err diff --git a/internal/modules/production/recordings/dto/recording.dto.go b/internal/modules/production/recordings/dto/recording.dto.go index b92d5a3c..a4e3ee47 100644 --- a/internal/modules/production/recordings/dto/recording.dto.go +++ b/internal/modules/production/recordings/dto/recording.dto.go @@ -92,13 +92,20 @@ type RecordingRelationDTO struct { Approval approvalDTO.ApprovalRelationDTO `json:"approval"` } +type RecordingFeedUsageDTO struct { + ProductName string `json:"product_name"` + UsageAmount float64 `json:"usage_amount"` + PendingQty float64 `json:"pending_qty"` +} + type RecordingListDTO struct { RecordingRelationDTO - CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Kandang *RecordingKandangDTO `json:"kandang,omitempty"` - Location *RecordingLocationDTO `json:"location,omitempty"` + CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Kandang *RecordingKandangDTO `json:"kandang,omitempty"` + Location *RecordingLocationDTO `json:"location,omitempty"` + FeedUsage []RecordingFeedUsageDTO `json:"feed_usage,omitempty"` } type RecordingDetailDTO struct { @@ -192,6 +199,36 @@ func ToRecordingStockDTOs(stocks []entity.RecordingStock) []RecordingStockDTO { return result } +func ToRecordingFeedUsageDTOs(stocks []entity.RecordingStock) []RecordingFeedUsageDTO { + return toRecordingFeedUsageDTOs(stocks) +} + +func toRecordingFeedUsageDTOs(stocks []entity.RecordingStock) []RecordingFeedUsageDTO { + result := make([]RecordingFeedUsageDTO, 0, len(stocks)) + for _, s := range stocks { + productName := "" + if s.ProductWarehouse.Product.Id != 0 { + productName = s.ProductWarehouse.Product.Name + } + + var usageAmount float64 + if s.UsageQty != nil { + usageAmount = *s.UsageQty + } + var pendingQty float64 + if s.PendingQty != nil { + pendingQty = *s.PendingQty + } + + result = append(result, RecordingFeedUsageDTO{ + ProductName: productName, + UsageAmount: usageAmount, + PendingQty: pendingQty, + }) + } + return result +} + func ToRecordingEggDTOs(eggs []entity.RecordingEgg) []RecordingEggDTO { result := make([]RecordingEggDTO, len(eggs)) for i, egg := range eggs { @@ -222,6 +259,7 @@ func toRecordingListDTO(e entity.Recording) RecordingListDTO { CreatedUser: createdUser, Kandang: recordingKandangDTO(e), Location: recordingKandangLocationDTO(e), + FeedUsage: toRecordingFeedUsageDTOs(e.Stocks), } } diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index ceb747ae..ca989359 100644 --- a/internal/modules/production/recordings/repositories/recording.repository.go +++ b/internal/modules/production/recordings/repositories/recording.repository.go @@ -147,7 +147,10 @@ func (r *RecordingRepositoryImpl) WithRelationsList(db *gorm.DB) *gorm.DB { Preload("ProjectFlockKandang.Kandang"). Preload("ProjectFlockKandang.Kandang.Location"). Preload("ProjectFlockKandang.ProjectFlock"). - Preload("ProjectFlockKandang.ProjectFlock.ProductionStandard") + Preload("ProjectFlockKandang.ProjectFlock.ProductionStandard"). + Preload("Stocks"). + Preload("Stocks.ProductWarehouse"). + Preload("Stocks.ProductWarehouse.Product") } func (r *RecordingRepositoryImpl) latestApprovalSubQuery(db *gorm.DB) *gorm.DB { From 0357531e7343916a7a7300773e3a376d5f634f35 Mon Sep 17 00:00:00 2001 From: giovanni Date: Thu, 7 May 2026 14:03:17 +0700 Subject: [PATCH 05/23] add sorting at marketing --- .../controllers/deliveryorder.controller.go | 8 ++++++++ .../services/deliveryorder.service.go | 20 ++++++++++++++++++- .../validations/deliveryorder.validation.go | 2 ++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/internal/modules/marketing/controllers/deliveryorder.controller.go b/internal/modules/marketing/controllers/deliveryorder.controller.go index bb285294..e9ccb285 100644 --- a/internal/modules/marketing/controllers/deliveryorder.controller.go +++ b/internal/modules/marketing/controllers/deliveryorder.controller.go @@ -56,6 +56,12 @@ func (u *DeliveryOrdersController) GetAll(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, "Invalid product_ids") } + sortBy := strings.TrimSpace(c.Query("sort_by", "")) + sortOrder := strings.TrimSpace(c.Query("sort_order", "")) + if sortOrder == "" { + sortOrder = "asc" + } + query := &validation.DeliveryOrderQuery{ Page: c.QueryInt("page", 1), Limit: c.QueryInt("limit", 10), @@ -66,6 +72,8 @@ func (u *DeliveryOrdersController) GetAll(c *fiber.Ctx) error { MarketingId: uint(c.QueryInt("marketing_id", 0)), ProjectFlockID: uint(c.QueryInt("project_flock_id", 0)), ProjectFlockKandangID: uint(c.QueryInt("project_flock_kandang_id", 0)), + SortBy: sortBy, + SortOrder: sortOrder, } if isAllExcelExportRequest(c) { diff --git a/internal/modules/marketing/services/deliveryorder.service.go b/internal/modules/marketing/services/deliveryorder.service.go index c06ff3de..5bd3c258 100644 --- a/internal/modules/marketing/services/deliveryorder.service.go +++ b/internal/modules/marketing/services/deliveryorder.service.go @@ -292,7 +292,25 @@ func (s deliveryOrdersService) GetAll(c *fiber.Ctx, params *validation.DeliveryO if params.MarketingId != 0 { return db.Where("id = ?", params.MarketingId) } - return db.Order("created_at DESC").Order("updated_at DESC") + + orderDir := "DESC" + if params.SortOrder != "" { + orderDir = strings.ToUpper(params.SortOrder) + } + + switch strings.TrimSpace(params.SortBy) { + case "so_number": + return db.Order("marketings.so_number " + orderDir) + case "so_date": + return db.Order("marketings.so_date " + orderDir) + case "status": + statusSQL := "(SELECT step_name FROM approvals WHERE approvable_type = '" + utils.ApprovalWorkflowMarketing.String() + "' AND approvable_id = marketings.id ORDER BY action_at DESC, id DESC LIMIT 1) " + orderDir + return db.Order(statusSQL) + case "customer": + return db.Joins("LEFT JOIN customers ON customers.id = marketings.customer_id").Order("COALESCE(customers.name, '') " + orderDir) + default: + return db.Order("created_at DESC").Order("updated_at DESC") + } }) if err != nil { return nil, 0, err diff --git a/internal/modules/marketing/validations/deliveryorder.validation.go b/internal/modules/marketing/validations/deliveryorder.validation.go index 4af4f89a..8b964221 100644 --- a/internal/modules/marketing/validations/deliveryorder.validation.go +++ b/internal/modules/marketing/validations/deliveryorder.validation.go @@ -31,6 +31,8 @@ type DeliveryOrderQuery struct { MarketingId uint `query:"marketing_id" validate:"omitempty,gt=0"` ProjectFlockID uint `query:"project_flock_id" validate:"omitempty,gt=0"` ProjectFlockKandangID uint `query:"project_flock_kandang_id" validate:"omitempty,gt=0"` + SortBy string `query:"sort_by" validate:"omitempty,oneof=so_number so_date status customer"` + SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"` } type DeliveryOrderApprove struct { From 094e8f904b31a22ca9cdde44cc8f508244a9d567 Mon Sep 17 00:00:00 2001 From: giovanni Date: Thu, 7 May 2026 14:11:39 +0700 Subject: [PATCH 06/23] add query sort by grand total --- internal/modules/marketing/services/deliveryorder.service.go | 2 ++ .../modules/marketing/validations/deliveryorder.validation.go | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/modules/marketing/services/deliveryorder.service.go b/internal/modules/marketing/services/deliveryorder.service.go index 5bd3c258..aa599894 100644 --- a/internal/modules/marketing/services/deliveryorder.service.go +++ b/internal/modules/marketing/services/deliveryorder.service.go @@ -308,6 +308,8 @@ func (s deliveryOrdersService) GetAll(c *fiber.Ctx, params *validation.DeliveryO return db.Order(statusSQL) case "customer": return db.Joins("LEFT JOIN customers ON customers.id = marketings.customer_id").Order("COALESCE(customers.name, '') " + orderDir) + case "grand_total": + return db.Order("(SELECT COALESCE(SUM(mp.total_price), 0) FROM marketing_products mp WHERE mp.marketing_id = marketings.id) " + orderDir) default: return db.Order("created_at DESC").Order("updated_at DESC") } diff --git a/internal/modules/marketing/validations/deliveryorder.validation.go b/internal/modules/marketing/validations/deliveryorder.validation.go index 8b964221..dba96eeb 100644 --- a/internal/modules/marketing/validations/deliveryorder.validation.go +++ b/internal/modules/marketing/validations/deliveryorder.validation.go @@ -31,7 +31,7 @@ type DeliveryOrderQuery struct { MarketingId uint `query:"marketing_id" validate:"omitempty,gt=0"` ProjectFlockID uint `query:"project_flock_id" validate:"omitempty,gt=0"` ProjectFlockKandangID uint `query:"project_flock_kandang_id" validate:"omitempty,gt=0"` - SortBy string `query:"sort_by" validate:"omitempty,oneof=so_number so_date status customer"` + SortBy string `query:"sort_by" validate:"omitempty,oneof=so_number so_date status customer grand_total"` SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"` } From 0d6ab5e718fc211e3566a790113d33ff950b7d91 Mon Sep 17 00:00:00 2001 From: giovanni Date: Thu, 7 May 2026 15:45:39 +0700 Subject: [PATCH 07/23] adjust path document for detail biaya --- .../modules/expenses/services/expense.service.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/internal/modules/expenses/services/expense.service.go b/internal/modules/expenses/services/expense.service.go index 860c9212..b7298f08 100644 --- a/internal/modules/expenses/services/expense.service.go +++ b/internal/modules/expenses/services/expense.service.go @@ -342,6 +342,18 @@ func (s expenseService) GetOne(c *fiber.Ctx, id uint) (*expenseDto.ExpenseDetail expense.LatestApproval = approval responseDTO := expenseDto.ToExpenseDetailDTO(expense) + + for i := range responseDTO.Documents { + if url, err := commonSvc.ResolveDocumentURL(c.Context(), s.DocumentSvc, responseDTO.Documents[i].Path, 15*time.Minute); err == nil && url != "" { + responseDTO.Documents[i].Path = url + } + } + for i := range responseDTO.RealizationDocs { + if url, err := commonSvc.ResolveDocumentURL(c.Context(), s.DocumentSvc, responseDTO.RealizationDocs[i].Path, 15*time.Minute); err == nil && url != "" { + responseDTO.RealizationDocs[i].Path = url + } + } + return &responseDTO, nil } From 6f02387d690be020c4c4d37614975331192fe8f4 Mon Sep 17 00:00:00 2001 From: giovanni Date: Thu, 7 May 2026 16:03:47 +0700 Subject: [PATCH 08/23] add field name to document --- internal/modules/expenses/dto/expense.dto.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/modules/expenses/dto/expense.dto.go b/internal/modules/expenses/dto/expense.dto.go index 4972a979..58166a6e 100644 --- a/internal/modules/expenses/dto/expense.dto.go +++ b/internal/modules/expenses/dto/expense.dto.go @@ -86,6 +86,7 @@ type KandangGroupDTO struct { type DocumentDTO struct { ID uint64 `json:"id"` Path string `json:"path"` + Name string `json:"name"` } // === MAPPERS === @@ -184,6 +185,7 @@ func ToExpenseDetailDTO(e *entity.Expense) ExpenseDetailDTO { documents = append(documents, DocumentDTO{ ID: uint64(doc.Id), Path: doc.Path, + Name: doc.Name, }) } @@ -191,6 +193,7 @@ func ToExpenseDetailDTO(e *entity.Expense) ExpenseDetailDTO { realizationDocs = append(realizationDocs, DocumentDTO{ ID: uint64(doc.Id), Path: doc.Path, + Name: doc.Name, }) } From 4c6942c7b760775d6f5230474b3ee051db31eea8 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Thu, 7 May 2026 17:16:06 +0700 Subject: [PATCH 09/23] fix: adjust exported file column order and copywriting --- .../controllers/repport.marketing.export.go | 107 +++++++++++++++-- .../controllers/repport.marketing.pdf.go | 113 ++++++++++++------ 2 files changed, 177 insertions(+), 43 deletions(-) diff --git a/internal/modules/repports/controllers/repport.marketing.export.go b/internal/modules/repports/controllers/repport.marketing.export.go index 866590c4..ab11a921 100644 --- a/internal/modules/repports/controllers/repport.marketing.export.go +++ b/internal/modules/repports/controllers/repport.marketing.export.go @@ -50,6 +50,14 @@ func buildMarketingReportWorkbook(items []dto.RepportMarketingItemDTO) ([]byte, if err := setMarketingReportRows(file, items); err != nil { return nil, err } + if err := file.SetPanes(marketingReportExportSheetName, &excelize.Panes{ + Freeze: true, + YSplit: 1, + TopLeftCell: "A2", + ActivePane: "bottomLeft", + }); err != nil { + return nil, err + } buffer, err := file.WriteToBuffer() if err != nil { @@ -88,6 +96,10 @@ func setMarketingReportColumns(file *excelize.File) error { } } + if err := file.SetRowHeight(sheet, 1, 24); err != nil { + return err + } + return nil } @@ -110,7 +122,6 @@ func setMarketingReportHeaders(file *excelize.File) error { "Bobot Total (Kg)", "Harga Jual (Rp)", "HPP (Rp)", - "HPP Amount (Rp)", "Total (Rp)", } @@ -124,7 +135,22 @@ func setMarketingReportHeaders(file *excelize.File) error { } } - return nil + headerStyle, err := file.NewStyle(&excelize.Style{ + Font: &excelize.Font{Bold: true, Color: "1F2937"}, + Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"DCEBFA"}}, + Alignment: &excelize.Alignment{Horizontal: "center", Vertical: "center"}, + Border: []excelize.Border{ + {Type: "left", Color: "D1D5DB", Style: 1}, + {Type: "top", Color: "D1D5DB", Style: 1}, + {Type: "bottom", Color: "D1D5DB", Style: 1}, + {Type: "right", Color: "D1D5DB", Style: 1}, + }, + }) + if err != nil { + return err + } + + return file.SetCellStyle(sheet, "A1", "Q1", headerStyle) } func setMarketingReportRows(file *excelize.File, items []dto.RepportMarketingItemDTO) error { @@ -173,7 +199,6 @@ func setMarketingReportRows(file *excelize.File, items []dto.RepportMarketingIte item.TotalWeightKg, formatMarketingRupiah(item.SalesPricePerKg), formatMarketingRupiah(item.HppPricePerKg), - formatMarketingRupiah(item.HppAmount), formatMarketingRupiah(item.SalesAmount), } @@ -210,15 +235,81 @@ func setMarketingReportRows(file *excelize.File, items []dto.RepportMarketingIte if err := file.SetCellValue(sheet, "P"+totalRow, formatMarketingRupiah(summary.TotalHppPricePerKg)); err != nil { return err } - if err := file.SetCellValue(sheet, "Q"+totalRow, formatMarketingRupiah(float64(summary.TotalHppAmount))); err != nil { - return err - } - if err := file.SetCellValue(sheet, "R"+totalRow, formatMarketingRupiah(float64(summary.TotalSalesAmount))); err != nil { + if err := file.SetCellValue(sheet, "Q"+totalRow, formatMarketingRupiah(float64(summary.TotalSalesAmount))); err != nil { return err } } - return nil + if len(items) > 0 { + lastDataRow := strconv.Itoa(len(items) + 1) + + dataStyle, err := file.NewStyle(&excelize.Style{ + Alignment: &excelize.Alignment{Horizontal: "left", Vertical: "center", WrapText: true}, + Border: []excelize.Border{ + {Type: "left", Color: "D1D5DB", Style: 1}, + {Type: "top", Color: "D1D5DB", Style: 1}, + {Type: "bottom", Color: "D1D5DB", Style: 1}, + {Type: "right", Color: "D1D5DB", Style: 1}, + }, + }) + if err != nil { + return err + } + if err := file.SetCellStyle(sheet, "A2", "Q"+lastDataRow, dataStyle); err != nil { + return err + } + + numericStyle, err := file.NewStyle(&excelize.Style{ + Alignment: &excelize.Alignment{Horizontal: "right", Vertical: "center"}, + Border: []excelize.Border{ + {Type: "left", Color: "D1D5DB", Style: 1}, + {Type: "top", Color: "D1D5DB", Style: 1}, + {Type: "bottom", Color: "D1D5DB", Style: 1}, + {Type: "right", Color: "D1D5DB", Style: 1}, + }, + }) + if err != nil { + return err + } + if err := file.SetCellStyle(sheet, "L2", "Q"+lastDataRow, numericStyle); err != nil { + return err + } + } + + totalTextStyle, err := file.NewStyle(&excelize.Style{ + Font: &excelize.Font{Bold: true, Color: "1F2937"}, + Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"F3F4F6"}}, + Alignment: &excelize.Alignment{Horizontal: "left", Vertical: "center"}, + Border: []excelize.Border{ + {Type: "left", Color: "D1D5DB", Style: 1}, + {Type: "top", Color: "D1D5DB", Style: 1}, + {Type: "bottom", Color: "D1D5DB", Style: 1}, + {Type: "right", Color: "D1D5DB", Style: 1}, + }, + }) + if err != nil { + return err + } + if err := file.SetCellStyle(sheet, "A"+totalRow, "Q"+totalRow, totalTextStyle); err != nil { + return err + } + + totalNumericStyle, err := file.NewStyle(&excelize.Style{ + Font: &excelize.Font{Bold: true, Color: "1F2937"}, + Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"F3F4F6"}}, + Alignment: &excelize.Alignment{Horizontal: "right", Vertical: "center"}, + Border: []excelize.Border{ + {Type: "left", Color: "D1D5DB", Style: 1}, + {Type: "top", Color: "D1D5DB", Style: 1}, + {Type: "bottom", Color: "D1D5DB", Style: 1}, + {Type: "right", Color: "D1D5DB", Style: 1}, + }, + }) + if err != nil { + return err + } + + return file.SetCellStyle(sheet, "L"+totalRow, "Q"+totalRow, totalNumericStyle) } func formatMarketingDate(t time.Time) string { diff --git a/internal/modules/repports/controllers/repport.marketing.pdf.go b/internal/modules/repports/controllers/repport.marketing.pdf.go index f5375a30..6e303891 100644 --- a/internal/modules/repports/controllers/repport.marketing.pdf.go +++ b/internal/modules/repports/controllers/repport.marketing.pdf.go @@ -56,22 +56,21 @@ type pdfColumn struct { var marketingPdfColumns = []pdfColumn{ {"No", 6, "C"}, {"Tanggal\nJual", 16, "C"}, - {"Tanggal\nRealisasi", 16, "C"}, + {"Tanggal\nRealisasi", 20, "C"}, {"Aging\n(Hari)", 9, "C"}, {"Gudang\nFisik", 20, "L"}, {"Pelanggan", 20, "L"}, {"No. DO", 18, "L"}, {"Sales", 18, "L"}, {"No. Polisi", 18, "L"}, - {"Tipe\nMarketing", 14, "C"}, + {"Tipe\nMarketing", 16, "C"}, {"Produk", 16, "L"}, {"Kuantitas", 13, "R"}, - {"Bobot Rata-Rata\n(Kg)", 13, "R"}, - {"Bobot Total Berat\n(Kg)", 14, "R"}, + {"Bobot\nRata-Rata (Kg)", 18, "R"}, + {"Bobot\nTotal Berat (Kg)", 18, "R"}, {"Harga Jual\n(Rp)", 17, "R"}, {"HPP\n(Rp)", 17, "R"}, - {"Total Jual\n(Rp)", 18, "R"}, - {"Total HPP\n(Rp)", 18, "R"}, + {"Total (Rp)", 18, "R"}, } // --------------------------------------------------------------------------- @@ -214,7 +213,7 @@ func calcMarketingRowHeight(pdf *fpdf.Fpdf, values []string, lineH float64) floa cols := marketingPdfColumns maxLines := 1 for i, col := range cols { - if i >= len(values) || i == 10 { + if i >= len(values) || i == 9 { continue } usableW := col.width - 2*margin @@ -238,7 +237,7 @@ func writeMarketingPdfRow(pdf *fpdf.Fpdf, item dto.RepportMarketingItemDTO, valu x := 10.0 for i, col := range cols { - if i == 10 { + if i == 9 { drawMarketingTypeBadge(pdf, x, y, col.width, rowH, item.MarketingType) pdf.SetDrawColor(borderR, borderG, borderB) pdf.SetTextColor(40, 40, 40) @@ -283,8 +282,8 @@ func marketingPdfRowValues(no int, item dto.RepportMarketingItemDTO) []string { customer, safeMarketingExportText(item.DoNumber), sales, - safeMarketingExportText(item.VehicleNumber), - safeMarketingExportText(item.MarketingType), // index 10, overridden by badge + safeMarketingExportText(formatMarketingVehicleNumber(item.VehicleNumber)), + safeMarketingExportText(item.MarketingType), // index 9, overridden by badge product, formatMarketingPdfNumber(item.Qty), formatMarketingPdfDecimal(item.AverageWeightKg), @@ -292,7 +291,6 @@ func marketingPdfRowValues(no int, item dto.RepportMarketingItemDTO) []string { formatMarketingPdfRupiah(item.SalesPricePerKg), formatMarketingPdfRupiah(item.HppPricePerKg), formatMarketingPdfRupiah(item.SalesAmount), - formatMarketingPdfRupiah(item.HppAmount), } } @@ -306,30 +304,9 @@ func writeMarketingPdfTotal(pdf *fpdf.Fpdf, items []dto.RepportMarketingItemDTO) return } - rowH := 6.5 - - if pdf.GetY()+rowH > marketingPdfPageHeight(pdf)-12 { - pdf.AddPage() - writeMarketingPdfHeader(pdf) - } - pdf.SetFont("Helvetica", "B", 6) - pdf.SetFillColor(220, 230, 245) - pdf.SetTextColor(30, 64, 120) - pdf.SetDrawColor(borderR, borderG, borderB) - pdf.SetLineWidth(0.1) - y := pdf.GetY() - x := 10.0 - - // merge first 11 cols (No … Tipe Marketing) into "TOTAL" label - mergedWidth := 0.0 - for i := 0; i < 11; i++ { - mergedWidth += marketingPdfColumns[i].width - } - pdf.SetXY(x, y) - pdf.CellFormat(mergedWidth, rowH, "TOTAL", "1", 0, "R", true, 0, "") - x += mergedWidth + lineH := 5.0 totals := []string{ formatMarketingPdfNumber(float64(summary.TotalQty)), @@ -338,13 +315,58 @@ func writeMarketingPdfTotal(pdf *fpdf.Fpdf, items []dto.RepportMarketingItemDTO) formatMarketingPdfRupiah(summary.AverageSalesPrice), formatMarketingPdfRupiah(summary.TotalHppPricePerKg), formatMarketingPdfRupiah(float64(summary.TotalSalesAmount)), - formatMarketingPdfRupiah(float64(summary.TotalHppAmount)), } + margin := pdf.GetCellMargin() + maxLines := 1 + for i, val := range totals { + col := marketingPdfColumns[11+i] + usableW := col.width - 2*margin + if usableW <= 0 { + continue + } + lines := pdf.SplitLines([]byte(val), usableW) + n := len(lines) + if n == 0 { + n = 1 + } + if n > maxLines { + maxLines = n + } + } + rowH := float64(maxLines) * lineH + + if pdf.GetY()+rowH > marketingPdfPageHeight(pdf)-12 { + pdf.AddPage() + writeMarketingPdfHeader(pdf) + pdf.SetFont("Helvetica", "B", 6) + } + + pdf.SetTextColor(30, 64, 120) + pdf.SetDrawColor(borderR, borderG, borderB) + pdf.SetLineWidth(0.1) + + y := pdf.GetY() + x := 10.0 + + const totalFillR, totalFillG, totalFillB = 220, 230, 245 + + mergedWidth := 0.0 + for i := range 11 { + mergedWidth += marketingPdfColumns[i].width + } + pdf.SetFillColor(totalFillR, totalFillG, totalFillB) + pdf.Rect(x, y, mergedWidth, rowH, "FD") + pdf.SetXY(x, y) + pdf.MultiCell(mergedWidth, lineH, "TOTAL", "", "R", false) + x += mergedWidth + for i, val := range totals { col := marketingPdfColumns[11+i] + pdf.SetFillColor(totalFillR, totalFillG, totalFillB) + pdf.Rect(x, y, col.width, rowH, "FD") pdf.SetXY(x, y) - pdf.CellFormat(col.width, rowH, val, "1", 0, "R", true, 0, "") + pdf.MultiCell(col.width, lineH, val, "", "R", false) x += col.width } @@ -510,6 +532,27 @@ func marketingPdfPageHeight(pdf *fpdf.Fpdf) float64 { return h } +// formatMarketingVehicleNumber spaces out Indonesian plate segments: D1234MBU → D 1234 MBU. +// Returns s unchanged if it doesn't match the [letters][digits][letters] pattern. +func formatMarketingVehicleNumber(s string) string { + s = strings.TrimSpace(s) + if s == "" { + return s + } + i := 0 + for i < len(s) && (s[i] >= 'A' && s[i] <= 'Z' || s[i] >= 'a' && s[i] <= 'z') { + i++ + } + j := i + for j < len(s) && s[j] >= '0' && s[j] <= '9' { + j++ + } + if i == 0 || j == i || j == len(s) { + return s + } + return s[:i] + " " + s[i:j] + " " + s[j:] +} + // formatMarketingPdfThousands inserts period every 3 digits. func formatMarketingPdfThousands(v int64) string { negative := v < 0 From 5511dc78dcebf3c497554e6c0bec1b194637446a Mon Sep 17 00:00:00 2001 From: giovanni Date: Thu, 7 May 2026 17:24:46 +0700 Subject: [PATCH 10/23] add migration for update day recording pullet cikaum 1 dan 2 --- ...alize_recording_day_pullet_cikaum.down.sql | 21 +++++++++++++++++ ...rmalize_recording_day_pullet_cikaum.up.sql | 23 +++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 internal/database/migrations/20260507101151_normalize_recording_day_pullet_cikaum.down.sql create mode 100644 internal/database/migrations/20260507101151_normalize_recording_day_pullet_cikaum.up.sql diff --git a/internal/database/migrations/20260507101151_normalize_recording_day_pullet_cikaum.down.sql b/internal/database/migrations/20260507101151_normalize_recording_day_pullet_cikaum.down.sql new file mode 100644 index 00000000..6cec944d --- /dev/null +++ b/internal/database/migrations/20260507101151_normalize_recording_day_pullet_cikaum.down.sql @@ -0,0 +1,21 @@ +-- Revert: hitung ulang recording.day menggunakan chick_in_date sebelum perubahan +-- PFK 70: old chick_in_date = 2026-03-23 +-- PFK 71: old chick_in_date = 2025-12-15 +-- Kembalikan constraint chk_recordings_day ke >= 1 + +UPDATE recordings r +SET day = GREATEST(1, (r.record_datetime::date - + CASE r.project_flock_kandangs_id + WHEN 70 THEN DATE '2026-03-23' + WHEN 71 THEN DATE '2025-12-15' + END)::int + 1), + updated_at = NOW() +WHERE r.project_flock_kandangs_id IN (70, 71) + AND r.deleted_at IS NULL; + +ALTER TABLE recordings +DROP CONSTRAINT IF EXISTS chk_recordings_day; + +ALTER TABLE recordings +ADD CONSTRAINT chk_recordings_day +CHECK (day IS NULL OR day >= 1); diff --git a/internal/database/migrations/20260507101151_normalize_recording_day_pullet_cikaum.up.sql b/internal/database/migrations/20260507101151_normalize_recording_day_pullet_cikaum.up.sql new file mode 100644 index 00000000..675b4239 --- /dev/null +++ b/internal/database/migrations/20260507101151_normalize_recording_day_pullet_cikaum.up.sql @@ -0,0 +1,23 @@ +-- Normalize recording.day untuk Pullet Cikaum 1 & 2 +-- Setelah migrasi 20260505083754_update_pullet_cikaum_chick_in_date mengubah chick_in_date: +-- PFK 70: 2026-03-23 → 2026-03-24 (shift +1 hari) +-- PFK 71: 2025-12-15 → 2026-04-06 (shift +112 hari) +-- Recording.day perlu dihitung ulang: day = record_datetime::date - chick_in_date::date +-- Edge case: PFK 70 punya 1 recording (2026-03-23) sebelum chick_in_date baru → di-clamp ke 0 +-- Note: constraint chk_recordings_day diubah ke >= 0 karena zero-indexed day + +ALTER TABLE recordings +DROP CONSTRAINT IF EXISTS chk_recordings_day; + +ALTER TABLE recordings +ADD CONSTRAINT chk_recordings_day +CHECK (day IS NULL OR day >= 0); + +UPDATE recordings r +SET day = GREATEST(0, (r.record_datetime::date - pc.chick_in_date::date)::int), + updated_at = NOW() +FROM project_chickins pc +WHERE pc.project_flock_kandang_id = r.project_flock_kandangs_id + AND pc.deleted_at IS NULL + AND r.deleted_at IS NULL + AND r.project_flock_kandangs_id IN (70, 71); From aa5d4ab8185bd8020c3c307fa8076130251ed9ab Mon Sep 17 00:00:00 2001 From: giovanni Date: Thu, 7 May 2026 21:09:10 +0700 Subject: [PATCH 11/23] adjust calculate for week at recording list --- internal/entities/recording.go | 1 + .../recordings/dto/recording.dto.go | 6 +- internal/utils/recording/recording_helpers.go | 88 ++++++++++++++++++- 3 files changed, 93 insertions(+), 2 deletions(-) diff --git a/internal/entities/recording.go b/internal/entities/recording.go index 19b757a4..f19161c4 100644 --- a/internal/entities/recording.go +++ b/internal/entities/recording.go @@ -43,6 +43,7 @@ type Recording struct { StandardEggMass *float64 `gorm:"-"` StandardEggWeight *float64 `gorm:"-"` StandardFcr *float64 `gorm:"-"` + StandardWeek *int `gorm:"-"` PopulationCanChange *bool `gorm:"-"` TransferExecuted *bool `gorm:"-"` IsTransition *bool `gorm:"-"` diff --git a/internal/modules/production/recordings/dto/recording.dto.go b/internal/modules/production/recordings/dto/recording.dto.go index a4e3ee47..6eb39544 100644 --- a/internal/modules/production/recordings/dto/recording.dto.go +++ b/internal/modules/production/recordings/dto/recording.dto.go @@ -314,9 +314,13 @@ func toRecordingProjectFlockDTO(e entity.Recording) RecordingProjectFlockDTO { result.Period = pfk.Period if pfk.ProjectFlock.ProductionStandard.Id != 0 { + week := recordingWeekValue(e) + if e.StandardWeek != nil && *e.StandardWeek > 0 { + week = *e.StandardWeek + } result.ProductionStandart = &RecordingProductionStandardDTO{ Id: pfk.ProjectFlock.ProductionStandard.Id, - Week: recordingWeekValue(e), + Week: week, Name: pfk.ProjectFlock.ProductionStandard.Name, HenDayStd: floatValue(e.StandardHenDay), HenHouseStd: floatValue(e.StandardHenHouse), diff --git a/internal/utils/recording/recording_helpers.go b/internal/utils/recording/recording_helpers.go index ff95a2f7..be96d12c 100644 --- a/internal/utils/recording/recording_helpers.go +++ b/internal/utils/recording/recording_helpers.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "strings" + "time" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" "gitlab.com/mbugroup/lti-api.git/internal/config" @@ -243,6 +244,31 @@ func AttachProductionStandards(ctx context.Context, db *gorm.DB, warnOnly bool, growthDetailByStd[standardID] = growthMap } + // Batch-load laying transfer targets → source PFK chick_in_dates + // untuk menentukan actual chicken week (bukan hardcode LayingWeekStart offset) + type transferChickIn struct { + TargetPFKID uint + ChickInDate time.Time + } + layingPFKIDs := collectLayingPFKIDs(items) + sourceChickInByTarget := make(map[uint]time.Time, len(layingPFKIDs)) + if len(layingPFKIDs) > 0 { + var results []transferChickIn + db.Raw(` + SELECT ltt.target_project_flock_kandang_id AS target_pfk_id, pc.chick_in_date + FROM laying_transfer_targets ltt + JOIN laying_transfer_sources lts ON lts.laying_transfer_id = ltt.laying_transfer_id + JOIN project_chickins pc ON pc.project_flock_kandang_id = lts.source_project_flock_kandang_id + WHERE ltt.target_project_flock_kandang_id IN ? + AND ltt.deleted_at IS NULL + AND lts.deleted_at IS NULL + AND pc.deleted_at IS NULL + `, layingPFKIDs).Scan(&results) + for _, r := range results { + sourceChickInByTarget[r.TargetPFKID] = r.ChickInDate + } + } + for _, item := range items { if item == nil || item.ProjectFlockKandang == nil || item.ProjectFlockKandang.ProjectFlock.Id == 0 { continue @@ -251,7 +277,8 @@ func AttachProductionStandards(ctx context.Context, db *gorm.DB, warnOnly bool, if standardID == 0 { continue } - week := RecordingWeekValue(*item) + week := computeTransferAwareWeek(item, sourceChickInByTarget) + item.StandardWeek = &week cacheKey := standardKey{standardID: standardID, week: week} if cached, ok := cache[cacheKey]; ok { applyProductionStandardValues(item, cached.values, cached.fcr) @@ -291,6 +318,65 @@ func applyProductionStandardValues(item *entity.Recording, values productionStan item.StandardFcr = fcr } +// collectLayingPFKIDs mengumpulkan semua project_flock_kandang_id dari recording laying +func collectLayingPFKIDs(items []*entity.Recording) []uint { + seen := make(map[uint]struct{}) + var ids []uint + for _, item := range items { + if item == nil || item.ProjectFlockKandang == nil { + continue + } + if strings.EqualFold(item.ProjectFlockKandang.ProjectFlock.Category, string(utils.ProjectFlockCategoryLaying)) { + id := item.ProjectFlockKandang.Id + if _, ok := seen[id]; !ok { + seen[id] = struct{}{} + ids = append(ids, id) + } + } + } + return ids +} + +// computeTransferAwareWeek menghitung production standard week untuk recording. +// Laying dengan transfer: actual chicken age dari source PFK chick_in_date. +// Laying cut-over (tanpa transfer): langsung dari recording.day (tanpa offset LayingWeekStart). +// Non-laying: ((day-1)/7) + 1. +func computeTransferAwareWeek(item *entity.Recording, sourceChickInByTarget map[uint]time.Time) int { + day := intValue(item.Day) + if item == nil || item.ProjectFlockKandang == nil { + if day > 0 { + return ((day - 1) / 7) + 1 + } + return 0 + } + + isLaying := strings.EqualFold(item.ProjectFlockKandang.ProjectFlock.Category, string(utils.ProjectFlockCategoryLaying)) + if !isLaying { + if day > 0 { + return ((day - 1) / 7) + 1 + } + return 0 + } + + // Laying recording — cek apakah PFK ini adalah target dari laying transfer + if sourceChickIn, ok := sourceChickInByTarget[item.ProjectFlockKandang.Id]; ok && !sourceChickIn.IsZero() { + // Ada laying transfer: hitung umur aktual dari source PFK chick_in_date + rDate := time.Date(item.RecordDatetime.Year(), item.RecordDatetime.Month(), item.RecordDatetime.Day(), 0, 0, 0, 0, item.RecordDatetime.Location()) + sDate := time.Date(sourceChickIn.Year(), sourceChickIn.Month(), sourceChickIn.Day(), 0, 0, 0, 0, sourceChickIn.Location()) + actualDay := int(rDate.Sub(sDate).Hours() / 24) + if actualDay > 0 { + return ((actualDay - 1) / 7) + 1 + } + return 0 + } + + // Cut-over laying (tanpa transfer): chick_in_date di PFK sudah umur asli DOC + if day > 0 { + return ((day - 1) / 7) + 1 + } + return 0 +} + func RecordingWeekValue(e entity.Recording) int { day := intValue(e.Day) if day <= 0 { From 126294d288a84b691217eef2085e82d55e0fb12a Mon Sep 17 00:00:00 2001 From: giovanni Date: Thu, 7 May 2026 22:39:36 +0700 Subject: [PATCH 12/23] add api get list stock log by product warehouse id --- internal/middleware/permissions.go | 1 + internal/modules/inventory/route.go | 2 + .../controllers/stock-log.controller.go | 66 +++++++++ .../controllers/stock-log.export.go | 118 +++++++++++++++++ .../inventory/stock-logs/dto/stock-log.dto.go | 61 +++++++++ .../modules/inventory/stock-logs/module.go | 24 ++++ .../modules/inventory/stock-logs/route.go | 19 +++ .../stock-logs/services/stock-log.service.go | 125 ++++++++++++++++++ .../validations/stock-log.validation.go | 7 + 9 files changed, 423 insertions(+) create mode 100644 internal/modules/inventory/stock-logs/controllers/stock-log.controller.go create mode 100644 internal/modules/inventory/stock-logs/controllers/stock-log.export.go create mode 100644 internal/modules/inventory/stock-logs/dto/stock-log.dto.go create mode 100644 internal/modules/inventory/stock-logs/module.go create mode 100644 internal/modules/inventory/stock-logs/route.go create mode 100644 internal/modules/inventory/stock-logs/services/stock-log.service.go create mode 100644 internal/modules/inventory/stock-logs/validations/stock-log.validation.go diff --git a/internal/middleware/permissions.go b/internal/middleware/permissions.go index a29ccd91..1e6165c8 100644 --- a/internal/middleware/permissions.go +++ b/internal/middleware/permissions.go @@ -66,6 +66,7 @@ const ( P_ProductStockGetOne = "lti.inventory.product_stock.detail" P_ProductWarehousekGetAll = "lti.inventory.product_warehouses.list" P_ProductWarehouseGetOne = "lti.inventory.product_warehouses.detail" + P_StockLogGetAll = "lti.inventory.stock_log.list" ) const ( P_ClosingGetAll = "lti.closing.list" diff --git a/internal/modules/inventory/route.go b/internal/modules/inventory/route.go index 0d4d2f4b..bdff88f0 100644 --- a/internal/modules/inventory/route.go +++ b/internal/modules/inventory/route.go @@ -10,6 +10,7 @@ import ( adjustments "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/adjustments" productStocks "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-stocks" productWarehouses "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses" + stockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/stock-logs" transfers "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers" // MODULE IMPORTS ) @@ -23,6 +24,7 @@ func RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Valida adjustments.AdjustmentModule{}, transfers.TransferModule{}, productStocks.ProductStockModule{}, + stockLogs.StockLogModule{}, // MODULE REGISTRY } diff --git a/internal/modules/inventory/stock-logs/controllers/stock-log.controller.go b/internal/modules/inventory/stock-logs/controllers/stock-log.controller.go new file mode 100644 index 00000000..762a98fd --- /dev/null +++ b/internal/modules/inventory/stock-logs/controllers/stock-log.controller.go @@ -0,0 +1,66 @@ +package controller + +import ( + "math" + "strings" + + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/stock-logs/validations" + stockLogDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/stock-logs/dto" + stockLogService "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/stock-logs/services" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type StockLogController struct { + StockLogService stockLogService.StockLogService +} + +func NewStockLogController(s stockLogService.StockLogService) *StockLogController { + return &StockLogController{ + StockLogService: s, + } +} + +func (u *StockLogController) GetAll(c *fiber.Ctx) error { + query := &validation.Query{ + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + ProductWarehouseID: uint(c.QueryInt("product_warehouse_id", 0)), + } + + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + + // Export to Excel + if strings.EqualFold(strings.TrimSpace(c.Query("export")), "excel") { + if query.ProductWarehouseID == 0 { + return fiber.NewError(fiber.StatusBadRequest, "product_warehouse_id is required for export") + } + results, err := u.StockLogService.GetAllForExport(c, query.ProductWarehouseID) + if err != nil { + return err + } + return exportStockLogListExcel(c, results) + } + + result, totalResults, err := u.StockLogService.GetAll(c, query) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[stockLogDTO.StockLogListDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get all stock logs successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: stockLogDTO.ToStockLogListDTOs(result), + }) +} diff --git a/internal/modules/inventory/stock-logs/controllers/stock-log.export.go b/internal/modules/inventory/stock-logs/controllers/stock-log.export.go new file mode 100644 index 00000000..652ef477 --- /dev/null +++ b/internal/modules/inventory/stock-logs/controllers/stock-log.export.go @@ -0,0 +1,118 @@ +package controller + +import ( + "fmt" + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + + "github.com/gofiber/fiber/v2" + "github.com/xuri/excelize/v2" +) + +func exportStockLogListExcel(c *fiber.Ctx, stockLogs []entity.StockLog) error { + file := excelize.NewFile() + defer file.Close() + + sheet := "Stock Logs" + file.SetSheetName("Sheet1", sheet) + + headers := []string{ + "ID", + "Tanggal", + "Gudang", + "Stok Akhir", + "Peningkatan", + "Penurunan", + "Jenis Transaksi", + "Catatan", + "Oleh", + } + + // Column widths + colWidths := map[string]float64{ + "A": 8, + "B": 20, + "C": 25, + "D": 14, + "E": 14, + "F": 14, + "G": 20, + "H": 30, + "I": 20, + } + for col, width := range colWidths { + file.SetColWidth(sheet, col, col, width) + } + + // Header style + headerStyle, _ := file.NewStyle(&excelize.Style{ + Font: &excelize.Font{ + Bold: true, + Size: 11, + }, + Fill: excelize.Fill{ + Type: "pattern", + Pattern: 1, + Color: []string{"D9E1F2"}, + }, + Border: []excelize.Border{ + {Type: "bottom", Style: 1, Color: "000000"}, + }, + }) + + // Write header row + for i, h := range headers { + cell, _ := excelize.CoordinatesToCellName(i+1, 1) + file.SetCellValue(sheet, cell, h) + file.SetCellStyle(sheet, cell, cell, headerStyle) + } + + // Freeze header row + file.SetPanes(sheet, &excelize.Panes{ + Freeze: true, + YSplit: 1, + TopLeftCell: "A2", + ActivePane: "bottomLeft", + }) + + // Write data rows + for i, log := range stockLogs { + row := i + 2 + + warehouseName := "" + if log.ProductWarehouse != nil { + warehouseName = log.ProductWarehouse.Warehouse.Name + } + + userName := "" + if log.CreatedUser != nil { + userName = log.CreatedUser.Name + } + + notes := "" + if log.Notes != "" { + notes = log.Notes + } + + file.SetCellInt(sheet, fmt.Sprintf("A%d", row), int(log.Id)) + file.SetCellValue(sheet, fmt.Sprintf("B%d", row), log.CreatedAt.Format("2006-01-02 15:04:05")) + file.SetCellValue(sheet, fmt.Sprintf("C%d", row), warehouseName) + file.SetCellFloat(sheet, fmt.Sprintf("D%d", row), log.Stock, 3, 64) + file.SetCellFloat(sheet, fmt.Sprintf("E%d", row), log.Increase, 3, 64) + file.SetCellFloat(sheet, fmt.Sprintf("F%d", row), log.Decrease, 3, 64) + file.SetCellValue(sheet, fmt.Sprintf("G%d", row), log.LoggableType) + file.SetCellValue(sheet, fmt.Sprintf("H%d", row), notes) + file.SetCellValue(sheet, fmt.Sprintf("I%d", row), userName) + } + + buffer, err := file.WriteToBuffer() + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "failed to generate excel file") + } + + filename := fmt.Sprintf("stock_logs_%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()) +} diff --git a/internal/modules/inventory/stock-logs/dto/stock-log.dto.go b/internal/modules/inventory/stock-logs/dto/stock-log.dto.go new file mode 100644 index 00000000..732df6fe --- /dev/null +++ b/internal/modules/inventory/stock-logs/dto/stock-log.dto.go @@ -0,0 +1,61 @@ +package dto + +import ( + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" +) + +type StockLogListDTO struct { + Id uint `json:"id"` + ProductWarehouseId uint `json:"product_warehouse_id"` + Increase float64 `json:"increase"` + Decrease float64 `json:"decrease"` + Stock float64 `json:"stock"` + LoggableType string `json:"loggable_type"` + LoggableId uint `json:"loggable_id"` + Notes *string `json:"notes"` + CreatedBy uint `json:"created_by"` + CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +func ToStockLogListDTO(e entity.StockLog) StockLogListDTO { + var notes *string + if e.Notes != "" { + n := e.Notes + notes = &n + } + + var createdUser *userDTO.UserRelationDTO + if e.CreatedUser != nil && e.CreatedUser.Id != 0 { + mapped := userDTO.ToUserRelationDTO(*e.CreatedUser) + createdUser = &mapped + } + + return StockLogListDTO{ + Id: e.Id, + ProductWarehouseId: e.ProductWarehouseId, + Increase: e.Increase, + Decrease: e.Decrease, + Stock: e.Stock, + LoggableType: e.LoggableType, + LoggableId: e.LoggableId, + Notes: notes, + CreatedBy: e.CreatedBy, + CreatedUser: createdUser, + CreatedAt: e.CreatedAt, + } +} + +func ToStockLogListDTOs(e []entity.StockLog) []StockLogListDTO { + if len(e) == 0 { + return []StockLogListDTO{} + } + result := make([]StockLogListDTO, len(e)) + for i, log := range e { + result[i] = ToStockLogListDTO(log) + } + return result +} diff --git a/internal/modules/inventory/stock-logs/module.go b/internal/modules/inventory/stock-logs/module.go new file mode 100644 index 00000000..2fc6815a --- /dev/null +++ b/internal/modules/inventory/stock-logs/module.go @@ -0,0 +1,24 @@ +package stockLogs + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" + + stockLogService "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/stock-logs/services" + stockLogRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" +) + +type StockLogModule struct{} + +func (StockLogModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + userRepo := rUser.NewUserRepository(db) + userService := sUser.NewUserService(userRepo, validate) + + stockLogRepo := stockLogRepo.NewStockLogRepository(db) + service := stockLogService.NewStockLogService(stockLogRepo, validate) + + StockLogRoutes(router, userService, service) +} diff --git a/internal/modules/inventory/stock-logs/route.go b/internal/modules/inventory/stock-logs/route.go new file mode 100644 index 00000000..a7387013 --- /dev/null +++ b/internal/modules/inventory/stock-logs/route.go @@ -0,0 +1,19 @@ +package stockLogs + +import ( + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/stock-logs/controllers" + stockLog "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/stock-logs/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + + "github.com/gofiber/fiber/v2" +) + +func StockLogRoutes(v1 fiber.Router, u user.UserService, s stockLog.StockLogService) { + ctrl := controller.NewStockLogController(s) + + route := v1.Group("/stock-logs") + route.Use(m.Auth(u)) + + route.Get("/", m.RequirePermissions(m.P_StockLogGetAll), ctrl.GetAll) +} diff --git a/internal/modules/inventory/stock-logs/services/stock-log.service.go b/internal/modules/inventory/stock-logs/services/stock-log.service.go new file mode 100644 index 00000000..77639fbd --- /dev/null +++ b/internal/modules/inventory/stock-logs/services/stock-log.service.go @@ -0,0 +1,125 @@ +package service + +import ( + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/stock-logs/validations" + stockLogRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type StockLogService interface { + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.StockLog, int64, error) + GetAllForExport(ctx *fiber.Ctx, productWarehouseID uint) ([]entity.StockLog, error) +} + +type stockLogService struct { + Log *logrus.Logger + Validate *validator.Validate + StockLogRepo stockLogRepo.StockLogRepository +} + +func NewStockLogService( + stockLogRepo stockLogRepo.StockLogRepository, + validate *validator.Validate, +) StockLogService { + return &stockLogService{ + Log: utils.Log, + Validate: validate, + StockLogRepo: stockLogRepo, + } +} + +func (s *stockLogService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.StockLog, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + locationScope, areaScope, err := m.ResolveLocationAreaScopes(c, s.StockLogRepo.DB()) + if err != nil { + return nil, 0, err + } + + offset := (params.Page - 1) * params.Limit + + stockLogs, total, err := s.StockLogRepo.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { + db = db.Where("product_warehouse_id = ?", params.ProductWarehouseID) + + if locationScope.Restrict || areaScope.Restrict { + if (locationScope.Restrict && len(locationScope.IDs) == 0) || (areaScope.Restrict && len(areaScope.IDs) == 0) { + return db.Where("1 = 0") + } + db = db. + Joins("JOIN product_warehouses pw ON pw.id = stock_logs.product_warehouse_id"). + Joins("JOIN warehouses w ON w.id = pw.warehouse_id") + if locationScope.Restrict { + db = db.Where("w.location_id IN ?", locationScope.IDs) + } + if areaScope.Restrict { + db = db.Where("w.area_id IN ?", areaScope.IDs) + } + } + + db = db. + Preload("CreatedUser"). + Order("stock_logs.created_at DESC") + + return db + }) + if err != nil { + s.Log.Errorf("Failed to get stock logs: %+v", err) + return nil, 0, err + } + + if total == 0 { + return []entity.StockLog{}, 0, nil + } + + return stockLogs, total, nil +} + +func (s *stockLogService) GetAllForExport(c *fiber.Ctx, productWarehouseID uint) ([]entity.StockLog, error) { + locationScope, areaScope, err := m.ResolveLocationAreaScopes(c, s.StockLogRepo.DB()) + if err != nil { + return nil, err + } + + stockLogs, _, err := s.StockLogRepo.GetAll(c.Context(), 0, -1, func(db *gorm.DB) *gorm.DB { + db = db.Where("product_warehouse_id = ?", productWarehouseID) + + if locationScope.Restrict || areaScope.Restrict { + if (locationScope.Restrict && len(locationScope.IDs) == 0) || (areaScope.Restrict && len(areaScope.IDs) == 0) { + return db.Where("1 = 0") + } + db = db. + Joins("JOIN product_warehouses pw ON pw.id = stock_logs.product_warehouse_id"). + Joins("JOIN warehouses w ON w.id = pw.warehouse_id") + if locationScope.Restrict { + db = db.Where("w.location_id IN ?", locationScope.IDs) + } + if areaScope.Restrict { + db = db.Where("w.area_id IN ?", areaScope.IDs) + } + } + + db = db. + Preload("CreatedUser"). + Preload("ProductWarehouse"). + Preload("ProductWarehouse.Warehouse"). + Order("stock_logs.created_at ASC") + + return db + }) + if err != nil { + s.Log.Errorf("Failed to get stock logs for export: %+v", err) + return nil, err + } + + return stockLogs, nil +} + diff --git a/internal/modules/inventory/stock-logs/validations/stock-log.validation.go b/internal/modules/inventory/stock-logs/validations/stock-log.validation.go new file mode 100644 index 00000000..a8af6d25 --- /dev/null +++ b/internal/modules/inventory/stock-logs/validations/stock-log.validation.go @@ -0,0 +1,7 @@ +package validation + +type Query struct { + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,gt=0"` + ProductWarehouseID uint `query:"product_warehouse_id" validate:"required,gt=0"` +} From ecac9275837b55ef91872ce0a05346c30d1645a8 Mon Sep 17 00:00:00 2001 From: giovanni Date: Fri, 8 May 2026 11:26:26 +0700 Subject: [PATCH 13/23] adjust export recording jumlah sapronak --- .../controllers/recording.export.go | 201 +++++++++++------- 1 file changed, 127 insertions(+), 74 deletions(-) diff --git a/internal/modules/production/recordings/controllers/recording.export.go b/internal/modules/production/recordings/controllers/recording.export.go index 55fd2f61..fc514fbd 100644 --- a/internal/modules/production/recordings/controllers/recording.export.go +++ b/internal/modules/production/recordings/controllers/recording.export.go @@ -79,8 +79,6 @@ func setRecordingExportColumns(file *excelize.File, sheet string) error { "AB": 18, "AC": 24, "AD": 18, - "AE": 18, - "AF": 18, } for col, width := range columnWidths { @@ -100,7 +98,7 @@ func setRecordingExportColumns(file *excelize.File, sheet string) error { } func setRecordingExportHeaders(file *excelize.File, sheet string) error { - verticalHeaderCols := []string{"A", "B", "C", "D", "E", "F", "G", "H", "I", "Y", "Z", "AA", "AB", "AC", "AD", "AE", "AF"} + verticalHeaderCols := []string{"A", "B", "C", "D", "E", "F", "G", "H", "I", "Y", "Z", "AA", "AB", "AC", "AD"} for _, col := range verticalHeaderCols { if err := file.MergeCell(sheet, col+"1", col+"2"); err != nil { return err @@ -121,10 +119,8 @@ func setRecordingExportHeaders(file *excelize.File, sheet string) error { "Z1": "Catatan Approval", "AA1": "Dibuat Oleh", "AB1": "Tanggal Submit", - "AC1": "Nama Pakan", - "AD1": "Jumlah Input Pakan", - "AE1": "Jumlah Penggunaan", - "AF1": "Pending Qty", + "AC1": "Nama Sapronak", + "AD1": "Jumlah Input Sapronak", } for cell, value := range headerValues { if err := file.SetCellValue(sheet, cell, value); err != nil { @@ -238,7 +234,7 @@ func setRecordingExportHeaders(file *excelize.File, sheet string) error { return err } - return file.SetCellStyle(sheet, "A1", "AF2", headerStyle) + return file.SetCellStyle(sheet, "A1", "AD2", headerStyle) } func setRecordingExportRows(file *excelize.File, sheet string, items []dto.RecordingListDTO) error { @@ -249,12 +245,14 @@ func setRecordingExportRows(file *excelize.File, sheet string, items []dto.Recor 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", - "AC", "AD", "AE", "AF", + "AC", "AD", } - for i, item := range items { - rowNumber := i + 3 + currentRow := 3 + type rowRange struct{ start, end int } + itemRanges := make([]rowRange, 0, len(items)) + for i, item := range items { fcrStd := 0.0 if item.ProjectFlock.Fcr != nil { fcrStd = item.ProjectFlock.Fcr.FcrStd @@ -292,73 +290,79 @@ func setRecordingExportRows(file *excelize.File, sheet string, items []dto.Recor createdBy = safeExportText(item.Approval.ActionBy.Name) } - // Build feed usage columns — concatenate multiple feeds with newline - feedNames := make([]string, 0, len(item.FeedUsage)) - usageAmounts := make([]string, 0, len(item.FeedUsage)) - pendingQtys := make([]string, 0, len(item.FeedUsage)) - inputQtys := make([]string, 0, len(item.FeedUsage)) - for _, fu := range item.FeedUsage { - feedNames = append(feedNames, safeExportText(fu.ProductName)) - usageAmounts = append(usageAmounts, formatNumberID(fu.UsageAmount, 2, true)) - pendingQtys = append(pendingQtys, formatNumberID(fu.PendingQty, 2, true)) - inputQtys = append(inputQtys, formatNumberID(fu.UsageAmount+fu.PendingQty, 2, true)) + // Expand recordings into one row per sapronak + type sapronakRow struct { + name string + input string } - - feedNameCol := "-" - usageCol := "-" - pendingCol := "-" - inputCol := "-" - if len(feedNames) > 0 { - feedNameCol = strings.Join(feedNames, "\n") - usageCol = strings.Join(usageAmounts, "\n") - pendingCol = strings.Join(pendingQtys, "\n") - inputCol = strings.Join(inputQtys, "\n") - } - - 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), - feedNameCol, // AC - inputCol, // AD - Jumlah Input Pakan - usageCol, // AE - Jumlah Penggunaan - pendingCol, // AF - Pending Qty - } - - for idx, col := range columns { - cell := fmt.Sprintf("%s%d", col, rowNumber) - if err := file.SetCellValue(sheet, cell, rowValues[idx]); err != nil { - return err + sapronaks := make([]sapronakRow, 0) + if len(item.FeedUsage) > 0 { + for _, fu := range item.FeedUsage { + sapronaks = append(sapronaks, sapronakRow{ + name: safeExportText(fu.ProductName), + input: formatNumberID(fu.UsageAmount+fu.PendingQty, 2, true), + }) } + } else { + sapronaks = append(sapronaks, sapronakRow{name: "-", input: "-"}) } + + groupStart := currentRow + + for sIdx, s := range sapronaks { + if sIdx == 0 { + rowValues := []interface{}{ + i + 1, // A + locationName, // B + safeExportText(item.ProjectFlock.FlockName), // C + kandangName, // D + item.ProjectFlock.Period, // E + formatCategoryLabel(item.ProjectFlock.ProjectFlockCategory), // F + formatAgeLabel(item), // G + formatDateIndonesian(item.RecordDatetime), // H + formatNumberID(item.ProjectFlock.TotalChickQty, 0, false), // I + formatNumberID(item.FcrValue, 2, true), // J + formatNumberID(fcrStd, 2, true), // K + formatNumberID(item.FeedIntake, 2, true), // L + formatNumberID(feedIntakeStd, 2, true), // M + formatPercentID(item.CumDepletionRate, 2), // N + formatPercentID(maxDepletionStd, 2), // O + formatNumberID(item.TotalDepletionQty, 2, true), // P + formatNumberID(item.EggMass, 2, true), // Q + formatNumberID(eggMassStd, 2, true), // R + formatNumberID(item.EggWeight, 2, true), // S + formatNumberID(eggWeightStd, 2, true), // T + formatPercentID(item.HenDay, 2), // U + formatPercentID(henDayStd, 2), // V + formatPercentID(item.HenHouse, 2), // W + formatPercentID(henHouseStd, 2), // X + formatApprovalStatus(item), // Y + safeExportText(pointerString(item.Approval.Notes)), // Z + createdBy, // AA + formatDateIndonesian(item.CreatedAt), // AB + s.name, // AC + s.input, // AD + } + + for idx, col := range columns { + cell := fmt.Sprintf("%s%d", col, currentRow) + if err := file.SetCellValue(sheet, cell, rowValues[idx]); err != nil { + return err + } + } + } else { + file.SetCellValue(sheet, fmt.Sprintf("AC%d", currentRow), s.name) + file.SetCellValue(sheet, fmt.Sprintf("AD%d", currentRow), s.input) + } + + currentRow++ + } + + itemRanges = append(itemRanges, rowRange{groupStart, currentRow - 1}) } - lastRow := len(items) + 2 + lastRow := currentRow - 1 + dataCenterStyle, err := file.NewStyle(&excelize.Style{ Alignment: &excelize.Alignment{ Horizontal: "center", @@ -375,7 +379,7 @@ func setRecordingExportRows(file *excelize.File, sheet string, items []dto.Recor if err != nil { return err } - if err := file.SetCellStyle(sheet, "A3", fmt.Sprintf("AF%d", lastRow), dataCenterStyle); err != nil { + if err := file.SetCellStyle(sheet, "A3", fmt.Sprintf("AD%d", lastRow), dataCenterStyle); err != nil { return err } @@ -403,6 +407,55 @@ func setRecordingExportRows(file *excelize.File, sheet string, items []dto.Recor } } + // Apply bottom border on the last sapronak row of each recording group + // Separate styles to preserve alignment (AC=left, AD=center) and thin borders + borderBottomLeftStyle, 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: "999999", Style: 2}, + {Type: "right", Color: "E6E6E6", Style: 1}, + }, + }) + if err != nil { + return err + } + + borderBottomCenterStyle, 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: "999999", Style: 2}, + {Type: "right", Color: "E6E6E6", Style: 1}, + }, + }) + if err != nil { + return err + } + + mergeCols := []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 _, rng := range itemRanges { + if rng.end > rng.start { + for _, col := range mergeCols { + file.MergeCell(sheet, fmt.Sprintf("%s%d", col, rng.start), fmt.Sprintf("%s%d", col, rng.end)) + } + } + file.SetCellStyle(sheet, fmt.Sprintf("AC%d", rng.end), fmt.Sprintf("AC%d", rng.end), borderBottomLeftStyle) + file.SetCellStyle(sheet, fmt.Sprintf("AD%d", rng.end), fmt.Sprintf("AD%d", rng.end), borderBottomCenterStyle) + } + return nil } From c328b9a880b0ed7db213765cd5e10b01753e05cd Mon Sep 17 00:00:00 2001 From: giovanni Date: Fri, 8 May 2026 13:55:48 +0700 Subject: [PATCH 14/23] fix calculate day recording if has laying transfer --- .../recordings/services/recording.service.go | 74 ++++++++++++++++--- 1 file changed, 63 insertions(+), 11 deletions(-) diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index 3f0e7946..7ca84d95 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -2434,21 +2434,24 @@ func (s *recordingService) computeRecordingDay(ctx context.Context, projectFlock return 0, fiber.NewError(fiber.StatusBadRequest, "Project flock kandang tidak valid") } - populations, err := s.ProjectFlockPopulationRepo.GetByProjectFlockKandangID(ctx, projectFlockKandangID) - if err != nil { - s.Log.Errorf("Failed to fetch populations for project_flock_kandang_id=%d: %+v", projectFlockKandangID, err) - return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data populasi") - } + // If this PFK is a laying transfer target, use source growing PFK's chick_in_date + sourcePFKIDs := s.getLayingTransferSourcePFKIDs(ctx, projectFlockKandangID) var chickinDate time.Time - for _, pop := range populations { - if pop.ProjectChickin == nil || pop.ProjectChickin.ChickInDate.IsZero() { - continue - } - if chickinDate.IsZero() || pop.ProjectChickin.ChickInDate.Before(chickinDate) { - chickinDate = pop.ProjectChickin.ChickInDate + if len(sourcePFKIDs) > 0 { + for _, pfkID := range sourcePFKIDs { + cd := s.getEarliestChickInDate(ctx, pfkID) + if !cd.IsZero() && (chickinDate.IsZero() || cd.Before(chickinDate)) { + chickinDate = cd + } } } + + // Fallback: use current PFK's own chick_in_date (cut-over or non-laying) + if chickinDate.IsZero() { + chickinDate = s.getEarliestChickInDate(ctx, projectFlockKandangID) + } + if chickinDate.IsZero() { return 0, fiber.NewError(fiber.StatusBadRequest, "Tanggal chick in tidak ditemukan") } @@ -2463,6 +2466,55 @@ func (s *recordingService) computeRecordingDay(ctx context.Context, projectFlock return diff, nil } +func (s *recordingService) getLayingTransferSourcePFKIDs(ctx context.Context, targetPFKID uint) []uint { + transfer, err := s.TransferLayingRepo.GetLatestApprovedByTargetKandang(ctx, targetPFKID) + if err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Failed to check laying transfer for pfk=%d: %+v", targetPFKID, err) + } + return nil + } + if transfer == nil { + return nil + } + + ids := make([]uint, 0) + if transfer.SourceProjectFlockKandangId != nil { + ids = append(ids, *transfer.SourceProjectFlockKandangId) + } + + // Check multi-source transfers + var sources []entity.LayingTransferSource + if err := s.Repository.DB().WithContext(ctx). + Where("laying_transfer_id = ?", transfer.Id). + Find(&sources).Error; err == nil { + for _, src := range sources { + ids = append(ids, src.SourceProjectFlockKandangId) + } + } + + return ids +} + +func (s *recordingService) getEarliestChickInDate(ctx context.Context, pfkID uint) time.Time { + populations, err := s.ProjectFlockPopulationRepo.GetByProjectFlockKandangID(ctx, pfkID) + if err != nil { + s.Log.Errorf("Failed to fetch populations for pfk=%d: %+v", pfkID, err) + return time.Time{} + } + + var chickinDate time.Time + for _, pop := range populations { + if pop.ProjectChickin == nil || pop.ProjectChickin.ChickInDate.IsZero() { + continue + } + if chickinDate.IsZero() || pop.ProjectChickin.ChickInDate.Before(chickinDate) { + chickinDate = pop.ProjectChickin.ChickInDate + } + } + return chickinDate +} + func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm.DB, recording *entity.Recording) error { day := 0 if recording.Day != nil { From 83aa23f67780c60d64d2e9d4c238bea8bb2c220c Mon Sep 17 00:00:00 2001 From: giovanni Date: Fri, 8 May 2026 15:04:54 +0700 Subject: [PATCH 15/23] add response excess day and week --- .../modules/production/recordings/dto/recording.dto.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/internal/modules/production/recordings/dto/recording.dto.go b/internal/modules/production/recordings/dto/recording.dto.go index 6eb39544..b24bfd68 100644 --- a/internal/modules/production/recordings/dto/recording.dto.go +++ b/internal/modules/production/recordings/dto/recording.dto.go @@ -74,6 +74,8 @@ type RecordingRelationDTO struct { ProjectFlock RecordingProjectFlockDTO `json:"project_flock"` RecordDatetime time.Time `json:"record_datetime"` Day int `json:"day"` + Week int `json:"week"` + ExcessDays int `json:"excess_days"` TotalDepletionQty float64 `json:"total_depletion_qty"` TotalDepletionCumQty float64 `json:"total_depletion_cum_qty"` CumDepletionRate float64 `json:"cum_depletion_rate"` @@ -270,11 +272,15 @@ func toRecordingRelationDTO(e entity.Recording) RecordingRelationDTO { latestApproval = snapshot } + day := intValue(e.Day) + return RecordingRelationDTO{ Id: e.Id, ProjectFlock: toRecordingProjectFlockDTO(e), RecordDatetime: e.RecordDatetime, - Day: intValue(e.Day), + Day: day, + Week: day / 7, + ExcessDays: day % 7, TotalDepletionQty: floatValue(e.TotalDepletionQty), TotalDepletionCumQty: floatValue(e.TotalDepletionCumQty), CumDepletionRate: roundFloatValue(e.CumDepletionRate, 2), From 6474dd57b3a685d404396bd86323297c86045c8d Mon Sep 17 00:00:00 2001 From: giovanni Date: Fri, 8 May 2026 16:54:44 +0700 Subject: [PATCH 16/23] add migration for change harga doc --- .../20260508091531_fix_adjustment_stock_531_price.down.sql | 5 +++++ .../20260508091531_fix_adjustment_stock_531_price.up.sql | 7 +++++++ 2 files changed, 12 insertions(+) create mode 100644 internal/database/migrations/20260508091531_fix_adjustment_stock_531_price.down.sql create mode 100644 internal/database/migrations/20260508091531_fix_adjustment_stock_531_price.up.sql diff --git a/internal/database/migrations/20260508091531_fix_adjustment_stock_531_price.down.sql b/internal/database/migrations/20260508091531_fix_adjustment_stock_531_price.down.sql new file mode 100644 index 00000000..7e4fcd54 --- /dev/null +++ b/internal/database/migrations/20260508091531_fix_adjustment_stock_531_price.down.sql @@ -0,0 +1,5 @@ +-- Rollback price adjustment_stock id=531 +UPDATE adjustment_stocks +SET price = 9535, + grand_total = ROUND(9000 * 9535, 3) +WHERE id = 531 AND adj_number = 'ADJ-00506'; diff --git a/internal/database/migrations/20260508091531_fix_adjustment_stock_531_price.up.sql b/internal/database/migrations/20260508091531_fix_adjustment_stock_531_price.up.sql new file mode 100644 index 00000000..2f650341 --- /dev/null +++ b/internal/database/migrations/20260508091531_fix_adjustment_stock_531_price.up.sql @@ -0,0 +1,7 @@ +-- Fix price adjustment_stock id=531 (ADJ-00506) +-- Old: price=9535, grand_total=85,815,000 +-- New: price=12635, grand_total=113,715,000 +UPDATE adjustment_stocks +SET price = 12635, + grand_total = ROUND(9000 * 12635, 3) +WHERE id = 531 AND adj_number = 'ADJ-00506'; From 748375b269ce19e8f83300790e61379099194352 Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Mon, 11 May 2026 10:04:22 +0700 Subject: [PATCH 17/23] feat: add chickin list permission --- internal/apikeys/defaults.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/apikeys/defaults.go b/internal/apikeys/defaults.go index 33662187..8004a6b2 100644 --- a/internal/apikeys/defaults.go +++ b/internal/apikeys/defaults.go @@ -89,5 +89,6 @@ func DefaultDashboardPermissions() []string { "lti.users.detail", "lti.users.list", "lti.daily_checklist.master_data.kandang", + "lti.production.chickins.list", } } From e0b9192e918ef4804e1465ef70640585cf150c6c Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Mon, 11 May 2026 10:04:57 +0700 Subject: [PATCH 18/23] fix: format vehicle number in generated excel file --- .../modules/repports/controllers/repport.marketing.export.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/modules/repports/controllers/repport.marketing.export.go b/internal/modules/repports/controllers/repport.marketing.export.go index ab11a921..69a58643 100644 --- a/internal/modules/repports/controllers/repport.marketing.export.go +++ b/internal/modules/repports/controllers/repport.marketing.export.go @@ -191,7 +191,7 @@ func setMarketingReportRows(file *excelize.File, items []dto.RepportMarketingIte customerName, safeMarketingExportText(item.DoNumber), salesName, - safeMarketingExportText(item.VehicleNumber), + safeMarketingExportText(formatMarketingVehicleNumber(item.VehicleNumber)), safeMarketingExportText(item.MarketingType), productName, item.Qty, From e138547f3be370ce7f66b7cacbf863bbedec4c9c Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Mon, 11 May 2026 10:43:15 +0700 Subject: [PATCH 19/23] chore: update openapi --- docs/openapi/read-api.json | 429 +++++++++++++++++++++++++++++++++++++ docs/openapi/read-api.yaml | 285 ++++++++++++++++++++++++ 2 files changed, 714 insertions(+) diff --git a/docs/openapi/read-api.json b/docs/openapi/read-api.json index dab696fa..c06f8812 100644 --- a/docs/openapi/read-api.json +++ b/docs/openapi/read-api.json @@ -3215,6 +3215,55 @@ ] } }, + "/api/inventory/stock-logs/": { + "get": { + "description": "Read access to `/api/inventory/stock-logs`.", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedEnvelope" + } + } + }, + "description": "Successful response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Forbidden" + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "summary": "GET api / inventory / stock logs", + "tags": [ + "Inventory" + ] + } + }, "/api/inventory/transfers/": { "get": { "description": "Read access to `/api/inventory/transfers`.", @@ -4318,6 +4367,29 @@ "200": { "content": { "application/json": { + "example": { + "code": 200, + "data": [ + { + "created_at": "2026-01-01T00:00:00Z", + "created_user": { + "id": 1, + "name": "Admin" + }, + "id": 1, + "name": "FCR Broiler Standard", + "updated_at": "2026-01-01T00:00:00Z" + } + ], + "message": "Get all fcrs successfully", + "meta": { + "limit": 10, + "page": 1, + "total_pages": 1, + "total_results": 1 + }, + "status": "success" + }, "schema": { "$ref": "#/components/schemas/PaginatedEnvelope" } @@ -4379,6 +4451,41 @@ "200": { "content": { "application/json": { + "example": { + "code": 200, + "data": { + "created_at": "2026-01-01T00:00:00Z", + "created_user": { + "id": 1, + "name": "Admin" + }, + "fcr_standards": [ + { + "fcr_number": 1.2, + "id": 1, + "mortality": 0.5, + "weight": 0.5 + }, + { + "fcr_number": 1.35, + "id": 2, + "mortality": 0.3, + "weight": 1 + }, + { + "fcr_number": 1.5, + "id": 3, + "mortality": 0.25, + "weight": 1.5 + } + ], + "id": 1, + "name": "FCR Broiler Standard", + "updated_at": "2026-01-01T00:00:00Z" + }, + "message": "Get fcr successfully", + "status": "success" + }, "schema": { "$ref": "#/components/schemas/SuccessEnvelope" } @@ -6457,6 +6564,126 @@ ] } }, + "/api/production/chickins/": { + "get": { + "description": "Read access to `/api/production/chickins`.", + "parameters": [ + { + "description": "Page number.", + "example": 1, + "in": "query", + "name": "page", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "Page size.", + "example": 10, + "in": "query", + "name": "limit", + "required": false, + "schema": { + "type": "integer" + } + }, + { + "description": "Project flock kandang id filter.", + "example": 1, + "in": "query", + "name": "project_flock_kandang_id", + "required": false, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "example": { + "code": 200, + "data": [ + { + "chick_in_date": "2026-01-01T00:00:00Z", + "created_at": "2026-01-01T00:00:00Z", + "created_user": { + "id": 1, + "name": "Admin" + }, + "id": 1, + "notes": "", + "pending_usage_qty": 0, + "product_warehouse": { + "id": 1, + "product": { + "id": 1, + "name": "DOC Broiler" + }, + "warehouse": { + "id": 1, + "name": "Gudang DOC" + } + }, + "product_warehouse_id": 1, + "project_flock_kandang_id": 1, + "updated_at": "2026-01-01T00:00:00Z", + "usage_qty": 10000 + } + ], + "message": "Get all chickins successfully", + "meta": { + "limit": 10, + "page": 1, + "total_pages": 1, + "total_results": 1 + }, + "status": "success" + }, + "schema": { + "$ref": "#/components/schemas/PaginatedEnvelope" + } + } + }, + "description": "Successful response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Forbidden" + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "summary": "GET api / production / chickins", + "tags": [ + "Production" + ] + } + }, "/api/production/chickins/{id}": { "get": { "description": "Read access to `/api/production/chickins/:id`.", @@ -7517,6 +7744,47 @@ "200": { "content": { "application/json": { + "example": { + "code": 200, + "data": [ + { + "approval": { + "action": null, + "step_name": "Pengajuan", + "step_number": 1 + }, + "created_at": "2026-01-15T00:00:00Z", + "created_by": 1, + "created_user": { + "id": 1, + "name": "Admin" + }, + "economic_cutoff_date": "2026-01-20T00:00:00Z", + "effective_move_date": "2026-01-18T00:00:00Z", + "executed_at": null, + "from_project_flock": { + "flock_name": "Flock A Period 1", + "id": 1 + }, + "id": 1, + "notes": "", + "to_project_flock": { + "flock_name": "Flock B Period 1", + "id": 2 + }, + "transfer_date": "2026-01-15T00:00:00Z", + "transfer_number": "TL-00001" + } + ], + "message": "Get all transferLayings successfully", + "meta": { + "limit": 10, + "page": 1, + "total_pages": 1, + "total_results": 1 + }, + "status": "success" + }, "schema": { "$ref": "#/components/schemas/PaginatedEnvelope" } @@ -7700,6 +7968,69 @@ "200": { "content": { "application/json": { + "example": { + "code": 200, + "data": { + "approval": { + "action": null, + "step_name": "Pengajuan", + "step_number": 1 + }, + "created_at": "2026-01-15T00:00:00Z", + "created_by": 1, + "created_user": { + "id": 1, + "name": "Admin" + }, + "economic_cutoff_date": "2026-01-20T00:00:00Z", + "effective_move_date": "2026-01-18T00:00:00Z", + "executed_at": null, + "from_project_flock": { + "flock_name": "Flock A Period 1", + "id": 1 + }, + "id": 1, + "notes": "", + "sources": [ + { + "note": "", + "qty": 5000, + "source_project_flock_kandang": { + "id": 1, + "kandang": { + "id": 1, + "name": "Kandang A" + }, + "kandang_id": 1, + "project_flock_id": 1 + } + } + ], + "targets": [ + { + "note": "", + "qty": 5000, + "target_project_flock_kandang": { + "id": 2, + "kandang": { + "id": 2, + "name": "Kandang B" + }, + "kandang_id": 2, + "project_flock_id": 2 + } + } + ], + "to_project_flock": { + "flock_name": "Flock B Period 1", + "id": 2 + }, + "transfer_date": "2026-01-15T00:00:00Z", + "transfer_number": "TL-00001" + }, + "message": "Get transferLaying successfully", + "status": "success" + }, "schema": { "$ref": "#/components/schemas/SuccessEnvelope" } @@ -8912,6 +9243,55 @@ ] } }, + "/api/reports/hpp-v2-breakdown": { + "get": { + "description": "Read access to `/api/reports/hpp-v2-breakdown`.", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedEnvelope" + } + } + }, + "description": "Successful response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Forbidden" + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "summary": "GET api / reports / hpp v2 breakdown", + "tags": [ + "Reports" + ] + } + }, "/api/reports/marketing": { "get": { "description": "Read access to `/api/reports/marketing`.", @@ -9555,6 +9935,55 @@ ] } }, + "/api/system-settings/": { + "get": { + "description": "Read access to `/api/system-settings`.", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedEnvelope" + } + } + }, + "description": "Successful response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Forbidden" + } + }, + "security": [ + { + "ApiKeyAuth": [] + }, + { + "BearerAuth": [] + } + ], + "summary": "GET api / system settings", + "tags": [ + "API" + ] + } + }, "/api/users/": { "get": { "description": "Read access to `/api/users`.", diff --git a/docs/openapi/read-api.yaml b/docs/openapi/read-api.yaml index f12674a4..6eaba560 100644 --- a/docs/openapi/read-api.yaml +++ b/docs/openapi/read-api.yaml @@ -2006,6 +2006,34 @@ paths: summary: GET api / inventory / product warehouses / :id tags: - Inventory + /api/inventory/stock-logs/: + get: + description: Read access to `/api/inventory/stock-logs`. + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedEnvelope' + description: Successful response + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + description: Unauthorized + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + description: Forbidden + security: + - ApiKeyAuth: [] + - BearerAuth: [] + summary: GET api / inventory / stock logs + tags: + - Inventory /api/inventory/transfers/: get: description: Read access to `/api/inventory/transfers`. @@ -2686,6 +2714,23 @@ paths: "200": content: application/json: + example: + code: 200 + data: + - created_at: "2026-01-01T00:00:00Z" + created_user: + id: 1 + name: Admin + id: 1 + name: FCR Broiler Standard + updated_at: "2026-01-01T00:00:00Z" + message: Get all fcrs successfully + meta: + limit: 10 + page: 1 + total_pages: 1 + total_results: 1 + status: success schema: $ref: '#/components/schemas/PaginatedEnvelope' description: Successful response @@ -2722,6 +2767,31 @@ paths: "200": content: application/json: + example: + code: 200 + data: + created_at: "2026-01-01T00:00:00Z" + created_user: + id: 1 + name: Admin + fcr_standards: + - fcr_number: 1.2 + id: 1 + mortality: 0.5 + weight: 0.5 + - fcr_number: 1.35 + id: 2 + mortality: 0.3 + weight: 1 + - fcr_number: 1.5 + id: 3 + mortality: 0.25 + weight: 1.5 + id: 1 + name: FCR Broiler Standard + updated_at: "2026-01-01T00:00:00Z" + message: Get fcr successfully + status: success schema: $ref: '#/components/schemas/SuccessEnvelope' description: Successful response @@ -3994,6 +4064,86 @@ paths: summary: GET api / master data / warehouses / :id tags: - Master Data + /api/production/chickins/: + get: + description: Read access to `/api/production/chickins`. + parameters: + - description: Page number. + example: 1 + in: query + name: page + required: false + schema: + type: integer + - description: Page size. + example: 10 + in: query + name: limit + required: false + schema: + type: integer + - description: Project flock kandang id filter. + example: 1 + in: query + name: project_flock_kandang_id + required: false + schema: + type: integer + responses: + "200": + content: + application/json: + example: + code: 200 + data: + - chick_in_date: "2026-01-01T00:00:00Z" + created_at: "2026-01-01T00:00:00Z" + created_user: + id: 1 + name: Admin + id: 1 + notes: "" + pending_usage_qty: 0 + product_warehouse: + id: 1 + product: + id: 1 + name: DOC Broiler + warehouse: + id: 1 + name: Gudang DOC + product_warehouse_id: 1 + project_flock_kandang_id: 1 + updated_at: "2026-01-01T00:00:00Z" + usage_qty: 10000 + message: Get all chickins successfully + meta: + limit: 10 + page: 1 + total_pages: 1 + total_results: 1 + status: success + schema: + $ref: '#/components/schemas/PaginatedEnvelope' + description: Successful response + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + description: Unauthorized + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + description: Forbidden + security: + - ApiKeyAuth: [] + - BearerAuth: [] + summary: GET api / production / chickins + tags: + - Production /api/production/chickins/{id}: get: description: Read access to `/api/production/chickins/:id`. @@ -4664,6 +4814,38 @@ paths: "200": content: application/json: + example: + code: 200 + data: + - approval: + action: null + step_name: Pengajuan + step_number: 1 + created_at: "2026-01-15T00:00:00Z" + created_by: 1 + created_user: + id: 1 + name: Admin + economic_cutoff_date: "2026-01-20T00:00:00Z" + effective_move_date: "2026-01-18T00:00:00Z" + executed_at: null + from_project_flock: + flock_name: Flock A Period 1 + id: 1 + id: 1 + notes: "" + to_project_flock: + flock_name: Flock B Period 1 + id: 2 + transfer_date: "2026-01-15T00:00:00Z" + transfer_number: TL-00001 + message: Get all transferLayings successfully + meta: + limit: 10 + page: 1 + total_pages: 1 + total_results: 1 + status: success schema: $ref: '#/components/schemas/PaginatedEnvelope' description: Successful response @@ -4700,6 +4882,53 @@ paths: "200": content: application/json: + example: + code: 200 + data: + approval: + action: null + step_name: Pengajuan + step_number: 1 + created_at: "2026-01-15T00:00:00Z" + created_by: 1 + created_user: + id: 1 + name: Admin + economic_cutoff_date: "2026-01-20T00:00:00Z" + effective_move_date: "2026-01-18T00:00:00Z" + executed_at: null + from_project_flock: + flock_name: Flock A Period 1 + id: 1 + id: 1 + notes: "" + sources: + - note: "" + qty: 5000 + source_project_flock_kandang: + id: 1 + kandang: + id: 1 + name: Kandang A + kandang_id: 1 + project_flock_id: 1 + targets: + - note: "" + qty: 5000 + target_project_flock_kandang: + id: 2 + kandang: + id: 2 + name: Kandang B + kandang_id: 2 + project_flock_id: 2 + to_project_flock: + flock_name: Flock B Period 1 + id: 2 + transfer_date: "2026-01-15T00:00:00Z" + transfer_number: TL-00001 + message: Get transferLaying successfully + status: success schema: $ref: '#/components/schemas/SuccessEnvelope' description: Successful response @@ -5545,6 +5774,34 @@ paths: summary: GET api / reports / hpp per kandang tags: - Reports + /api/reports/hpp-v2-breakdown: + get: + description: Read access to `/api/reports/hpp-v2-breakdown`. + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedEnvelope' + description: Successful response + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + description: Unauthorized + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + description: Forbidden + security: + - ApiKeyAuth: [] + - BearerAuth: [] + summary: GET api / reports / hpp v2 breakdown + tags: + - Reports /api/reports/marketing: get: description: Read access to `/api/reports/marketing`. @@ -5955,6 +6212,34 @@ paths: summary: GET api / sso / userinfo tags: - SSO + /api/system-settings/: + get: + description: Read access to `/api/system-settings`. + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedEnvelope' + description: Successful response + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + description: Unauthorized + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + description: Forbidden + security: + - ApiKeyAuth: [] + - BearerAuth: [] + summary: GET api / system settings + tags: + - API /api/users/: get: description: Read access to `/api/users`. From e576b730493cd5f57470a71d43a72f589597793b Mon Sep 17 00:00:00 2001 From: ValdiANS Date: Mon, 11 May 2026 10:43:21 +0700 Subject: [PATCH 20/23] chore: update postman --- docs/postman/read-api.collection.json | 52 +++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/docs/postman/read-api.collection.json b/docs/postman/read-api.collection.json index aa262c80..019394a9 100644 --- a/docs/postman/read-api.collection.json +++ b/docs/postman/read-api.collection.json @@ -109,6 +109,19 @@ "method": "GET", "url": "{{base_url}}/api/closings/?page=1\u0026limit=10\u0026search=kandang\u0026project_status=1\u0026location_id={{location_id}}" } + }, + { + "name": "GET api / system settings", + "request": { + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "GET", + "url": "{{base_url}}/api/system-settings/" + } } ], "name": "API" @@ -582,6 +595,19 @@ "url": "{{base_url}}/api/inventory/product-warehouses/{{id}}" } }, + { + "name": "GET api / inventory / stock logs", + "request": { + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "GET", + "url": "{{base_url}}/api/inventory/stock-logs/" + } + }, { "name": "GET api / inventory / transfers", "request": { @@ -1143,6 +1169,19 @@ }, { "item": [ + { + "name": "GET api / production / chickins", + "request": { + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "GET", + "url": "{{base_url}}/api/production/chickins/?page=1\u0026limit=10\u0026project_flock_kandang_id={{project_flock_kandang_id}}" + } + }, { "name": "GET api / production / chickins / :id", "request": { @@ -1478,6 +1517,19 @@ "url": "{{base_url}}/api/reports/hpp-per-kandang?page=1\u0026limit=10\u0026period=2026-01-01\u0026show_unrecorded=false\u0026area_id=1,2\u0026location_id=1,2\u0026kandang_id=1,2\u0026weight_min=1.2\u0026weight_max=1.8" } }, + { + "name": "GET api / reports / hpp v2 breakdown", + "request": { + "header": [ + { + "key": "Accept", + "value": "application/json" + } + ], + "method": "GET", + "url": "{{base_url}}/api/reports/hpp-v2-breakdown" + } + }, { "name": "GET api / reports / marketing", "request": { From bd8b149f111d625850f515e0f55c3aaf3c194a2d Mon Sep 17 00:00:00 2001 From: giovanni Date: Mon, 11 May 2026 11:10:35 +0700 Subject: [PATCH 21/23] adjust edit kandang kosong --- .../services/daily-checklist.service.go | 30 +++++++++++++------ 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/internal/modules/daily-checklists/services/daily-checklist.service.go b/internal/modules/daily-checklists/services/daily-checklist.service.go index 2f0c3157..6d367c7a 100644 --- a/internal/modules/daily-checklists/services/daily-checklist.service.go +++ b/internal/modules/daily-checklists/services/daily-checklist.service.go @@ -127,7 +127,6 @@ const ( dailyChecklistCategoryEmptyKandang = "empty_kandang" dailyChecklistStatusRejected = "REJECTED" dailyChecklistStatusDraft = "DRAFT" - dailyChecklistErrEmptyKandangExist = "DailyChecklist cannot be created because empty_kandang already exists for at least one date in range" dailyChecklistErrDateOverlapExist = "DailyChecklist cannot be created because at least one date in range already has a checklist" dailyChecklistErrDeletedNonEmptyKandangExists = "DailyChecklist cannot be created as empty_kandang because a deleted non-empty_kandang checklist exists for this date" ) @@ -544,9 +543,15 @@ func (s *dailyChecklistService) CreateOne(c *fiber.Ctx, req *validation.Create) return err } } else { - if err := s.validateNoEmptyKandangConflict(tx, req.KandangId, date, date); err != nil { + conflictID := uint(0) + + if err := s.validateNoEmptyKandangConflict(tx, req.KandangId, date, date, category, status, &conflictID); err != nil { return err } + if conflictID > 0 { + targetID = conflictID + return nil + } } return s.createOrReuseSingleDailyChecklist(tx, req.KandangId, date, category, status, &targetID) @@ -594,18 +599,25 @@ func (s *dailyChecklistService) validateNoChecklistOverlapForEmptyKandang(tx *go return nil } -func (s *dailyChecklistService) validateNoEmptyKandangConflict(tx *gorm.DB, kandangID uint, startDate, endDate time.Time) error { - var conflictCount int64 - if err := tx.Model(&entity.DailyChecklist{}). - Where("kandang_id = ? AND date BETWEEN ? AND ? AND category = ? AND deleted_at IS NULL", kandangID, startDate, endDate, dailyChecklistCategoryEmptyKandang). - Count(&conflictCount).Error; err != nil { +func (s *dailyChecklistService) validateNoEmptyKandangConflict(tx *gorm.DB, kandangID uint, startDate, endDate time.Time, newCategory, newStatus string, conflictID *uint) error { + var existing entity.DailyChecklist + if err := tx.Where("kandang_id = ? AND date BETWEEN ? AND ? AND category = ? AND deleted_at IS NULL", + kandangID, startDate, endDate, dailyChecklistCategoryEmptyKandang). + First(&existing).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } return err } - if conflictCount > 0 { - return fiber.NewError(fiber.StatusConflict, dailyChecklistErrEmptyKandangExist) + if err := tx.Model(&entity.DailyChecklist{}).Where("id = ?", existing.Id).Updates(map[string]interface{}{ + "category": newCategory, + "status": newStatus, + }).Error; err != nil { + return err } + *conflictID = existing.Id return nil } From aab1c3a2d52b05c37c16b8e0b4b756ff40cd5eda Mon Sep 17 00:00:00 2001 From: giovanni Date: Mon, 11 May 2026 13:45:23 +0700 Subject: [PATCH 22/23] add api for update data daily checklist by id --- .../controllers/daily-checklist.controller.go | 27 +++++++++ internal/modules/daily-checklists/route.go | 1 + .../services/daily-checklist.service.go | 59 +++++++++++++++++++ 3 files changed, 87 insertions(+) diff --git a/internal/modules/daily-checklists/controllers/daily-checklist.controller.go b/internal/modules/daily-checklists/controllers/daily-checklist.controller.go index c2f258fd..ec02fc1f 100644 --- a/internal/modules/daily-checklists/controllers/daily-checklist.controller.go +++ b/internal/modules/daily-checklists/controllers/daily-checklist.controller.go @@ -412,6 +412,33 @@ func (u *DailyChecklistController) UpdateOne(c *fiber.Ctx) error { }) } +func (u *DailyChecklistController) UpdateByPut(c *fiber.Ctx) error { + req := new(validation.Create) + param := c.Params("idDailyChecklist") + + id, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.DailyChecklistService.UpdateByPut(c, req, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Update dailyChecklist successfully", + Data: dto.ToDailyChecklistListDTO(*result), + }) +} + func validateDailyChecklistDocumentSizes(files []*multipart.FileHeader) error { const maxDailyChecklistDocumentBytes = 5 * 1024 * 1024 // 5MB for _, file := range files { diff --git a/internal/modules/daily-checklists/route.go b/internal/modules/daily-checklists/route.go index e8965697..7f983a04 100644 --- a/internal/modules/daily-checklists/route.go +++ b/internal/modules/daily-checklists/route.go @@ -59,6 +59,7 @@ func DailyChecklistRoutes(v1 fiber.Router, u user.UserService, s dailyChecklist. route.Post("/assignment", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.UpdateAssignment) route.Patch("/bulk-update", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.BulkUpdate) + route.Put("/:idDailyChecklist", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.UpdateByPut) route.Patch("/:idDailyChecklist", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.UpdateOne) route.Delete("/:idDailyChecklist", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.DeleteOne) } diff --git a/internal/modules/daily-checklists/services/daily-checklist.service.go b/internal/modules/daily-checklists/services/daily-checklist.service.go index 6d367c7a..815aec47 100644 --- a/internal/modules/daily-checklists/services/daily-checklist.service.go +++ b/internal/modules/daily-checklists/services/daily-checklist.service.go @@ -29,6 +29,7 @@ type DailyChecklistService interface { GetOne(ctx *fiber.Ctx, id uint) (*entity.DailyChecklist, error) CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.DailyChecklist, error) UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.DailyChecklist, error) + UpdateByPut(ctx *fiber.Ctx, req *validation.Create, id uint) (*entity.DailyChecklist, error) BulkUpdate(ctx *fiber.Ctx, req *validation.BulkStatusUpdate) ([]entity.DailyChecklist, error) DeleteOne(ctx *fiber.Ctx, id uint) error AssignPhases(ctx *fiber.Ctx, id uint, req *validation.AssignPhases) error @@ -878,6 +879,64 @@ func (s dailyChecklistService) BulkUpdate(c *fiber.Ctx, req *validation.BulkStat return updated, nil } +func (s *dailyChecklistService) UpdateByPut(c *fiber.Ctx, req *validation.Create, id uint) (*entity.DailyChecklist, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + if err := s.ensureChecklistAccess(c, id); err != nil { + return nil, err + } + + date, err := time.Parse(dailyChecklistDateLayout, strings.TrimSpace(req.Date)) + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "invalid date format, use YYYY-MM-DD") + } + + category := req.Category + if req.EmptyKandang { + category = dailyChecklistCategoryEmptyKandang + } + + status := req.Status + + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { + if err := s.lockKandangForChecklistCreation(tx, req.KandangId); err != nil { + return err + } + + var conflictCount int64 + if err := tx.Model(&entity.DailyChecklist{}). + Where("id <> ? AND date = ? AND kandang_id = ? AND category = ? AND deleted_at IS NULL", + id, date, req.KandangId, category). + Count(&conflictCount).Error; err != nil { + return err + } + if conflictCount > 0 { + return fiber.NewError(fiber.StatusConflict, "DailyChecklist already exists with same date, kandang, and category") + } + + result := tx.Model(&entity.DailyChecklist{}).Where("id = ?", id).Updates(map[string]any{ + "date": date, + "kandang_id": req.KandangId, + "category": category, + "status": status, + }) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return nil + }) + if err != nil { + return nil, err + } + + return s.GetOne(c, id) +} + func (s dailyChecklistService) DeleteOne(c *fiber.Ctx, id uint) error { if err := s.ensureChecklistAccess(c, id); err != nil { return err From a76ab69a845aff11a91bb6a34586204d20dc759a Mon Sep 17 00:00:00 2001 From: giovanni Date: Mon, 11 May 2026 14:07:56 +0700 Subject: [PATCH 23/23] add api for update is paid to expense --- ...511070035_add_is_paid_to_expenses.down.sql | 1 + ...60511070035_add_is_paid_to_expenses.up.sql | 1 + internal/entities/expense.go | 1 + .../controllers/expense.controller.go | 21 ++++++++++++ internal/modules/expenses/dto/expense.dto.go | 2 ++ internal/modules/expenses/route.go | 1 + .../expenses/services/expense.service.go | 34 +++++++++++++++++++ 7 files changed, 61 insertions(+) create mode 100644 internal/database/migrations/20260511070035_add_is_paid_to_expenses.down.sql create mode 100644 internal/database/migrations/20260511070035_add_is_paid_to_expenses.up.sql diff --git a/internal/database/migrations/20260511070035_add_is_paid_to_expenses.down.sql b/internal/database/migrations/20260511070035_add_is_paid_to_expenses.down.sql new file mode 100644 index 00000000..9333efe3 --- /dev/null +++ b/internal/database/migrations/20260511070035_add_is_paid_to_expenses.down.sql @@ -0,0 +1 @@ +ALTER TABLE expenses DROP COLUMN is_paid; diff --git a/internal/database/migrations/20260511070035_add_is_paid_to_expenses.up.sql b/internal/database/migrations/20260511070035_add_is_paid_to_expenses.up.sql new file mode 100644 index 00000000..6a611039 --- /dev/null +++ b/internal/database/migrations/20260511070035_add_is_paid_to_expenses.up.sql @@ -0,0 +1 @@ +ALTER TABLE expenses ADD COLUMN is_paid BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/internal/entities/expense.go b/internal/entities/expense.go index 7bea3076..ec02e0c0 100644 --- a/internal/entities/expense.go +++ b/internal/entities/expense.go @@ -17,6 +17,7 @@ type Expense struct { RealizationDate time.Time `gorm:"type:date;column:realization_date"` TransactionDate time.Time `gorm:"type:date;not null"` Notes string `gorm:"type:text;column:notes"` + IsPaid bool `gorm:"column:is_paid;not null;default:false"` CreatedBy uint64 `gorm:""` CreatedAt time.Time `gorm:"autoCreateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"` diff --git a/internal/modules/expenses/controllers/expense.controller.go b/internal/modules/expenses/controllers/expense.controller.go index 44a97446..fddd4356 100644 --- a/internal/modules/expenses/controllers/expense.controller.go +++ b/internal/modules/expenses/controllers/expense.controller.go @@ -481,6 +481,27 @@ func (u *ExpenseController) CompleteExpense(c *fiber.Ctx) error { }) } +func (u *ExpenseController) Pay(c *fiber.Ctx) error { + expenseID := c.Params("id") + id, err := strconv.Atoi(expenseID) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid expense ID") + } + + expense, err := u.ExpenseService.Pay(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Pay expense successfully", + Data: expense, + }) +} + func ensureExpenseBulkApprovalPermission(c *fiber.Ctx, targetStep approvalutils.ApprovalStep) error { requiredPerms := []string{} diff --git a/internal/modules/expenses/dto/expense.dto.go b/internal/modules/expenses/dto/expense.dto.go index 58166a6e..762cad51 100644 --- a/internal/modules/expenses/dto/expense.dto.go +++ b/internal/modules/expenses/dto/expense.dto.go @@ -29,6 +29,7 @@ type ExpenseBaseDTO struct { RealizationDate *time.Time `json:"realization_date,omitempty"` TransactionDate time.Time `json:"transaction_date"` Location *locationDTO.LocationRelationDTO `json:"location,omitempty"` + IsPaid bool `json:"is_paid"` } type ExpenseListDTO struct { @@ -127,6 +128,7 @@ func ToExpenseBaseDTO(e *entity.Expense) ExpenseBaseDTO { RealizationDate: realizationDate, TransactionDate: e.TransactionDate, Location: location, + IsPaid: e.IsPaid, } } diff --git a/internal/modules/expenses/route.go b/internal/modules/expenses/route.go index f3815e7a..9cdf7808 100644 --- a/internal/modules/expenses/route.go +++ b/internal/modules/expenses/route.go @@ -36,6 +36,7 @@ func ExpenseRoutes(v1 fiber.Router, u user.UserService, s expense.ExpenseService route.Post("/:id/realizations", m.RequirePermissions(m.P_ExpenseCreateRealizations), ctrl.CreateRealization) route.Patch("/:id/realizations", m.RequirePermissions(m.P_ExpenseUpdateRealizations), ctrl.UpdateRealization) route.Post("/:id/complete", m.RequirePermissions(m.P_ExpenseCompleteExpense), ctrl.CompleteExpense) + route.Patch("/:id/pay", m.RequirePermissions(m.P_ExpenseCreateRealizations), ctrl.Pay) route.Delete("/:id/documents/:documentId", m.RequirePermissions(m.P_ExpenseDocument), ctrl.DeleteDocument) route.Delete("/:id/realization-documents/:documentId", m.RequirePermissions(m.P_ExpenseDocumentRealizations), ctrl.DeleteRealizationDocument) } diff --git a/internal/modules/expenses/services/expense.service.go b/internal/modules/expenses/services/expense.service.go index b7298f08..d0bd8fba 100644 --- a/internal/modules/expenses/services/expense.service.go +++ b/internal/modules/expenses/services/expense.service.go @@ -36,6 +36,7 @@ type ExpenseService interface { DeleteOne(ctx *fiber.Ctx, id uint64) error CreateRealization(ctx *fiber.Ctx, expenseID uint, req *validation.CreateRealization) (*expenseDto.ExpenseDetailDTO, error) CompleteExpense(ctx *fiber.Ctx, id uint, notes *string) (*expenseDto.ExpenseDetailDTO, error) + Pay(ctx *fiber.Ctx, id uint) (*expenseDto.ExpenseDetailDTO, error) UpdateRealization(ctx *fiber.Ctx, expenseID uint, req *validation.UpdateRealization) (*expenseDto.ExpenseDetailDTO, error) DeleteDocument(ctx *fiber.Ctx, expenseID uint, documentID uint64, isRealization bool) error Approval(ctx *fiber.Ctx, req *validation.ApprovalRequest, approvalType string) ([]expenseDto.ExpenseDetailDTO, error) @@ -1310,6 +1311,39 @@ func (s *expenseService) CompleteExpense(c *fiber.Ctx, id uint, notes *string) ( return responseDTO, nil } +func (s *expenseService) Pay(c *fiber.Ctx, id uint) (*expenseDto.ExpenseDetailDTO, error) { + if err := commonSvc.EnsureRelations(c.Context(), + commonSvc.RelationCheck{Name: "Expense", ID: &id, Exists: s.Repository.IdExists}, + ); err != nil { + return nil, err + } + + expense, err := s.Repository.GetByID(c.Context(), id, nil) + if err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense") + } + if expense.IsPaid { + return nil, fiber.NewError(fiber.StatusBadRequest, "Expense is already paid") + } + + latestApproval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowExpense, id, nil) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate workflow") + } + if latestApproval == nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "No approval found. Please create expense first.") + } + if latestApproval.StepNumber < uint16(utils.ExpenseStepFinance) || latestApproval.Action == nil || *latestApproval.Action != entity.ApprovalActionApproved { + return nil, fiber.NewError(fiber.StatusBadRequest, "Expense must be approved by Finance (step 4) before payment") + } + + if err := s.Repository.PatchOne(c.Context(), id, map[string]any{"is_paid": true}, nil); err != nil { + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to update payment status") + } + + return s.GetOne(c, id) +} + func (s *expenseService) UpdateRealization(c *fiber.Ctx, expenseID uint, req *validation.UpdateRealization) (*expenseDto.ExpenseDetailDTO, error) { if err := s.Validate.Struct(req); err != nil {