From a8434a52463e08141f7483b48e7924ed1f600ae2 Mon Sep 17 00:00:00 2001 From: ragilap Date: Mon, 8 Dec 2025 11:28:32 +0700 Subject: [PATCH] feat/BE/US-284/TASK-,299-Create API (GET ONE in tab Perhitungan Sapronak) --- .../controllers/closing.controller.go | 63 +- internal/modules/closings/dto/sapronak.dto.go | 88 +++ internal/modules/closings/module.go | 3 +- .../repositories/closing.repository.go | 409 +++++++++++++ internal/modules/closings/route.go | 7 +- .../closings/services/closing.service.go | 2 +- .../closings/services/sapronak.service.go | 565 ++++++++++++++++++ .../closings/services/sapronak_formatter.go | 119 ++++ .../validations/sapronak.validation.go | 9 + 9 files changed, 1258 insertions(+), 7 deletions(-) create mode 100644 internal/modules/closings/dto/sapronak.dto.go create mode 100644 internal/modules/closings/services/sapronak.service.go create mode 100644 internal/modules/closings/services/sapronak_formatter.go create mode 100644 internal/modules/closings/validations/sapronak.validation.go diff --git a/internal/modules/closings/controllers/closing.controller.go b/internal/modules/closings/controllers/closing.controller.go index a9282f21..6d3ca4f4 100644 --- a/internal/modules/closings/controllers/closing.controller.go +++ b/internal/modules/closings/controllers/closing.controller.go @@ -13,12 +13,16 @@ import ( ) type ClosingController struct { - ClosingService service.ClosingService + ClosingService service.ClosingService + SapronakService service.SapronakService + SapronakFormatter service.SapronakFormatter } -func NewClosingController(closingService service.ClosingService) *ClosingController { +func NewClosingController(closingService service.ClosingService, sapronakService service.SapronakService, sapronakFormatter service.SapronakFormatter) *ClosingController { return &ClosingController{ - ClosingService: closingService, + ClosingService: closingService, + SapronakService: sapronakService, + SapronakFormatter: sapronakFormatter, } } @@ -123,3 +127,56 @@ func (u *ClosingController) GetPenjualan(c *fiber.Ctx) error { Data: dto.ToPenjualanRealisasiResponseDTO(projectFlock.Category, uint(projectFlockID), result), }) } + +func (u *ClosingController) GetSapronakByProject(c *fiber.Ctx) error { + param := c.Params("project_flock_id") + + projectID, err := strconv.Atoi(param) + if err != nil || projectID <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_id") + } + + result, err := u.SapronakService.GetSapronakByProject(c, uint(projectID)) + if err != nil { + return err + } + + payload := u.SapronakFormatter.ProjectPayload(result) + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get perhitungan sapronak per project successfully", + Data: payload, + }) +} + +func (u *ClosingController) GetSapronakByKandang(c *fiber.Ctx) error { + projectParam := c.Params("project_flock_id") + kandangParam := c.Params("project_flock_kandang_id") + + projectID, err := strconv.Atoi(projectParam) + if err != nil || projectID <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_id") + } + pfkID, err := strconv.Atoi(kandangParam) + if err != nil || pfkID <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_kandang_id") + } + + result, err := u.SapronakService.GetSapronakByKandang(c, uint(projectID), uint(pfkID)) + if err != nil { + return err + } + + payload := u.SapronakFormatter.KandangPayload(result) + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get perhitungan sapronak per kandang successfully", + Data: payload, + }) +} diff --git a/internal/modules/closings/dto/sapronak.dto.go b/internal/modules/closings/dto/sapronak.dto.go new file mode 100644 index 00000000..fdf2559a --- /dev/null +++ b/internal/modules/closings/dto/sapronak.dto.go @@ -0,0 +1,88 @@ +package dto + +import "time" + +type SapronakDetailDTO struct { + ProductID uint `json:"product_id"` + ProductName string `json:"product_name"` + Flag string `json:"flag"` + Tanggal *time.Time `json:"tanggal,omitempty"` + NoReferensi string `json:"no_referensi,omitempty"` + JenisTransaksi string `json:"jenis_transaksi,omitempty"` + QtyMasuk float64 `json:"qty_masuk"` + QtyKeluar float64 `json:"qty_keluar"` + Harga float64 `json:"harga"` + Nilai float64 `json:"nilai"` +} + +type SapronakGroupDTO struct { + Flag string `json:"flag"` + Items []SapronakDetailDTO `json:"items"` + TotalMasuk float64 `json:"total_masuk"` + TotalKeluar float64 `json:"total_keluar"` + SaldoAkhir float64 `json:"saldo_akhir"` + TotalNilai float64 `json:"total_nilai"` +} + +type SapronakItemDTO struct { + ProductID uint `json:"product_id"` + ProductName string `json:"product_name"` + Flag string `json:"flag"` + IncomingQty float64 `json:"incoming_qty"` + IncomingValue float64 `json:"incoming_value"` + UsageQty float64 `json:"usage_qty"` + UsageValue float64 `json:"usage_value"` + RemainingQty float64 `json:"remaining_qty"` + AveragePrice float64 `json:"average_price"` +} + +type SapronakReportDTO struct { + ProjectFlockKandangID uint `json:"project_flock_kandang_id"` + ProjectFlockID uint `json:"project_flock_id"` + ProjectName string `json:"project_name"` + KandangID uint `json:"kandang_id"` + KandangName string `json:"kandang_name"` + Period int `json:"period"` + Status string `json:"status"` + StartDate *time.Time `json:"start_date,omitempty"` + EndDate *time.Time `json:"end_date,omitempty"` + TotalIncomingValue float64 `json:"total_incoming_value"` + TotalUsageValue float64 `json:"total_usage_value"` + Items []SapronakItemDTO `json:"items"` + Groups []SapronakGroupDTO `json:"groups,omitempty"` +} + +// Simplified view for project-level sapronak response +type SapronakCategoryRowDTO struct { + ID int `json:"id"` + Date string `json:"date"` + ReferenceNumber string `json:"reference_number"` + QtyIn float64 `json:"qty_in"` + QtyOut float64 `json:"qty_out"` + QtyUsed float64 `json:"qty_used"` + Description string `json:"description"` + ProductCategory string `json:"product_category"` + UnitPrice float64 `json:"unit_price"` + TotalAmount float64 `json:"total_amount"` + Notes string `json:"notes"` +} + +type SapronakCategoryTotalDTO struct { + Label string `json:"label"` + QtyIn float64 `json:"qty_in"` + QtyOut float64 `json:"qty_out"` + QtyUsed float64 `json:"qty_used"` + AvgUnitPrice float64 `json:"avg_unit_price"` + TotalAmount float64 `json:"total_amount"` +} + +type SapronakCategoryDTO struct { + Rows []SapronakCategoryRowDTO `json:"rows"` + Total SapronakCategoryTotalDTO `json:"total"` +} + +type SapronakProjectAggregatedDTO struct { + Doc SapronakCategoryDTO `json:"doc"` + Ovk SapronakCategoryDTO `json:"ovk"` + Pakan SapronakCategoryDTO `json:"pakan"` +} diff --git a/internal/modules/closings/module.go b/internal/modules/closings/module.go index 77941256..9ca91447 100644 --- a/internal/modules/closings/module.go +++ b/internal/modules/closings/module.go @@ -28,7 +28,8 @@ func (ClosingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * approvalService := commonSvc.NewApprovalService(approvalRepo) closingService := sClosing.NewClosingService(closingRepo, projectFlockRepo, marketingRepo, marketingDeliveryProductRepo, approvalService, validate) + sapronakService := sClosing.NewSapronakService(closingRepo, validate) userService := sUser.NewUserService(userRepo, validate) - ClosingRoutes(router, userService, closingService) + ClosingRoutes(router, userService, closingService, sapronakService) } diff --git a/internal/modules/closings/repositories/closing.repository.go b/internal/modules/closings/repositories/closing.repository.go index 946797fd..88c7da41 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -1,13 +1,27 @@ package repository import ( + "context" + "fmt" + "time" + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" "gorm.io/gorm" ) type ClosingRepository interface { repository.BaseRepository[entity.ProjectFlock] + ListProjectFlockKandangsForSapronak(ctx context.Context, params *validation.SapronakQuery) ([]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) } type ClosingRepositoryImpl struct { @@ -19,3 +33,398 @@ func NewClosingRepository(db *gorm.DB) ClosingRepository { BaseRepositoryImpl: repository.NewBaseRepository[entity.ProjectFlock](db), } } + +type SapronakIncomingRow struct { + ProductID uint + ProductName string + Flag string + Qty float64 + Value float64 + DefaultPrice float64 +} + +type SapronakUsageRow struct { + ProductID uint + ProductName string + Flag string + Qty float64 + DefaultPrice float64 +} + +type SapronakDetailRow struct { + ProductID uint + ProductName string + Flag string + Date *time.Time + Reference string + QtyIn float64 + QtyOut float64 + Price float64 +} + +func (r *ClosingRepositoryImpl) ListProjectFlockKandangsForSapronak(ctx context.Context, params *validation.SapronakQuery) ([]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) + } + } + + var pfks []entity.ProjectFlockKandang + if err := db.Find(&pfks).Error; err != nil { + return nil, err + } + return pfks, nil +} + +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 + } + + 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 + } + + for _, row := range rows { + if row.StartDate != nil { + result[row.ProjectFlockKandangID] = row.StartDate.UTC() + } + } + + return result, nil +} + +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) { + 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 +} + +func (r *ClosingRepositoryImpl) FetchSapronakUsageDetails(ctx context.Context, pfkID uint, start, end *time.Time) (map[uint][]SapronakDetailRow, error) { + rows := make([]SapronakDetailRow, 0) + + 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, + r.record_datetime AS date, + CAST(r.id AS TEXT) AS reference, + 0 AS qty_in, + COALESCE(rs.usage_qty,0) AS qty_out, + COALESCE(p.product_price,0) AS 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.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 +} + +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 + } + + for _, row := range rows { + ref := fmt.Sprintf("ADJ-%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 +} + +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 { + 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 +} diff --git a/internal/modules/closings/route.go b/internal/modules/closings/route.go index ba18f3b9..eca546a2 100644 --- a/internal/modules/closings/route.go +++ b/internal/modules/closings/route.go @@ -9,8 +9,9 @@ import ( "github.com/gofiber/fiber/v2" ) -func ClosingRoutes(v1 fiber.Router, u user.UserService, s closing.ClosingService) { - ctrl := controller.NewClosingController(s) +func ClosingRoutes(v1 fiber.Router, u user.UserService, s closing.ClosingService, sapronakSvc closing.SapronakService) { + formatter := closing.NewSapronakFormatter() + ctrl := controller.NewClosingController(s, sapronakSvc, formatter) route := v1.Group("/closing") @@ -22,5 +23,7 @@ func ClosingRoutes(v1 fiber.Router, u user.UserService, s closing.ClosingService route.Get("/", ctrl.GetAll) route.Get("/:project_flock_id/penjualan", ctrl.GetPenjualan) + route.Get("/:project_flock_id/:project_flock_kandang_id/perhitungan_sapronak", ctrl.GetSapronakByKandang) + route.Get("/:project_flock_id/perhitungan_sapronak", ctrl.GetSapronakByProject) route.Get("/:projectFlockId", ctrl.GetClosingSummary) } diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index 7fcd51ec..79bbfd24 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -165,7 +165,7 @@ func (s closingService) getApprovalStatuses(ctx context.Context, projectFlockID minStep = rec.StepNumber statusProject = rec.StepName } - if rec.StepNumber == uint16(utils.ProjectFlockStepSelesai) { + if rec.StepNumber == uint16(utils.ProjectFlockStepAktif) { completed++ } } diff --git a/internal/modules/closings/services/sapronak.service.go b/internal/modules/closings/services/sapronak.service.go new file mode 100644 index 00000000..dca4c373 --- /dev/null +++ b/internal/modules/closings/services/sapronak.service.go @@ -0,0 +1,565 @@ +package service + +import ( + "context" + "strings" + "time" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "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" + "gitlab.com/mbugroup/lti-api.git/internal/utils" +) + +type SapronakService interface { + GetSapronakByProject(ctx *fiber.Ctx, projectFlockID uint) ([]dto.SapronakReportDTO, error) + GetSapronakByKandang(ctx *fiber.Ctx, projectFlockID uint, pfkID uint) (*dto.SapronakReportDTO, error) + GetSapronakReport(ctx *fiber.Ctx, params *validation.SapronakQuery) ([]dto.SapronakReportDTO, error) +} + +type sapronakService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.ClosingRepository +} + +func NewSapronakService(repo repository.ClosingRepository, validate *validator.Validate) SapronakService { + return &sapronakService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + } +} + +func (s sapronakService) GetSapronakReport(c *fiber.Ctx, params *validation.SapronakQuery) ([]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) ([]dto.SapronakReportDTO, error) { + if projectFlockID == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_id is required") + } + reports, err := s.computeSapronakReports(c.Context(), &validation.SapronakQuery{ + ProjectFlockID: projectFlockID, + Status: "all", + }) + if err != nil { + return nil, err + } + if len(reports) <= 1 { + return reports, nil + } + + combined := s.combineSapronakReports(reports, projectFlockID) + return []dto.SapronakReportDTO{combined}, nil +} + +func (s sapronakService) GetSapronakByKandang(c *fiber.Ctx, projectFlockID uint, pfkID uint) (*dto.SapronakReportDTO, error) { + if projectFlockID == 0 || pfkID == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_id and project_flock_kandang_id are required") + } + + results, err := s.computeSapronakReports(c.Context(), &validation.SapronakQuery{ + ProjectFlockID: projectFlockID, + ProjectFlockKandangID: pfkID, + Status: "all", + }) + if err != nil { + return nil, err + } + + for _, res := range results { + if res.ProjectFlockID == projectFlockID && res.ProjectFlockKandangID == pfkID { + return &res, nil + } + } + + return nil, fiber.NewError(fiber.StatusNotFound, "Sapronak for kandang not found") +} + +func (s sapronakService) computeSapronakReports(ctx context.Context, params *validation.SapronakQuery) ([]dto.SapronakReportDTO, error) { + pfks, err := s.loadProjectFlockKandangs(ctx, params) + if err != nil { + return nil, err + } + if len(pfks) == 0 { + 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" + } + + results := make([]dto.SapronakReportDTO, 0, len(pfks)) + for _, pfk := range pfks { + status := statusMap[pfk.Id] + if status == "" { + status = "closing" + } + + 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) + 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") + } + + results = append(results, dto.SapronakReportDTO{ + ProjectFlockKandangID: pfk.Id, + ProjectFlockID: pfk.ProjectFlockId, + ProjectName: pfk.ProjectFlock.FlockName, + KandangID: pfk.KandangId, + KandangName: pfk.Kandang.Name, + Period: pfk.Period, + Status: status, + StartDate: startPtr, + EndDate: endPtr, + TotalIncomingValue: totalIncoming, + TotalUsageValue: totalUsage, + Items: items, + Groups: groups, + }) + } + + return results, nil +} + +func (s sapronakService) loadProjectFlockKandangs(ctx context.Context, params *validation.SapronakQuery) ([]entity.ProjectFlockKandang, error) { + pfks, err := s.Repository.ListProjectFlockKandangsForSapronak(ctx, params) + if 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{} + } + + var ( + totalIncoming float64 + totalUsage float64 + earliestStart *time.Time + projectName = reports[0].ProjectName + ) + + itemMap := make(map[uint]dto.SapronakItemDTO) + groupMap := make(map[string]*dto.SapronakGroupDTO) + + ensureGroup := func(flag string) *dto.SapronakGroupDTO { + if g, ok := groupMap[flag]; ok { + return g + } + groupMap[flag] = &dto.SapronakGroupDTO{Flag: flag} + return groupMap[flag] + } + + 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] + if cur.ProductID == 0 { + cur.ProductID = it.ProductID + cur.ProductName = it.ProductName + cur.Flag = it.Flag + } + cur.IncomingQty += it.IncomingQty + cur.IncomingValue += it.IncomingValue + cur.UsageQty += it.UsageQty + cur.UsageValue += it.UsageValue + if cur.IncomingQty >= cur.UsageQty { + cur.RemainingQty = cur.IncomingQty - cur.UsageQty + } else { + cur.RemainingQty = 0 + } + if cur.IncomingQty > 0 { + cur.AveragePrice = cur.IncomingValue / cur.IncomingQty + } else { + cur.AveragePrice = it.AveragePrice + } + itemMap[it.ProductID] = cur + } + + for _, g := range r.Groups { + agg := ensureGroup(g.Flag) + agg.TotalMasuk += g.TotalMasuk + agg.TotalKeluar += g.TotalKeluar + agg.SaldoAkhir += g.SaldoAkhir + agg.TotalNilai += g.TotalNilai + agg.Items = append(agg.Items, g.Items...) + } + } + + items := make([]dto.SapronakItemDTO, 0, len(itemMap)) + for _, it := range itemMap { + items = append(items, it) + } + + groups := make([]dto.SapronakGroupDTO, 0, len(groupMap)) + for _, g := range groupMap { + groups = append(groups, *g) + } + + return dto.SapronakReportDTO{ + ProjectFlockID: projectID, + ProjectName: projectName, + Status: "combined", + StartDate: earliestStart, + TotalIncomingValue: totalIncoming, + TotalUsageValue: totalUsage, + Items: items, + Groups: groups, + } +} + +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 (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.ProjectFlockKandang, start, end *time.Time) ([]dto.SapronakItemDTO, []dto.SapronakGroupDTO, float64, float64, error) { + incomingRows, err := s.Repository.FetchSapronakIncoming(ctx, pfk.KandangId, start, end) + if err != nil { + return nil, nil, 0, 0, err + } + incomingDetailsRows, err := s.Repository.FetchSapronakIncomingDetails(ctx, pfk.KandangId, start, end) + if err != nil { + return nil, nil, 0, 0, err + } + usageRows, err := s.Repository.FetchSapronakUsage(ctx, pfk.Id, start, end) + if err != nil { + return nil, nil, 0, 0, err + } + usageDetailsRows, err := s.Repository.FetchSapronakUsageDetails(ctx, pfk.Id, start, end) + if err != nil { + return nil, nil, 0, 0, err + } + adjIncomingRows, adjOutgoingRows, err := s.Repository.FetchSapronakAdjustments(ctx, pfk.KandangId, start, end) + if err != nil { + return nil, nil, 0, 0, err + } + transIncomingRows, _, err := s.Repository.FetchSapronakTransfers(ctx, pfk.KandangId, start, end) + if err != nil { + return nil, nil, 0, 0, err + } + + incoming, usage := mapIncomingUsage(incomingRows, usageRows) + itemMap := make(map[uint]dto.SapronakItemDTO, len(incoming)+len(usage)) + groupMap := make(map[string]*dto.SapronakGroupDTO) + details := buildSapronakDetails(incomingDetailsRows, usageDetailsRows, adjIncomingRows, adjOutgoingRows, transIncomingRows) + + ensureGroup := func(flag string) *dto.SapronakGroupDTO { + if g, ok := groupMap[flag]; ok { + return g + } + groupMap[flag] = &dto.SapronakGroupDTO{Flag: flag} + return groupMap[flag] + } + + for _, row := range incoming { + avgPrice := row.DefaultPrice + if row.Qty > 0 && row.Value > 0 { + avgPrice = row.Value / row.Qty + } + + itemMap[row.ProductID] = dto.SapronakItemDTO{ + ProductID: row.ProductID, + ProductName: row.ProductName, + Flag: row.Flag, + IncomingQty: row.Qty, + IncomingValue: row.Value, + RemainingQty: row.Qty, + AveragePrice: avgPrice, + } + } + + for _, row := range usage { + existing := itemMap[row.ProductID] + price := existing.AveragePrice + if price == 0 { + price = row.DefaultPrice + } + + usageValue := row.Qty * price + + existing.ProductID = row.ProductID + if existing.ProductName == "" { + existing.ProductName = row.ProductName + } + if existing.Flag == "" { + existing.Flag = row.Flag + } + existing.AveragePrice = price + existing.UsageQty += row.Qty + existing.UsageValue += usageValue + if existing.IncomingQty >= existing.UsageQty { + existing.RemainingQty = existing.IncomingQty - existing.UsageQty + } else { + existing.RemainingQty = 0 + } + + itemMap[row.ProductID] = existing + } + + for productID, details := range adjIncoming { + for _, d := range details { + existing := itemMap[productID] + if existing.Flag == "" { + existing.Flag = d.Flag + } + if existing.ProductName == "" { + existing.ProductName = d.ProductName + } + existing.IncomingQty += d.QtyMasuk + existing.IncomingValue += d.Nilai + if existing.IncomingQty > 0 { + existing.AveragePrice = existing.IncomingValue / existing.IncomingQty + } + if existing.IncomingQty >= existing.UsageQty { + existing.RemainingQty = existing.IncomingQty - existing.UsageQty + } else { + existing.RemainingQty = 0 + } + itemMap[productID] = existing + } + } + + for productID, details := range adjOutgoing { + for _, d := range details { + existing := itemMap[productID] + if existing.Flag == "" { + existing.Flag = d.Flag + } + if existing.ProductName == "" { + existing.ProductName = d.ProductName + } + existing.UsageQty += d.QtyKeluar + existing.UsageValue += d.Nilai + if existing.IncomingQty >= existing.UsageQty { + existing.RemainingQty = existing.IncomingQty - existing.UsageQty + } else { + existing.RemainingQty = 0 + } + itemMap[productID] = existing + } + } + + for productID, details := range transIncoming { + for _, d := range details { + existing := itemMap[productID] + if existing.Flag == "" { + existing.Flag = d.Flag + } + if existing.ProductName == "" { + existing.ProductName = d.ProductName + } + existing.IncomingQty += d.QtyMasuk + existing.IncomingValue += d.Nilai + if existing.IncomingQty > 0 { + existing.AveragePrice = existing.IncomingValue / existing.IncomingQty + } + if existing.IncomingQty >= existing.UsageQty { + existing.RemainingQty = existing.IncomingQty - existing.UsageQty + } else { + existing.RemainingQty = 0 + } + itemMap[productID] = existing + } + } + + items := make([]dto.SapronakItemDTO, 0, len(itemMap)) + var totalIncoming, totalUsage float64 + for _, item := range itemMap { + totalIncoming += item.IncomingValue + totalUsage += item.UsageValue + items = append(items, item) + } + + for productID, details := range incomingDetails { + flag := "" + name := "" + if item, ok := itemMap[productID]; ok { + flag = item.Flag + name = item.ProductName + } + group := ensureGroup(flag) + for _, d := range details { + d.Flag = flag + d.ProductName = name + group.Items = append(group.Items, d) + group.TotalMasuk += d.QtyMasuk + group.TotalNilai += d.Nilai + group.SaldoAkhir += d.QtyMasuk + } + } + + for productID, details := range adjIncoming { + flag := "" + name := "" + if item, ok := itemMap[productID]; ok { + flag = item.Flag + name = item.ProductName + } + group := ensureGroup(flag) + for _, d := range details { + d.Flag = flag + d.ProductName = name + group.Items = append(group.Items, d) + group.TotalMasuk += d.QtyMasuk + group.TotalNilai += d.Nilai + group.SaldoAkhir += d.QtyMasuk + } + } + + for productID, details := range usageDetails { + flag := "" + name := "" + if item, ok := itemMap[productID]; ok { + flag = item.Flag + name = item.ProductName + } + 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 + } + } + + for productID, details := range adjOutgoing { + flag := "" + name := "" + if item, ok := itemMap[productID]; ok { + flag = item.Flag + name = item.ProductName + } + 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 + } + } + + for productID, details := range transIncoming { + flag := "" + name := "" + if item, ok := itemMap[productID]; ok { + flag = item.Flag + name = item.ProductName + } + group := ensureGroup(flag) + for _, d := range details { + d.Flag = flag + d.ProductName = name + group.Items = append(group.Items, d) + group.TotalMasuk += d.QtyMasuk + group.TotalNilai += d.Nilai + group.SaldoAkhir += d.QtyMasuk + } + } + + groups := make([]dto.SapronakGroupDTO, 0, len(groupMap)) + for _, g := range groupMap { + groups = append(groups, *g) + } + + return items, groups, totalIncoming, totalUsage, nil +} diff --git a/internal/modules/closings/services/sapronak_formatter.go b/internal/modules/closings/services/sapronak_formatter.go new file mode 100644 index 00000000..ce4b5ca2 --- /dev/null +++ b/internal/modules/closings/services/sapronak_formatter.go @@ -0,0 +1,119 @@ +package service + +import ( + "strings" + "time" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/dto" +) + +type SapronakFormatter interface { + ProjectPayload(reports []dto.SapronakReportDTO) dto.SapronakProjectAggregatedDTO + KandangPayload(report *dto.SapronakReportDTO) dto.SapronakProjectAggregatedDTO +} + +type sapronakFormatter struct{} + +func NewSapronakFormatter() SapronakFormatter { + return &sapronakFormatter{} +} + +func (f *sapronakFormatter) ProjectPayload(reports []dto.SapronakReportDTO) dto.SapronakProjectAggregatedDTO { + result := dto.SapronakProjectAggregatedDTO{ + Doc: dto.SapronakCategoryDTO{}, + Ovk: dto.SapronakCategoryDTO{}, + Pakan: dto.SapronakCategoryDTO{}, + } + + if len(reports) == 0 { + return result + } + + rep := reports[0] + return f.mapFromReport(&rep) +} + +func (f *sapronakFormatter) KandangPayload(report *dto.SapronakReportDTO) dto.SapronakProjectAggregatedDTO { + return f.mapFromReport(report) +} + +func (f *sapronakFormatter) mapFromReport(report *dto.SapronakReportDTO) dto.SapronakProjectAggregatedDTO { + result := dto.SapronakProjectAggregatedDTO{ + Doc: dto.SapronakCategoryDTO{}, + Ovk: dto.SapronakCategoryDTO{}, + Pakan: dto.SapronakCategoryDTO{}, + } + + if report == nil { + return result + } + + byFlag := map[string]*dto.SapronakCategoryDTO{ + "DOC": &result.Doc, + "OVK": &result.Ovk, + "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) + target := byFlag[flag] + if target == nil { + continue + } + 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) { + 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 new file mode 100644 index 00000000..3656e854 --- /dev/null +++ b/internal/modules/closings/validations/sapronak.validation.go @@ -0,0 +1,9 @@ +package validation + +type SapronakQuery struct { + ProjectFlockID uint `query:"project_flock_id" validate:"omitempty,gt=0"` + 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"` + Debug bool `query:"debug"` +}