From cf7b3418a552e2c850463bd308b7c77f1f31bced Mon Sep 17 00:00:00 2001 From: ragilap Date: Tue, 16 Dec 2025 10:44:19 +0700 Subject: [PATCH] fixing report-counting-sapronak --- .../controllers/closing.controller.go | 16 +- internal/modules/closings/dto/sapronak.dto.go | 149 ++++- internal/modules/closings/module.go | 3 +- .../repositories/closing.repository.go | 620 +++++++++--------- internal/modules/closings/route.go | 3 +- .../closings/services/sapronak.service.go | 232 +++---- .../closings/services/sapronak_formatter.go | 125 ---- .../validations/sapronak.validation.go | 2 +- .../product_warehouse.repository.go | 20 +- .../projectflock_kandang.repository.go | 1 + .../repositories/purchase.repository.go | 3 - .../purchases/services/purchase.service.go | 8 +- 12 files changed, 593 insertions(+), 589 deletions(-) delete mode 100644 internal/modules/closings/services/sapronak_formatter.go diff --git a/internal/modules/closings/controllers/closing.controller.go b/internal/modules/closings/controllers/closing.controller.go index f7af762f..a04fc5f9 100644 --- a/internal/modules/closings/controllers/closing.controller.go +++ b/internal/modules/closings/controllers/closing.controller.go @@ -14,16 +14,14 @@ import ( ) type ClosingController struct { - ClosingService service.ClosingService - SapronakService service.SapronakService - SapronakFormatter service.SapronakFormatter + ClosingService service.ClosingService + SapronakService service.SapronakService } -func NewClosingController(closingService service.ClosingService, sapronakService service.SapronakService, sapronakFormatter service.SapronakFormatter) *ClosingController { +func NewClosingController(closingService service.ClosingService, sapronakService service.SapronakService) *ClosingController { return &ClosingController{ - ClosingService: closingService, - SapronakService: sapronakService, - SapronakFormatter: sapronakFormatter, + ClosingService: closingService, + SapronakService: sapronakService, } } @@ -207,7 +205,7 @@ func (u *ClosingController) GetSapronakByProject(c *fiber.Ctx) error { return err } - payload := u.SapronakFormatter.ProjectPayload(result, flag) + payload := dto.ToSapronakProjectAggregatedFromReports(result, flag) return c.Status(fiber.StatusOK). JSON(response.Success{ @@ -237,7 +235,7 @@ func (u *ClosingController) GetSapronakByKandang(c *fiber.Ctx) error { return err } - payload := u.SapronakFormatter.KandangPayload(result, flag) + payload := dto.ToSapronakProjectAggregatedFromReport(result, flag) return c.Status(fiber.StatusOK). JSON(response.Success{ diff --git a/internal/modules/closings/dto/sapronak.dto.go b/internal/modules/closings/dto/sapronak.dto.go index c6fe43fa..13044efd 100644 --- a/internal/modules/closings/dto/sapronak.dto.go +++ b/internal/modules/closings/dto/sapronak.dto.go @@ -1,6 +1,9 @@ package dto -import "time" +import ( + "strings" + "time" +) type SapronakDetailDTO struct { ProductID uint `json:"product_id"` @@ -82,9 +85,10 @@ type SapronakCategoryDTO struct { } type SapronakProjectAggregatedDTO struct { - Doc *SapronakCategoryDTO `json:"doc,omitempty"` - Ovk *SapronakCategoryDTO `json:"ovk,omitempty"` - Pakan *SapronakCategoryDTO `json:"pakan,omitempty"` + Doc *SapronakCategoryDTO `json:"doc,omitempty"` + Ovk *SapronakCategoryDTO `json:"ovk,omitempty"` + Pakan *SapronakCategoryDTO `json:"pakan,omitempty"` + Pullet *SapronakCategoryDTO `json:"pullet,omitempty"` } type ClosingSapronakItemDTO struct { @@ -109,3 +113,140 @@ type ClosingSapronakDTO struct { IncomingSapronak []ClosingSapronakItemDTO `json:"incoming_sapronak"` OutgoingSapronak []ClosingSapronakItemDTO `json:"outgoing_sapronak"` } + +// === Mapper Functions for Aggregated Sapronak Response === + +func ToSapronakProjectAggregatedFromReports(reports []SapronakReportDTO, flag string) SapronakProjectAggregatedDTO { + result := SapronakProjectAggregatedDTO{} + + if len(reports) == 0 { + return result + } + + rep := reports[0] + return ToSapronakProjectAggregatedFromReport(&rep, flag) +} + +func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag string) SapronakProjectAggregatedDTO { + result := SapronakProjectAggregatedDTO{} + + if report == nil { + report = &SapronakReportDTO{} + } + + filter := strings.ToUpper(strings.TrimSpace(flag)) + + byFlag := map[string]**SapronakCategoryDTO{} + if filter == "" || filter == "DOC" { + result.Doc = &SapronakCategoryDTO{Rows: make([]SapronakCategoryRowDTO, 0)} + byFlag["DOC"] = &result.Doc + } + if filter == "" || filter == "OVK" { + result.Ovk = &SapronakCategoryDTO{Rows: make([]SapronakCategoryRowDTO, 0)} + byFlag["OVK"] = &result.Ovk + } + if filter == "" || filter == "PAKAN" { + result.Pakan = &SapronakCategoryDTO{Rows: make([]SapronakCategoryRowDTO, 0)} + byFlag["PAKAN"] = &result.Pakan + } + if filter == "" || filter == "PULLET" { + result.Pullet = &SapronakCategoryDTO{Rows: make([]SapronakCategoryRowDTO, 0)} + byFlag["PULLET"] = &result.Pullet + } + + formatDate := func(t *time.Time) string { + if t == nil { + return "" + } + return t.Format("02-Jan-2006") + } + + for _, group := range report.Groups { + flagKey := strings.ToUpper(group.Flag) + ptr := byFlag[flagKey] + if ptr == nil || *ptr == nil { + continue + } + target := *ptr + + rowIndexByProduct := make(map[string]int) + + getOrCreateRow := func(productKey string, base SapronakCategoryRowDTO) *SapronakCategoryRowDTO { + if idx, ok := rowIndexByProduct[productKey]; ok { + return &target.Rows[idx] + } + target.Rows = append(target.Rows, base) + idx := len(target.Rows) - 1 + rowIndexByProduct[productKey] = idx + return &target.Rows[idx] + } + + for idx, item := range group.Items { + productKey := strings.ToUpper(group.Flag + "|" + item.ProductName) + baseRow := SapronakCategoryRowDTO{ + ID: idx + 1, + Date: formatDate(item.Tanggal), + ReferenceNumber: item.NoReferensi, + Description: item.ProductName, + ProductCategory: item.ProductName, + UnitPrice: item.Harga, + Notes: "-", + } + + row := getOrCreateRow(productKey, baseRow) + + switch strings.ToLower(item.JenisTransaksi) { + case "pembelian", "adjustment masuk", "mutasi masuk": + row.QtyIn += item.QtyMasuk + row.TotalAmount += item.Nilai + case "pemakaian", "adjustment keluar": + row.QtyUsed += item.QtyKeluar + case "mutasi keluar": + row.QtyOut += item.QtyKeluar + default: + row.QtyIn += item.QtyMasuk + row.TotalAmount += item.Nilai + } + + if row.QtyIn > 0 { + row.UnitPrice = row.TotalAmount / row.QtyIn + } + } + + for i := range target.Rows { + target.Rows[i].ID = i + 1 + } + } + + buildTotals := func(cat *SapronakCategoryDTO, label string) { + if cat == nil { + return + } + var qtyIn, qtyOut, qtyUsed, total float64 + for _, r := range cat.Rows { + qtyIn += r.QtyIn + qtyOut += r.QtyOut + qtyUsed += r.QtyUsed + total += r.TotalAmount + } + avg := 0.0 + if qtyIn > 0 { + avg = total / qtyIn + } + cat.Total = SapronakCategoryTotalDTO{ + Label: label, + QtyIn: qtyIn, + QtyOut: qtyOut, + QtyUsed: qtyUsed, + AvgUnitPrice: avg, + TotalAmount: total, + } + } + + buildTotals(result.Doc, "TOTAL DOC") + buildTotals(result.Ovk, "TOTAL OVK") + buildTotals(result.Pakan, "TOTAL PAKAN") + buildTotals(result.Pullet, "TOTAL PULLET") + + return result +} diff --git a/internal/modules/closings/module.go b/internal/modules/closings/module.go index 00206823..c3de4a86 100644 --- a/internal/modules/closings/module.go +++ b/internal/modules/closings/module.go @@ -24,6 +24,7 @@ func (ClosingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * closingRepo := rClosing.NewClosingRepository(db) userRepo := rUser.NewUserRepository(db) projectFlockRepo := rProjectFlock.NewProjectflockRepository(db) + projectFlockKandangRepo := rProjectFlock.NewProjectFlockKandangRepository(db) projectBudgetRepo := rProjectFlock.NewProjectBudgetRepository(db) marketingRepo := rMarketings.NewMarketingRepository(db) marketingDeliveryProductRepo := rMarketings.NewMarketingDeliveryProductRepository(db) @@ -33,7 +34,7 @@ func (ClosingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * approvalService := commonSvc.NewApprovalService(approvalRepo) closingService := sClosing.NewClosingService(closingRepo, projectFlockRepo, marketingRepo, marketingDeliveryProductRepo, approvalService, expenseRealizationRepo, projectBudgetRepo, chickinRepo, validate) - sapronakService := sClosing.NewSapronakService(closingRepo, validate) + sapronakService := sClosing.NewSapronakService(closingRepo, projectFlockKandangRepo, validate) userService := sUser.NewUserService(userRepo, validate) ClosingRoutes(router, userService, closingService, sapronakService) diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go index f9f64a89..7b568801 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -16,14 +16,14 @@ import ( type ClosingRepository interface { repository.BaseRepository[entity.ProjectFlock] GetSapronak(ctx context.Context, params SapronakQueryParams) ([]SapronakRow, int64, error) - ListProjectFlockKandangsForSapronak(ctx context.Context, params *validation.CountSapronakQuery) ([]entity.ProjectFlockKandang, error) - MapSapronakStartDates(ctx context.Context, pfkIDs []uint) (map[uint]time.Time, error) - FetchSapronakIncoming(ctx context.Context, kandangID uint, start, end *time.Time) ([]SapronakIncomingRow, error) - FetchSapronakIncomingDetails(ctx context.Context, kandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error) - FetchSapronakUsage(ctx context.Context, pfkID uint, start, end *time.Time) ([]SapronakUsageRow, error) - FetchSapronakUsageDetails(ctx context.Context, pfkID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error) - FetchSapronakAdjustments(ctx context.Context, kandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) - FetchSapronakTransfers(ctx context.Context, kandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) + FetchSapronakIncoming(ctx context.Context, kandangID uint) ([]SapronakIncomingRow, error) + FetchSapronakIncomingDetails(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, error) + FetchSapronakUsage(ctx context.Context, pfkID uint) ([]SapronakUsageRow, error) + FetchSapronakUsageDetails(ctx context.Context, pfkID uint) (map[uint][]SapronakDetailRow, error) + FetchSapronakChickinUsage(ctx context.Context, pfkID uint) ([]SapronakUsageRow, error) + FetchSapronakChickinUsageDetails(ctx context.Context, pfkID uint) (map[uint][]SapronakDetailRow, error) + FetchSapronakAdjustments(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) + FetchSapronakTransfers(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) } type ClosingRepositoryImpl struct { @@ -260,183 +260,159 @@ type SapronakDetailRow struct { Price float64 } -func (r *ClosingRepositoryImpl) ListProjectFlockKandangsForSapronak(ctx context.Context, params *validation.CountSapronakQuery) ([]entity.ProjectFlockKandang, error) { - db := r.DB(). - WithContext(ctx). - Preload("ProjectFlock"). - Preload("Kandang") - if params != nil { - if params.ProjectFlockID > 0 { - db = db.Where("project_flock_kandangs.project_flock_id = ?", params.ProjectFlockID) - } - if params.KandangID > 0 { - db = db.Where("project_flock_kandangs.kandang_id = ?", params.KandangID) - } - if params.ProjectFlockKandangID > 0 { - db = db.Where("project_flock_kandangs.id = ?", params.ProjectFlockKandangID) +func (r *ClosingRepositoryImpl) withCtx(ctx context.Context) *gorm.DB { return r.DB().WithContext(ctx) } + +func applyJoins(db *gorm.DB, joins ...string) *gorm.DB { + for _, j := range joins { + if strings.TrimSpace(j) != "" { + db = db.Joins(j) } } - - var pfks []entity.ProjectFlockKandang - if err := db.Find(&pfks).Error; err != nil { - return nil, err - } - return pfks, nil + return db } -func (r *ClosingRepositoryImpl) MapSapronakStartDates(ctx context.Context, pfkIDs []uint) (map[uint]time.Time, error) { - result := make(map[uint]time.Time, len(pfkIDs)) - if len(pfkIDs) == 0 { - return result, nil +func sapronakFlags(flags ...utils.FlagType) []string { + out := make([]string, len(flags)) + for i, f := range flags { + out[i] = string(f) } + return out +} - var rows []struct { - ProjectFlockKandangID uint `gorm:"column:project_flock_kandang_id"` - StartDate *time.Time `gorm:"column:start_date"` - } - - if err := r.DB(). - WithContext(ctx). - Table("project_chickins"). - Select("project_flock_kandang_id, MIN(chick_in_date) AS start_date"). - Where("project_flock_kandang_id IN ?", pfkIDs). - Group("project_flock_kandang_id"). - Scan(&rows).Error; err != nil { - return nil, err - } +var ( + sapronakFlagsAll = sapronakFlags(utils.FlagDOC, utils.FlagPakan, utils.FlagOVK, utils.FlagPullet) + sapronakFlagsUsage = sapronakFlags(utils.FlagPakan, utils.FlagOVK) + sapronakFlagsChickin = sapronakFlags(utils.FlagDOC, utils.FlagPullet) +) +func groupSapronakDetails(rows []SapronakDetailRow) map[uint][]SapronakDetailRow { + m := make(map[uint][]SapronakDetailRow) for _, row := range rows { - if row.StartDate != nil { - result[row.ProjectFlockKandangID] = row.StartDate.UTC() - } + m[row.ProductID] = append(m[row.ProductID], row) } - - return result, nil + return m } -func (r *ClosingRepositoryImpl) FetchSapronakIncoming(ctx context.Context, kandangID uint, start, end *time.Time) ([]SapronakIncomingRow, error) { - rows := make([]SapronakIncomingRow, 0) - - db := r.DB(). - WithContext(ctx). - Table("purchase_items AS pi"). - Select(` - pi.product_id AS product_id, - p.name AS product_name, - f.name AS flag, - COALESCE(SUM(pi.total_qty), 0) AS qty, - COALESCE(SUM(pi.total_qty * pi.price), 0) AS value, - COALESCE(p.product_price, 0) AS default_price - `). - Joins("JOIN purchases po ON po.id = pi.purchase_id AND po.deleted_at IS NULL"). - Joins("JOIN products p ON p.id = pi.product_id"). - Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct). - Joins("JOIN warehouses w ON w.id = pi.warehouse_id"). - Where("w.kandang_id = ?", kandangID). - Where("f.name IN ?", []string{string(utils.FlagDOC), string(utils.FlagPakan), string(utils.FlagOVK)}). - Where("pi.received_date IS NOT NULL") - - if start != nil { - db = db.Where("pi.received_date >= ?", *start) - } - if end != nil { - db = db.Where("pi.received_date < ?", *end) - } - - if err := db.Group("pi.product_id, p.name, f.name, p.product_price").Scan(&rows).Error; err != nil { - return nil, err - } - - return rows, nil -} - -func (r *ClosingRepositoryImpl) FetchSapronakUsage(ctx context.Context, pfkID uint, start, end *time.Time) ([]SapronakUsageRow, error) { - rows := make([]SapronakUsageRow, 0) - if pfkID == 0 { - return rows, nil - } - - db := r.DB(). - WithContext(ctx). - Table("recording_stocks AS rs"). - Select(` - pw.product_id AS product_id, - p.name AS product_name, - f.name AS flag, - COALESCE(SUM(rs.usage_qty), 0) AS qty, - COALESCE(p.product_price, 0) AS default_price - `). - Joins("JOIN recordings r ON r.id = rs.recording_id AND r.deleted_at IS NULL"). - Joins("JOIN product_warehouses pw ON pw.id = rs.product_warehouse_id"). - Joins("JOIN products p ON p.id = pw.product_id"). - Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct). - Where("r.project_flock_kandangs_id = ?", pfkID). - Where("f.name IN ?", []string{string(utils.FlagDOC), string(utils.FlagPakan), string(utils.FlagOVK)}) - - if start != nil { - db = db.Where("r.record_datetime >= ?", *start) - } - if end != nil { - db = db.Where("r.record_datetime < ?", *end) - } - - if err := db.Group("pw.product_id, p.name, f.name, p.product_price").Scan(&rows).Error; err != nil { - return nil, err - } - - return rows, nil -} - -func (r *ClosingRepositoryImpl) FetchSapronakIncomingDetails(ctx context.Context, kandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error) { +func scanAndGroupDetails(db *gorm.DB) (map[uint][]SapronakDetailRow, error) { rows := make([]SapronakDetailRow, 0) - - db := r.DB(). - WithContext(ctx). - Table("purchase_items AS pi"). - Select(` - pi.product_id AS product_id, - p.name AS product_name, - f.name AS flag, - pi.received_date AS date, - COALESCE(po.po_number, '') AS reference, - COALESCE(pi.total_qty,0) AS qty_in, - 0 AS qty_out, - COALESCE(pi.price,0) AS price - `). - Joins("JOIN purchases po ON po.id = pi.purchase_id AND po.deleted_at IS NULL"). - Joins("JOIN products p ON p.id = pi.product_id"). - Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct). - Joins("JOIN warehouses w ON w.id = pi.warehouse_id"). - Where("w.kandang_id = ?", kandangID). - Where("f.name IN ?", []string{string(utils.FlagDOC), string(utils.FlagPakan), string(utils.FlagOVK)}). - Where("pi.received_date IS NOT NULL") - - if start != nil { - db = db.Where("pi.received_date >= ?", *start) - } - if end != nil { - db = db.Where("pi.received_date < ?", *end) - } - if err := db.Scan(&rows).Error; err != nil { return nil, err } - - result := make(map[uint][]SapronakDetailRow) - for _, row := range rows { - result[row.ProductID] = append(result[row.ProductID], row) - } - return result, nil + return groupSapronakDetails(rows), nil } -func (r *ClosingRepositoryImpl) FetchSapronakUsageDetails(ctx context.Context, pfkID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error) { - rows := make([]SapronakDetailRow, 0) +// ========================= +// Usage (summary + details) +// ========================= - db := r.DB(). - WithContext(ctx). - Table("recording_stocks AS rs"). - Select(` +func (r *ClosingRepositoryImpl) usageQuery( + ctx context.Context, + table string, + pwJoinCond string, + joins []string, + where string, + args ...any, +) *gorm.DB { + db := r.withCtx(ctx).Table(table).Select(` + pw.product_id AS product_id, + p.name AS product_name, + f.name AS flag, + COALESCE(SUM(usage_qty), 0) AS qty, + COALESCE(p.product_price, 0) AS default_price + `) + db = applyJoins(db, joins...) + return db. + Joins("JOIN product_warehouses pw ON " + pwJoinCond). + Joins("JOIN products p ON p.id = pw.product_id"). + Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct). + Where(where, args...) +} + +func (r *ClosingRepositoryImpl) fetchSapronakUsage( + ctx context.Context, + table string, + pwJoinCond string, + joins []string, + where string, + args ...any, +) ([]SapronakUsageRow, error) { + rows := make([]SapronakUsageRow, 0) + db := r.usageQuery(ctx, table, pwJoinCond, joins, where, args...) + if err := db.Group("pw.product_id, p.name, f.name, p.product_price").Scan(&rows).Error; err != nil { + return nil, err + } + return rows, nil +} + +func (r *ClosingRepositoryImpl) detailQuery( + ctx context.Context, + table string, + pwJoinCond string, + joins []string, + selectSQL string, + where string, + args ...any, +) *gorm.DB { + db := r.withCtx(ctx). + Table(table). + Joins("JOIN product_warehouses pw ON " + pwJoinCond). + Joins("JOIN products p ON p.id = pw.product_id"). + Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct) + + db = applyJoins(db, joins...) + return db.Select(selectSQL).Where(where, args...) +} + +func (r *ClosingRepositoryImpl) fetchSapronakDetails( + ctx context.Context, + table string, + pwJoinCond string, + joins []string, + selectSQL string, + where string, + args ...any, +) (map[uint][]SapronakDetailRow, error) { + return scanAndGroupDetails(r.detailQuery(ctx, table, pwJoinCond, joins, selectSQL, where, args...)) +} + +func (r *ClosingRepositoryImpl) FetchSapronakUsage(ctx context.Context, pfkID uint) ([]SapronakUsageRow, error) { + if pfkID == 0 { + return nil, nil + } + return r.fetchSapronakUsage( + ctx, + "recording_stocks rs", + "pw.id = rs.product_warehouse_id", + []string{"JOIN recordings r ON r.id = rs.recording_id AND r.deleted_at IS NULL"}, + "r.project_flock_kandangs_id = ? AND f.name IN ?", + pfkID, + sapronakFlagsUsage, + ) +} + +func (r *ClosingRepositoryImpl) FetchSapronakChickinUsage(ctx context.Context, pfkID uint) ([]SapronakUsageRow, error) { + if pfkID == 0 { + return []SapronakUsageRow{}, nil + } + return r.fetchSapronakUsage( + ctx, + "project_chickins pc", + "pw.id = pc.product_warehouse_id", + nil, + "pc.project_flock_kandang_id = ? AND pc.usage_qty > 0 AND f.name IN ?", + pfkID, + sapronakFlagsChickin, + ) +} + +func (r *ClosingRepositoryImpl) FetchSapronakUsageDetails(ctx context.Context, pfkID uint) (map[uint][]SapronakDetailRow, error) { + return r.fetchSapronakDetails( + ctx, + "recording_stocks rs", + "pw.id = rs.product_warehouse_id", + []string{"JOIN recordings r ON r.id = rs.recording_id AND r.deleted_at IS NULL"}, // penting: supaya alias r valid + ` pw.product_id AS product_id, p.name AS product_name, f.name AS flag, @@ -445,184 +421,180 @@ func (r *ClosingRepositoryImpl) FetchSapronakUsageDetails(ctx context.Context, p 0 AS qty_in, COALESCE(rs.usage_qty,0) AS qty_out, COALESCE(p.product_price,0) AS price + `, + "r.project_flock_kandangs_id = ? AND f.name IN ?", + pfkID, + sapronakFlagsUsage, + ) +} + +func (r *ClosingRepositoryImpl) FetchSapronakChickinUsageDetails(ctx context.Context, pfkID uint) (map[uint][]SapronakDetailRow, error) { + return r.fetchSapronakDetails( + ctx, + "project_chickins pc", + "pw.id = pc.product_warehouse_id", + nil, + ` + pw.product_id AS product_id, + p.name AS product_name, + f.name AS flag, + pc.chick_in_date AS date, + CAST(pc.id AS TEXT) AS reference, + 0 AS qty_in, + COALESCE(pc.usage_qty,0) AS qty_out, + COALESCE(p.product_price,0) AS price + `, + "pc.project_flock_kandang_id = ? AND pc.usage_qty > 0 AND f.name IN ?", + pfkID, + sapronakFlagsChickin, + ) +} + + +func (r *ClosingRepositoryImpl) incomingPurchaseBase(ctx context.Context, kandangID uint) *gorm.DB { + return r.withCtx(ctx). + Table("purchase_items AS pi"). + Joins("JOIN purchases po ON po.id = pi.purchase_id AND po.deleted_at IS NULL"). + Joins("JOIN products p ON p.id = pi.product_id"). + Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct). + Joins("JOIN warehouses w ON w.id = pi.warehouse_id"). + Where("w.kandang_id = ?", kandangID). + Where("f.name IN ?", sapronakFlagsAll). + Where("pi.received_date IS NOT NULL") +} + +func (r *ClosingRepositoryImpl) FetchSapronakIncoming(ctx context.Context, kandangID uint) ([]SapronakIncomingRow, error) { + rows := make([]SapronakIncomingRow, 0) + db := r.incomingPurchaseBase(ctx, kandangID).Select(` + pi.product_id AS product_id, + p.name AS product_name, + f.name AS flag, + COALESCE(SUM(pi.total_qty), 0) AS qty, + COALESCE(SUM(pi.total_qty * pi.price), 0) AS value, + COALESCE(p.product_price, 0) AS default_price + `) + if err := db.Group("pi.product_id, p.name, f.name, p.product_price").Scan(&rows).Error; err != nil { + return nil, err + } + return rows, nil +} + +func (r *ClosingRepositoryImpl) FetchSapronakIncomingDetails(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, error) { + return scanAndGroupDetails( + r.incomingPurchaseBase(ctx, kandangID).Select(` + pi.product_id AS product_id, + p.name AS product_name, + f.name AS flag, + pi.received_date AS date, + COALESCE(po.po_number, '') AS reference, + COALESCE(pi.total_qty,0) AS qty_in, + 0 AS qty_out, + COALESCE(pi.price,0) AS price + `), + ) +} + +type stockLogSapronakRow struct { + ID uint `gorm:"column:id"` + ProductID uint `gorm:"column:product_id"` + ProductName string `gorm:"column:product_name"` + Flag string `gorm:"column:flag"` + CreatedAt *time.Time `gorm:"column:created_at"` + Increase float64 `gorm:"column:increase"` + Decrease float64 `gorm:"column:decrease"` + Price float64 `gorm:"column:price"` + MovementNumber string `gorm:"column:movement_number"` +} + +func (r *ClosingRepositoryImpl) fetchStockLogs(ctx context.Context, kandangID uint, logType any, withMovement bool) ([]stockLogSapronakRow, error) { + rows := make([]stockLogSapronakRow, 0) + + movementSelect := "'' AS movement_number" + joins := []string{} + if withMovement { + movementSelect = "COALESCE(st.movement_number,'') AS movement_number" + joins = append(joins, "JOIN stock_transfers st ON st.id = sl.loggable_id") + } + + db := r.withCtx(ctx). + Table("stock_logs sl"). + Select(` + sl.id AS id, + pw.product_id AS product_id, + p.name AS product_name, + f.name AS flag, + sl.created_at AS created_at, + COALESCE(sl.increase,0) AS increase, + COALESCE(sl.decrease,0) AS decrease, + COALESCE(p.product_price,0) AS price, + ` + movementSelect + ` `). - Joins("JOIN recordings r ON r.id = rs.recording_id AND r.deleted_at IS NULL"). - Joins("JOIN product_warehouses pw ON pw.id = rs.product_warehouse_id"). + Joins("JOIN product_warehouses pw ON pw.id = sl.product_warehouse_id"). Joins("JOIN products p ON p.id = pw.product_id"). Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct). - Where("r.project_flock_kandangs_id = ?", pfkID). - Where("f.name IN ?", []string{string(utils.FlagDOC), string(utils.FlagPakan), string(utils.FlagOVK)}) + Joins("JOIN warehouses w ON w.id = pw.warehouse_id") - if start != nil { - db = db.Where("r.record_datetime >= ?", *start) - } - if end != nil { - db = db.Where("r.record_datetime < ?", *end) - } + db = applyJoins(db, joins...) - if err := db.Scan(&rows).Error; err != nil { + if err := db. + Where("sl.loggable_type = ?", logType). + Where("w.kandang_id = ?", kandangID). + Where("f.name IN ?", sapronakFlagsAll). + Scan(&rows).Error; err != nil { return nil, err } - result := make(map[uint][]SapronakDetailRow) - for _, row := range rows { - result[row.ProductID] = append(result[row.ProductID], row) - } - return result, nil + return rows, nil } -func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, kandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) { - incoming := make(map[uint][]SapronakDetailRow) - outgoing := make(map[uint][]SapronakDetailRow) - - rows := make([]struct { - ID uint - ProductID uint - ProductName string - Flag string - CreatedAt *time.Time - Increase float64 - Decrease float64 - Price float64 - }, 0) - - db := r.DB(). - WithContext(ctx). - Table("stock_logs sl"). - Select(` - sl.id AS id, - pw.product_id AS product_id, - p.name AS product_name, - f.name AS flag, - sl.created_at AS created_at, - COALESCE(sl.increase,0) AS increase, - COALESCE(sl.decrease,0) AS decrease, - COALESCE(p.product_price,0) AS price - `). - Joins("JOIN product_warehouses pw ON pw.id = sl.product_warehouse_id"). - Joins("JOIN products p ON p.id = pw.product_id"). - Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct). - Joins("JOIN warehouses w ON w.id = pw.warehouse_id"). - Where("sl.loggable_type = ?", entity.LogTypeAdjustment). - Where("w.kandang_id = ?", kandangID). - Where("f.name IN ?", []string{string(utils.FlagDOC), string(utils.FlagPakan), string(utils.FlagOVK)}) - - if start != nil { - db = db.Where("sl.created_at >= ?", *start) - } - if end != nil { - db = db.Where("sl.created_at < ?", *end) - } - - if err := db.Scan(&rows).Error; err != nil { - return nil, nil, err - } +func splitStockLogs(rows []stockLogSapronakRow, refFn func(stockLogSapronakRow) string) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow) { + in := make(map[uint][]SapronakDetailRow) + out := make(map[uint][]SapronakDetailRow) for _, row := range rows { - ref := fmt.Sprintf("ADJ-%d", row.ID) + base := SapronakDetailRow{ + ProductID: row.ProductID, + ProductName: row.ProductName, + Flag: row.Flag, + Date: row.CreatedAt, + Reference: refFn(row), + Price: row.Price, + } + if row.Increase > 0 { - incoming[row.ProductID] = append(incoming[row.ProductID], SapronakDetailRow{ - ProductID: row.ProductID, - ProductName: row.ProductName, - Flag: row.Flag, - Date: row.CreatedAt, - Reference: ref, - QtyIn: row.Increase, - QtyOut: 0, - Price: row.Price, - }) + d := base + d.QtyIn = row.Increase + in[row.ProductID] = append(in[row.ProductID], d) } if row.Decrease > 0 { - outgoing[row.ProductID] = append(outgoing[row.ProductID], SapronakDetailRow{ - ProductID: row.ProductID, - ProductName: row.ProductName, - Flag: row.Flag, - Date: row.CreatedAt, - Reference: ref, - QtyIn: 0, - QtyOut: row.Decrease, - Price: row.Price, - }) + d := base + d.QtyOut = row.Decrease + out[row.ProductID] = append(out[row.ProductID], d) } } - return incoming, outgoing, nil + return in, out } -func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kandangID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) { - incoming := make(map[uint][]SapronakDetailRow) - outgoing := make(map[uint][]SapronakDetailRow) - - rows := make([]struct { - ID uint - ProductID uint - ProductName string - Flag string - CreatedAt *time.Time - Increase float64 - Decrease float64 - Price float64 - }, 0) - - db := r.DB(). - WithContext(ctx). - Table("stock_logs sl"). - Select(` - sl.id AS id, - pw.product_id AS product_id, - p.name AS product_name, - f.name AS flag, - sl.created_at AS created_at, - COALESCE(sl.increase,0) AS increase, - COALESCE(sl.decrease,0) AS decrease, - COALESCE(p.product_price,0) AS price - `). - Joins("JOIN product_warehouses pw ON pw.id = sl.product_warehouse_id"). - Joins("JOIN products p ON p.id = pw.product_id"). - Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct). - Joins("JOIN warehouses w ON w.id = pw.warehouse_id"). - Where("sl.loggable_type = ?", entity.LogTypeTransfer). - Where("w.kandang_id = ?", kandangID). - Where("f.name IN ?", []string{string(utils.FlagDOC), string(utils.FlagPakan), string(utils.FlagOVK)}) - - if start != nil { - db = db.Where("sl.created_at >= ?", *start) - } - if end != nil { - db = db.Where("sl.created_at < ?", *end) - } - - if err := db.Scan(&rows).Error; err != nil { +func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) { + rows, err := r.fetchStockLogs(ctx, kandangID, entity.LogTypeAdjustment, false) + if err != nil { return nil, nil, err } - - for _, row := range rows { - ref := fmt.Sprintf("TRF-%d", row.ID) - if row.Increase > 0 { - incoming[row.ProductID] = append(incoming[row.ProductID], SapronakDetailRow{ - ProductID: row.ProductID, - ProductName: row.ProductName, - Flag: row.Flag, - Date: row.CreatedAt, - Reference: ref, - QtyIn: row.Increase, - QtyOut: 0, - Price: row.Price, - }) - } - if row.Decrease > 0 { - outgoing[row.ProductID] = append(outgoing[row.ProductID], SapronakDetailRow{ - ProductID: row.ProductID, - ProductName: row.ProductName, - Flag: row.Flag, - Date: row.CreatedAt, - Reference: ref, - QtyIn: 0, - QtyOut: row.Decrease, - Price: row.Price, - }) - } - } - - return incoming, outgoing, nil + in, out := splitStockLogs(rows, func(row stockLogSapronakRow) string { return fmt.Sprintf("ADJ-%d", row.ID) }) + return in, out, nil } + +func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) { + rows, err := r.fetchStockLogs(ctx, kandangID, entity.LogTypeTransfer, true) + if err != nil { + return nil, nil, err + } + in, out := splitStockLogs(rows, func(row stockLogSapronakRow) string { + if ref := strings.TrimSpace(row.MovementNumber); ref != "" { + return ref + } + return fmt.Sprintf("TRF-%d", row.ID) + }) + return in, out, nil +} \ No newline at end of file diff --git a/internal/modules/closings/route.go b/internal/modules/closings/route.go index b12cd72f..a76e8b79 100644 --- a/internal/modules/closings/route.go +++ b/internal/modules/closings/route.go @@ -10,8 +10,7 @@ import ( ) func ClosingRoutes(v1 fiber.Router, u user.UserService, s closing.ClosingService, sapronakSvc closing.SapronakService) { - formatter := closing.NewSapronakFormatter() - ctrl := controller.NewClosingController(s, sapronakSvc, formatter) + ctrl := controller.NewClosingController(s, sapronakSvc) route := v1.Group("/closings") diff --git a/internal/modules/closings/services/sapronak.service.go b/internal/modules/closings/services/sapronak.service.go index 9958a815..3c1843dd 100644 --- a/internal/modules/closings/services/sapronak.service.go +++ b/internal/modules/closings/services/sapronak.service.go @@ -13,36 +13,35 @@ import ( "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/dto" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/validations" + projectflockRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" ) type SapronakService interface { GetSapronakByProject(ctx *fiber.Ctx, projectFlockID uint, flag string) ([]dto.SapronakReportDTO, error) GetSapronakByKandang(ctx *fiber.Ctx, projectFlockID uint, pfkID uint, flag string) (*dto.SapronakReportDTO, error) - GetSapronakReport(ctx *fiber.Ctx, params *validation.CountSapronakQuery) ([]dto.SapronakReportDTO, error) } type sapronakService struct { - Log *logrus.Logger - Validate *validator.Validate - Repository repository.ClosingRepository + Log *logrus.Logger + Validate *validator.Validate + Repository repository.ClosingRepository + ProjectFlockKandangRepo projectflockRepository.ProjectFlockKandangRepository } -func NewSapronakService(repo repository.ClosingRepository, validate *validator.Validate) SapronakService { +func NewSapronakService( + repo repository.ClosingRepository, + pfkRepo projectflockRepository.ProjectFlockKandangRepository, + validate *validator.Validate, +) SapronakService { return &sapronakService{ - Log: utils.Log, - Validate: validate, - Repository: repo, + Log: utils.Log, + Validate: validate, + Repository: repo, + ProjectFlockKandangRepo: pfkRepo, } } -func (s sapronakService) GetSapronakReport(c *fiber.Ctx, params *validation.CountSapronakQuery) ([]dto.SapronakReportDTO, error) { - if err := s.Validate.Struct(params); err != nil { - return nil, err - } - return s.computeSapronakReports(c.Context(), params) -} - func (s sapronakService) GetSapronakByProject(c *fiber.Ctx, projectFlockID uint, flag string) ([]dto.SapronakReportDTO, error) { if projectFlockID == 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_id is required") @@ -96,13 +95,6 @@ func (s sapronakService) computeSapronakReports(ctx context.Context, params *val return []dto.SapronakReportDTO{}, nil } - startMap, err := s.mapStartDates(ctx, pfks) - if err != nil { - s.Log.Errorf("Failed to prepare start dates for sapronak report: %+v", err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to prepare sapronak report") - } - statusMap, nextStartMap := s.computeStatusAndNextStart(pfks, startMap) - filterStatus := strings.ToLower(strings.TrimSpace(params.Status)) if filterStatus == "" { filterStatus = "all" @@ -110,29 +102,17 @@ func (s sapronakService) computeSapronakReports(ctx context.Context, params *val results := make([]dto.SapronakReportDTO, 0, len(pfks)) for _, pfk := range pfks { - status := statusMap[pfk.Id] - if status == "" { - status = "closing" + status := "closing" + if pfk.ClosedAt == nil { + status = "active" } if (filterStatus == "active" && status != "active") || (filterStatus == "closing" && status != "closing") { continue } - start := startMap[pfk.Id] - var startPtr *time.Time - if !start.IsZero() { - startCopy := start - startPtr = &startCopy - } - - var endPtr *time.Time - if end, ok := nextStartMap[pfk.Id]; ok { - endCopy := end - endPtr = &endCopy - } - - items, groups, totalIncoming, totalUsage, err := s.buildSapronakItems(ctx, pfk, startPtr, endPtr, params.Flag) + // We no longer filter by date for closing sapronak report; pass nil pointers. + items, groups, totalIncoming, totalUsage, err := s.buildSapronakItems(ctx, pfk, nil, nil, params.Flag) if err != nil { s.Log.Errorf("Failed to build sapronak items for pfk %d: %+v", pfk.Id, err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to calculate sapronak report") @@ -146,8 +126,8 @@ func (s sapronakService) computeSapronakReports(ctx context.Context, params *val KandangName: pfk.Kandang.Name, Period: pfk.Period, Status: status, - StartDate: startPtr, - EndDate: endPtr, + StartDate: nil, + EndDate: nil, TotalIncomingValue: totalIncoming, TotalUsageValue: totalUsage, Items: items, @@ -159,41 +139,31 @@ func (s sapronakService) computeSapronakReports(ctx context.Context, params *val } func (s sapronakService) loadProjectFlockKandangs(ctx context.Context, params *validation.CountSapronakQuery) ([]entity.ProjectFlockKandang, error) { - pfks, err := s.Repository.ListProjectFlockKandangsForSapronak(ctx, params) - if err != nil { + db := s.ProjectFlockKandangRepo.DB().WithContext(ctx). + Preload("ProjectFlock"). + Preload("Kandang"). + Preload("Chickins") + + if params != nil { + if params.ProjectFlockID > 0 { + db = db.Where("project_flock_kandangs.project_flock_id = ?", params.ProjectFlockID) + } + if params.KandangID > 0 { + db = db.Where("project_flock_kandangs.kandang_id = ?", params.KandangID) + } + if params.ProjectFlockKandangID > 0 { + db = db.Where("project_flock_kandangs.id = ?", params.ProjectFlockKandangID) + } + } + + var pfks []entity.ProjectFlockKandang + if err := db.Find(&pfks).Error; err != nil { s.Log.Errorf("Failed to load project flock kandangs for sapronak report: %+v", err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load project flock kandangs") } return pfks, nil } -func (s sapronakService) mapStartDates(ctx context.Context, pfks []entity.ProjectFlockKandang) (map[uint]time.Time, error) { - result := make(map[uint]time.Time, len(pfks)) - if len(pfks) == 0 { - return result, nil - } - - ids := make([]uint, len(pfks)) - for i, pfk := range pfks { - ids[i] = pfk.Id - } - - startDates, err := s.Repository.MapSapronakStartDates(ctx, ids) - if err != nil { - return nil, err - } - - for _, pfk := range pfks { - if start, ok := startDates[pfk.Id]; ok { - result[pfk.Id] = start - continue - } - result[pfk.Id] = pfk.CreatedAt.UTC() - } - - return result, nil -} - func (s sapronakService) combineSapronakReports(reports []dto.SapronakReportDTO, projectID uint) dto.SapronakReportDTO { if len(reports) == 0 { return dto.SapronakReportDTO{} @@ -202,7 +172,6 @@ func (s sapronakService) combineSapronakReports(reports []dto.SapronakReportDTO, var ( totalIncoming float64 totalUsage float64 - earliestStart *time.Time projectName = reports[0].ProjectName ) @@ -220,11 +189,6 @@ func (s sapronakService) combineSapronakReports(reports []dto.SapronakReportDTO, for _, r := range reports { totalIncoming += r.TotalIncomingValue totalUsage += r.TotalUsageValue - if r.StartDate != nil { - if earliestStart == nil || r.StartDate.Before(*earliestStart) { - earliestStart = r.StartDate - } - } for _, it := range r.Items { cur := itemMap[it.ProductID] @@ -274,7 +238,7 @@ func (s sapronakService) combineSapronakReports(reports []dto.SapronakReportDTO, ProjectFlockID: projectID, ProjectName: projectName, Status: "combined", - StartDate: earliestStart, + StartDate: nil, TotalIncomingValue: totalIncoming, TotalUsageValue: totalUsage, Items: items, @@ -282,36 +246,6 @@ func (s sapronakService) combineSapronakReports(reports []dto.SapronakReportDTO, } } -func (s sapronakService) computeStatusAndNextStart(pfks []entity.ProjectFlockKandang, startMap map[uint]time.Time) (map[uint]string, map[uint]time.Time) { - statusMap := make(map[uint]string, len(pfks)) - nextStartMap := make(map[uint]time.Time, len(pfks)) - - if len(pfks) == 0 { - return statusMap, nextStartMap - } - - grouped := make(map[uint][]entity.ProjectFlockKandang) - for _, pfk := range pfks { - grouped[pfk.KandangId] = append(grouped[pfk.KandangId], pfk) - } - - for _, list := range grouped { - for idx, item := range list { - if idx < len(list)-1 { - next := list[idx+1] - if start, ok := startMap[next.Id]; ok { - nextStartMap[item.Id] = start - } - statusMap[item.Id] = "closing" - continue - } - statusMap[item.Id] = "active" - } - } - - return statusMap, nextStartMap -} - func mapIncomingUsage(incomingRows []repository.SapronakIncomingRow, usageRows []repository.SapronakUsageRow) (map[uint]repository.SapronakIncomingRow, map[uint]repository.SapronakUsageRow) { incoming := make(map[uint]repository.SapronakIncomingRow, len(incomingRows)) for _, row := range incomingRows { @@ -330,6 +264,7 @@ type sapronakDetailMaps struct { AdjIncoming map[uint][]dto.SapronakDetailDTO AdjOutgoing map[uint][]dto.SapronakDetailDTO TransferIn map[uint][]dto.SapronakDetailDTO + TransferOut map[uint][]dto.SapronakDetailDTO } func buildSapronakDetails( @@ -338,6 +273,7 @@ func buildSapronakDetails( adjIncomingRows map[uint][]repository.SapronakDetailRow, adjOutgoingRows map[uint][]repository.SapronakDetailRow, transferInRows map[uint][]repository.SapronakDetailRow, + transferOutRows map[uint][]repository.SapronakDetailRow, ) sapronakDetailMaps { result := sapronakDetailMaps{ Incoming: make(map[uint][]dto.SapronakDetailDTO), @@ -345,6 +281,7 @@ func buildSapronakDetails( AdjIncoming: make(map[uint][]dto.SapronakDetailDTO), AdjOutgoing: make(map[uint][]dto.SapronakDetailDTO), TransferIn: make(map[uint][]dto.SapronakDetailDTO), + TransferOut: make(map[uint][]dto.SapronakDetailDTO), } addRows := func(target map[uint][]dto.SapronakDetailDTO, src map[uint][]repository.SapronakDetailRow, jenis string, masuk bool) { @@ -376,32 +313,43 @@ func buildSapronakDetails( addRows(result.AdjIncoming, adjIncomingRows, "Adjustment Masuk", true) addRows(result.AdjOutgoing, adjOutgoingRows, "Adjustment Keluar", false) addRows(result.TransferIn, transferInRows, "Mutasi Masuk", true) + addRows(result.TransferOut, transferOutRows, "Mutasi Keluar", false) return result } func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.ProjectFlockKandang, start, end *time.Time, flagFilter string) ([]dto.SapronakItemDTO, []dto.SapronakGroupDTO, float64, float64, error) { - incomingRows, err := s.Repository.FetchSapronakIncoming(ctx, pfk.KandangId, start, end) + // For sapronak closing report we intentionally ignore date range + // and aggregate all historical transactions for the kandang/project. + incomingRows, err := s.Repository.FetchSapronakIncoming(ctx, pfk.KandangId) if err != nil { return nil, nil, 0, 0, err } - incomingDetailsRows, err := s.Repository.FetchSapronakIncomingDetails(ctx, pfk.KandangId, start, end) + incomingDetailsRows, err := s.Repository.FetchSapronakIncomingDetails(ctx, pfk.KandangId) if err != nil { return nil, nil, 0, 0, err } - usageRows, err := s.Repository.FetchSapronakUsage(ctx, pfk.Id, start, end) + usageRows, err := s.Repository.FetchSapronakUsage(ctx, pfk.Id) if err != nil { return nil, nil, 0, 0, err } - usageDetailsRows, err := s.Repository.FetchSapronakUsageDetails(ctx, pfk.Id, start, end) + chickinUsageRows, err := s.Repository.FetchSapronakChickinUsage(ctx, pfk.Id) if err != nil { return nil, nil, 0, 0, err } - adjIncomingRows, adjOutgoingRows, err := s.Repository.FetchSapronakAdjustments(ctx, pfk.KandangId, start, end) + usageDetailsRows, err := s.Repository.FetchSapronakUsageDetails(ctx, pfk.Id) if err != nil { return nil, nil, 0, 0, err } - transIncomingRows, _, err := s.Repository.FetchSapronakTransfers(ctx, pfk.KandangId, start, end) + chickinUsageDetailsRows, err := s.Repository.FetchSapronakChickinUsageDetails(ctx, pfk.Id) + if err != nil { + return nil, nil, 0, 0, err + } + adjIncomingRows, adjOutgoingRows, err := s.Repository.FetchSapronakAdjustments(ctx, pfk.KandangId) + if err != nil { + return nil, nil, 0, 0, err + } + transIncomingRows, transOutgoingRows, err := s.Repository.FetchSapronakTransfers(ctx, pfk.KandangId) if err != nil { return nil, nil, 0, 0, err } @@ -414,16 +362,50 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj return strings.ToUpper(f) == filterFlag } - incoming, usage := mapIncomingUsage(incomingRows, usageRows) + // For project flocks with category GROWING, pullet usage from chickin + // should not be counted yet. Only when category is LAYING we allow + // pullet usage to contribute to qty_used. + isLaying := strings.EqualFold(string(pfk.ProjectFlock.Category), string(utils.ProjectFlockCategoryLaying)) + + if !isLaying { + filteredUsage := make([]repository.SapronakUsageRow, 0, len(chickinUsageRows)) + for _, row := range chickinUsageRows { + if strings.ToUpper(row.Flag) == "DOC" { + filteredUsage = append(filteredUsage, row) + } + } + chickinUsageRows = filteredUsage + + filteredDetail := make(map[uint][]repository.SapronakDetailRow, len(chickinUsageDetailsRows)) + for pid, rows := range chickinUsageDetailsRows { + for _, d := range rows { + if strings.ToUpper(d.Flag) == "DOC" { + filteredDetail[pid] = append(filteredDetail[pid], d) + } + } + } + chickinUsageDetailsRows = filteredDetail + } + + allUsageRows := append(usageRows, chickinUsageRows...) + incoming, usage := mapIncomingUsage(incomingRows, allUsageRows) itemMap := make(map[uint]dto.SapronakItemDTO, len(incoming)+len(usage)) groupMap := make(map[string]*dto.SapronakGroupDTO) - detailMaps := buildSapronakDetails(incomingDetailsRows, usageDetailsRows, adjIncomingRows, adjOutgoingRows, transIncomingRows) + for pid, rows := range chickinUsageDetailsRows { + if len(rows) == 0 { + continue + } + usageDetailsRows[pid] = append(usageDetailsRows[pid], rows...) + } + + detailMaps := buildSapronakDetails(incomingDetailsRows, usageDetailsRows, adjIncomingRows, adjOutgoingRows, transIncomingRows, transOutgoingRows) incomingDetails := detailMaps.Incoming usageDetails := detailMaps.Usage adjIncoming := detailMaps.AdjIncoming adjOutgoing := detailMaps.AdjOutgoing transIncoming := detailMaps.TransferIn + transOutgoing := detailMaps.TransferOut ensureGroup := func(flag string) *dto.SapronakGroupDTO { if g, ok := groupMap[flag]; ok { @@ -670,6 +652,26 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj } } + for productID, details := range transOutgoing { + flag := "" + name := "" + if item, ok := itemMap[productID]; ok { + flag = item.Flag + name = item.ProductName + } + if !matchesFlag(flag) { + continue + } + group := ensureGroup(flag) + for _, d := range details { + d.Flag = flag + d.ProductName = name + group.Items = append(group.Items, d) + group.TotalKeluar += d.QtyKeluar + group.SaldoAkhir -= d.QtyKeluar + } + } + groups := make([]dto.SapronakGroupDTO, 0, len(groupMap)) for _, g := range groupMap { groups = append(groups, *g) diff --git a/internal/modules/closings/services/sapronak_formatter.go b/internal/modules/closings/services/sapronak_formatter.go deleted file mode 100644 index 036d1e64..00000000 --- a/internal/modules/closings/services/sapronak_formatter.go +++ /dev/null @@ -1,125 +0,0 @@ -package service - -import ( - "strings" - "time" - - "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/dto" -) - -type SapronakFormatter interface { - ProjectPayload(reports []dto.SapronakReportDTO, flag string) dto.SapronakProjectAggregatedDTO - KandangPayload(report *dto.SapronakReportDTO, flag string) dto.SapronakProjectAggregatedDTO -} - -type sapronakFormatter struct{} - -func NewSapronakFormatter() SapronakFormatter { - return &sapronakFormatter{} -} - -func (f *sapronakFormatter) ProjectPayload(reports []dto.SapronakReportDTO, flag string) dto.SapronakProjectAggregatedDTO { - result := dto.SapronakProjectAggregatedDTO{} - - if len(reports) == 0 { - return result - } - - rep := reports[0] - return f.mapFromReport(&rep, flag) -} - -func (f *sapronakFormatter) KandangPayload(report *dto.SapronakReportDTO, flag string) dto.SapronakProjectAggregatedDTO { - return f.mapFromReport(report, flag) -} - -func (f *sapronakFormatter) mapFromReport(report *dto.SapronakReportDTO, flag string) dto.SapronakProjectAggregatedDTO { - result := dto.SapronakProjectAggregatedDTO{} - - if report == nil { - report = &dto.SapronakReportDTO{} - } - - filter := strings.ToUpper(strings.TrimSpace(flag)) - - byFlag := map[string]**dto.SapronakCategoryDTO{} - if filter == "" || filter == "DOC" { - result.Doc = &dto.SapronakCategoryDTO{Rows: make([]dto.SapronakCategoryRowDTO, 0)} - byFlag["DOC"] = &result.Doc - } - if filter == "" || filter == "OVK" { - result.Ovk = &dto.SapronakCategoryDTO{Rows: make([]dto.SapronakCategoryRowDTO, 0),} - byFlag["OVK"] = &result.Ovk - } - if filter == "" || filter == "PAKAN" { - result.Pakan = &dto.SapronakCategoryDTO{Rows: make([]dto.SapronakCategoryRowDTO, 0),} - byFlag["PAKAN"] = &result.Pakan - } - - formatDate := func(t *time.Time) string { - if t == nil { - return "" - } - return t.Format("02-Jan-2006") - } - - for _, group := range report.Groups { - flag := strings.ToUpper(group.Flag) - ptr := byFlag[flag] - if ptr == nil || *ptr == nil { - continue - } - target := *ptr - for idx, item := range group.Items { - qtyUsed := item.QtyKeluar - if qtyUsed == 0 { - qtyUsed = item.QtyMasuk - } - - target.Rows = append(target.Rows, dto.SapronakCategoryRowDTO{ - ID: idx + 1, - Date: formatDate(item.Tanggal), - ReferenceNumber: item.NoReferensi, - QtyIn: item.QtyMasuk, - QtyOut: item.QtyKeluar, - QtyUsed: qtyUsed, - Description: item.ProductName, - ProductCategory: item.ProductName, - UnitPrice: item.Harga, - TotalAmount: item.Nilai, - Notes: "-", - }) - } - } - - buildTotals := func(cat *dto.SapronakCategoryDTO, label string) { - if cat == nil { - return - } - var qtyIn, qtyOut, qtyUsed, total float64 - for _, r := range cat.Rows { - qtyIn += r.QtyIn - qtyOut += r.QtyOut - qtyUsed += r.QtyUsed - total += r.TotalAmount - } - avg := 0.0 - if qtyIn > 0 { - avg = total / qtyIn - } - cat.Total = dto.SapronakCategoryTotalDTO{ - Label: label, - QtyIn: qtyIn, - QtyOut: qtyOut, - QtyUsed: qtyUsed, - AvgUnitPrice: avg, - TotalAmount: total, - } - } - - buildTotals(result.Doc, "TOTAL DOC") - buildTotals(result.Ovk, "TOTAL OVK") - buildTotals(result.Pakan, "TOTAL PAKAN") - - return result -} diff --git a/internal/modules/closings/validations/sapronak.validation.go b/internal/modules/closings/validations/sapronak.validation.go index 7de79399..78f64d08 100644 --- a/internal/modules/closings/validations/sapronak.validation.go +++ b/internal/modules/closings/validations/sapronak.validation.go @@ -5,5 +5,5 @@ type CountSapronakQuery struct { KandangID uint `query:"kandang_id" validate:"omitempty,gt=0"` ProjectFlockKandangID uint `query:"project_flock_kandang_id" validate:"omitempty,gt=0"` Status string `query:"status" validate:"omitempty,oneof=active closing all"` - Flag string `query:"flag" validate:"omitempty,oneof=DOC OVK PAKAN doc ovk pakan"` + Flag string `query:"flag" validate:"omitempty,oneof=DOC OVK PAKAN PULLET doc ovk pakan pullet"` } diff --git a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go index 8b33a852..2d56d458 100644 --- a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go +++ b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go @@ -27,7 +27,7 @@ type ProductWarehouseRepository interface { GetDetailByID(ctx context.Context, id uint) (*entity.ProductWarehouse, error) IdExists(ctx context.Context, id uint) (bool, error) CleanupEmpty(ctx context.Context, affected map[uint]struct{}) error - EnsureProductWarehouse(ctx context.Context, productID, warehouseID uint, createdBy uint) (uint, error) + EnsureProductWarehouse(ctx context.Context, productID, warehouseID uint, projectFlockKandangID *uint, createdBy uint) (uint, error) } type ProductWarehouseRepositoryImpl struct { @@ -199,10 +199,21 @@ func (r *ProductWarehouseRepositoryImpl) EnsureProductWarehouse( ctx context.Context, productID uint, warehouseID uint, + projectFlockKandangID *uint, createdBy uint, ) (uint, error) { record, err := r.GetProductWarehouseByProductAndWarehouseID(ctx, productID, warehouseID) if err == nil { + // Backfill project_flock_kandang_id when it's missing and caller provides one. + if projectFlockKandangID != nil && (record.ProjectFlockKandangId == nil || *record.ProjectFlockKandangId == 0) { + if err := r.DB().WithContext(ctx). + Model(&entity.ProductWarehouse{}). + Where("id = ?", record.Id). + Update("project_flock_kandang_id", *projectFlockKandangID).Error; err != nil { + return 0, err + } + record.ProjectFlockKandangId = projectFlockKandangID + } return record.Id, nil } if !errors.Is(err, gorm.ErrRecordNotFound) { @@ -210,9 +221,10 @@ func (r *ProductWarehouseRepositoryImpl) EnsureProductWarehouse( } entity := &entity.ProductWarehouse{ - ProductId: productID, - WarehouseId: warehouseID, - Quantity: 0, + ProductId: productID, + WarehouseId: warehouseID, + ProjectFlockKandangId: projectFlockKandangID, + Quantity: 0, // CreatedBy: uint(createdBy), } // if entity.CreatedBy == 0 { diff --git a/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go index 889a95be..ee690864 100644 --- a/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go +++ b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go @@ -28,6 +28,7 @@ type ProjectFlockKandangRepository interface { ProjectPeriodsByProjectIDs(ctx context.Context, projectIDs []uint) (map[uint]int, error) HasOpenNewerPeriod(ctx context.Context, kandangID uint, currentPeriod int, excludeID *uint) (bool, error) WithTx(tx *gorm.DB) ProjectFlockKandangRepository + DB() *gorm.DB IdExists(ctx context.Context, id uint) (bool, error) } diff --git a/internal/modules/purchases/repositories/purchase.repository.go b/internal/modules/purchases/repositories/purchase.repository.go index bcb35e85..9f008b0d 100644 --- a/internal/modules/purchases/repositories/purchase.repository.go +++ b/internal/modules/purchases/repositories/purchase.repository.go @@ -189,9 +189,6 @@ func (r *PurchaseRepositoryImpl) UpdateReceivingDetails( if upd.VehicleNumber != nil { data["vehicle_number"] = upd.VehicleNumber } - if upd.ReceivedQty != nil { - data["total_qty"] = upd.ReceivedQty - } if upd.WarehouseID != nil && *upd.WarehouseID != 0 { data["warehouse_id"] = upd.WarehouseID } diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index c4b6effd..64a91e9d 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -814,7 +814,13 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation // Always ensure PW when qty > 0 so stockable has target. if prep.receivedQty > 0 { - pwID, err := pwRepoTx.EnsureProductWarehouse(c.Context(), uint(item.ProductId), prep.warehouseID, purchase.CreatedBy) + pwID, err := pwRepoTx.EnsureProductWarehouse( + c.Context(), + uint(item.ProductId), + prep.warehouseID, + item.ProjectFlockKandangId, + purchase.CreatedBy, + ) if err != nil { return err }