From a8434a52463e08141f7483b48e7924ed1f600ae2 Mon Sep 17 00:00:00 2001 From: ragilap Date: Mon, 8 Dec 2025 11:28:32 +0700 Subject: [PATCH 01/12] 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"` +} From e0e2d91db53b26d5242cec5421c451864880cb04 Mon Sep 17 00:00:00 2001 From: ragilap Date: Mon, 8 Dec 2025 13:40:37 +0700 Subject: [PATCH 02/12] feat/BE/US-284/TASK-,299-Create API (GET ONE in tab Perhitungan Sapronak),add filtering by flag --- .../controllers/closing.controller.go | 10 +- internal/modules/closings/dto/sapronak.dto.go | 6 +- .../closings/services/sapronak.service.go | 128 +++++++++++++++++- .../closings/services/sapronak_formatter.go | 58 ++++---- .../validations/sapronak.validation.go | 2 +- 5 files changed, 163 insertions(+), 41 deletions(-) diff --git a/internal/modules/closings/controllers/closing.controller.go b/internal/modules/closings/controllers/closing.controller.go index 6d3ca4f4..47b18ace 100644 --- a/internal/modules/closings/controllers/closing.controller.go +++ b/internal/modules/closings/controllers/closing.controller.go @@ -130,18 +130,19 @@ func (u *ClosingController) GetPenjualan(c *fiber.Ctx) error { func (u *ClosingController) GetSapronakByProject(c *fiber.Ctx) error { param := c.Params("project_flock_id") + flag := c.Query("flag", "") 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)) + result, err := u.SapronakService.GetSapronakByProject(c, uint(projectID), flag) if err != nil { return err } - payload := u.SapronakFormatter.ProjectPayload(result) + payload := u.SapronakFormatter.ProjectPayload(result, flag) return c.Status(fiber.StatusOK). JSON(response.Success{ @@ -155,6 +156,7 @@ func (u *ClosingController) GetSapronakByProject(c *fiber.Ctx) error { func (u *ClosingController) GetSapronakByKandang(c *fiber.Ctx) error { projectParam := c.Params("project_flock_id") kandangParam := c.Params("project_flock_kandang_id") + flag := c.Query("flag", "") projectID, err := strconv.Atoi(projectParam) if err != nil || projectID <= 0 { @@ -165,12 +167,12 @@ func (u *ClosingController) GetSapronakByKandang(c *fiber.Ctx) error { return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_kandang_id") } - result, err := u.SapronakService.GetSapronakByKandang(c, uint(projectID), uint(pfkID)) + result, err := u.SapronakService.GetSapronakByKandang(c, uint(projectID), uint(pfkID), flag) if err != nil { return err } - payload := u.SapronakFormatter.KandangPayload(result) + payload := u.SapronakFormatter.KandangPayload(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 fdf2559a..edb6bc88 100644 --- a/internal/modules/closings/dto/sapronak.dto.go +++ b/internal/modules/closings/dto/sapronak.dto.go @@ -82,7 +82,7 @@ type SapronakCategoryDTO struct { } type SapronakProjectAggregatedDTO struct { - Doc SapronakCategoryDTO `json:"doc"` - Ovk SapronakCategoryDTO `json:"ovk"` - Pakan SapronakCategoryDTO `json:"pakan"` + Doc *SapronakCategoryDTO `json:"doc,omitempty"` + Ovk *SapronakCategoryDTO `json:"ovk,omitempty"` + Pakan *SapronakCategoryDTO `json:"pakan,omitempty"` } diff --git a/internal/modules/closings/services/sapronak.service.go b/internal/modules/closings/services/sapronak.service.go index dca4c373..31952479 100644 --- a/internal/modules/closings/services/sapronak.service.go +++ b/internal/modules/closings/services/sapronak.service.go @@ -17,8 +17,8 @@ import ( ) type SapronakService interface { - GetSapronakByProject(ctx *fiber.Ctx, projectFlockID uint) ([]dto.SapronakReportDTO, error) - GetSapronakByKandang(ctx *fiber.Ctx, projectFlockID uint, pfkID uint) (*dto.SapronakReportDTO, error) + 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.SapronakQuery) ([]dto.SapronakReportDTO, error) } @@ -43,13 +43,14 @@ func (s sapronakService) GetSapronakReport(c *fiber.Ctx, params *validation.Sapr return s.computeSapronakReports(c.Context(), params) } -func (s sapronakService) GetSapronakByProject(c *fiber.Ctx, projectFlockID uint) ([]dto.SapronakReportDTO, error) { +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") } reports, err := s.computeSapronakReports(c.Context(), &validation.SapronakQuery{ ProjectFlockID: projectFlockID, Status: "all", + Flag: flag, }) if err != nil { return nil, err @@ -62,7 +63,7 @@ func (s sapronakService) GetSapronakByProject(c *fiber.Ctx, projectFlockID uint) return []dto.SapronakReportDTO{combined}, nil } -func (s sapronakService) GetSapronakByKandang(c *fiber.Ctx, projectFlockID uint, pfkID uint) (*dto.SapronakReportDTO, error) { +func (s sapronakService) GetSapronakByKandang(c *fiber.Ctx, projectFlockID uint, pfkID uint, flag string) (*dto.SapronakReportDTO, error) { if projectFlockID == 0 || pfkID == 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_id and project_flock_kandang_id are required") } @@ -71,6 +72,7 @@ func (s sapronakService) GetSapronakByKandang(c *fiber.Ctx, projectFlockID uint, ProjectFlockID: projectFlockID, ProjectFlockKandangID: pfkID, Status: "all", + Flag: flag, }) if err != nil { return nil, err @@ -130,7 +132,7 @@ func (s sapronakService) computeSapronakReports(ctx context.Context, params *val endPtr = &endCopy } - items, groups, totalIncoming, totalUsage, err := s.buildSapronakItems(ctx, pfk, startPtr, endPtr) + items, groups, totalIncoming, totalUsage, err := s.buildSapronakItems(ctx, pfk, startPtr, endPtr, 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") @@ -310,7 +312,75 @@ func (s sapronakService) computeStatusAndNextStart(pfks []entity.ProjectFlockKan return statusMap, nextStartMap } -func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.ProjectFlockKandang, start, end *time.Time) ([]dto.SapronakItemDTO, []dto.SapronakGroupDTO, float64, float64, error) { +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 { + incoming[row.ProductID] = row + } + usage := make(map[uint]repository.SapronakUsageRow, len(usageRows)) + for _, row := range usageRows { + usage[row.ProductID] = row + } + return incoming, usage +} + +type sapronakDetailMaps struct { + Incoming map[uint][]dto.SapronakDetailDTO + Usage map[uint][]dto.SapronakDetailDTO + AdjIncoming map[uint][]dto.SapronakDetailDTO + AdjOutgoing map[uint][]dto.SapronakDetailDTO + TransferIn map[uint][]dto.SapronakDetailDTO +} + +func buildSapronakDetails( + incomingRows map[uint][]repository.SapronakDetailRow, + usageRows map[uint][]repository.SapronakDetailRow, + adjIncomingRows map[uint][]repository.SapronakDetailRow, + adjOutgoingRows map[uint][]repository.SapronakDetailRow, + transferInRows map[uint][]repository.SapronakDetailRow, +) sapronakDetailMaps { + result := sapronakDetailMaps{ + Incoming: make(map[uint][]dto.SapronakDetailDTO), + Usage: make(map[uint][]dto.SapronakDetailDTO), + AdjIncoming: make(map[uint][]dto.SapronakDetailDTO), + AdjOutgoing: make(map[uint][]dto.SapronakDetailDTO), + TransferIn: make(map[uint][]dto.SapronakDetailDTO), + } + + addRows := func(target map[uint][]dto.SapronakDetailDTO, src map[uint][]repository.SapronakDetailRow, jenis string, masuk bool) { + for pid, rows := range src { + for _, r := range rows { + d := dto.SapronakDetailDTO{ + ProductID: r.ProductID, + ProductName: r.ProductName, + Flag: r.Flag, + Tanggal: r.Date, + NoReferensi: r.Reference, + JenisTransaksi: jenis, + Harga: r.Price, + } + if masuk { + d.QtyMasuk = r.QtyIn + d.Nilai = r.QtyIn * r.Price + } else { + d.QtyKeluar = r.QtyOut + d.Nilai = r.QtyOut * r.Price + } + target[pid] = append(target[pid], d) + } + } + } + + addRows(result.Incoming, incomingRows, "Pembelian", true) + addRows(result.Usage, usageRows, "Pemakaian", false) + addRows(result.AdjIncoming, adjIncomingRows, "Adjustment Masuk", true) + addRows(result.AdjOutgoing, adjOutgoingRows, "Adjustment Keluar", false) + addRows(result.TransferIn, transferInRows, "Mutasi Masuk", true) + + 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) if err != nil { return nil, nil, 0, 0, err @@ -336,10 +406,24 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj return nil, nil, 0, 0, err } + filterFlag := strings.ToUpper(strings.TrimSpace(flagFilter)) + matchesFlag := func(f string) bool { + if filterFlag == "" { + return true + } + return strings.ToUpper(f) == filterFlag + } + 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) + + detailMaps := buildSapronakDetails(incomingDetailsRows, usageDetailsRows, adjIncomingRows, adjOutgoingRows, transIncomingRows) + incomingDetails := detailMaps.Incoming + usageDetails := detailMaps.Usage + adjIncoming := detailMaps.AdjIncoming + adjOutgoing := detailMaps.AdjOutgoing + transIncoming := detailMaps.TransferIn ensureGroup := func(flag string) *dto.SapronakGroupDTO { if g, ok := groupMap[flag]; ok { @@ -350,6 +434,9 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj } for _, row := range incoming { + if !matchesFlag(row.Flag) { + continue + } avgPrice := row.DefaultPrice if row.Qty > 0 && row.Value > 0 { avgPrice = row.Value / row.Qty @@ -367,6 +454,9 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj } for _, row := range usage { + if !matchesFlag(row.Flag) { + continue + } existing := itemMap[row.ProductID] price := existing.AveragePrice if price == 0 { @@ -396,6 +486,9 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj for productID, details := range adjIncoming { for _, d := range details { + if !matchesFlag(d.Flag) { + continue + } existing := itemMap[productID] if existing.Flag == "" { existing.Flag = d.Flag @@ -419,6 +512,9 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj for productID, details := range adjOutgoing { for _, d := range details { + if !matchesFlag(d.Flag) { + continue + } existing := itemMap[productID] if existing.Flag == "" { existing.Flag = d.Flag @@ -439,6 +535,9 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj for productID, details := range transIncoming { for _, d := range details { + if !matchesFlag(d.Flag) { + continue + } existing := itemMap[productID] if existing.Flag == "" { existing.Flag = d.Flag @@ -475,6 +574,9 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj flag = item.Flag name = item.ProductName } + if !matchesFlag(flag) { + continue + } group := ensureGroup(flag) for _, d := range details { d.Flag = flag @@ -493,6 +595,9 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj flag = item.Flag name = item.ProductName } + if !matchesFlag(flag) { + continue + } group := ensureGroup(flag) for _, d := range details { d.Flag = flag @@ -511,6 +616,9 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj flag = item.Flag name = item.ProductName } + if !matchesFlag(flag) { + continue + } group := ensureGroup(flag) for _, d := range details { d.Flag = flag @@ -528,6 +636,9 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj flag = item.Flag name = item.ProductName } + if !matchesFlag(flag) { + continue + } group := ensureGroup(flag) for _, d := range details { d.Flag = flag @@ -545,6 +656,9 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj flag = item.Flag name = item.ProductName } + if !matchesFlag(flag) { + continue + } group := ensureGroup(flag) for _, d := range details { d.Flag = flag diff --git a/internal/modules/closings/services/sapronak_formatter.go b/internal/modules/closings/services/sapronak_formatter.go index ce4b5ca2..880d2149 100644 --- a/internal/modules/closings/services/sapronak_formatter.go +++ b/internal/modules/closings/services/sapronak_formatter.go @@ -8,8 +8,8 @@ import ( ) type SapronakFormatter interface { - ProjectPayload(reports []dto.SapronakReportDTO) dto.SapronakProjectAggregatedDTO - KandangPayload(report *dto.SapronakReportDTO) dto.SapronakProjectAggregatedDTO + ProjectPayload(reports []dto.SapronakReportDTO, flag string) dto.SapronakProjectAggregatedDTO + KandangPayload(report *dto.SapronakReportDTO, flag string) dto.SapronakProjectAggregatedDTO } type sapronakFormatter struct{} @@ -18,40 +18,42 @@ 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{}, - } +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) + return f.mapFromReport(&rep, flag) } -func (f *sapronakFormatter) KandangPayload(report *dto.SapronakReportDTO) dto.SapronakProjectAggregatedDTO { - return f.mapFromReport(report) +func (f *sapronakFormatter) KandangPayload(report *dto.SapronakReportDTO, flag string) dto.SapronakProjectAggregatedDTO { + return f.mapFromReport(report, flag) } -func (f *sapronakFormatter) mapFromReport(report *dto.SapronakReportDTO) dto.SapronakProjectAggregatedDTO { - result := dto.SapronakProjectAggregatedDTO{ - Doc: dto.SapronakCategoryDTO{}, - Ovk: dto.SapronakCategoryDTO{}, - Pakan: dto.SapronakCategoryDTO{}, - } +func (f *sapronakFormatter) mapFromReport(report *dto.SapronakReportDTO, flag string) dto.SapronakProjectAggregatedDTO { + result := dto.SapronakProjectAggregatedDTO{} if report == nil { return result } - byFlag := map[string]*dto.SapronakCategoryDTO{ - "DOC": &result.Doc, - "OVK": &result.Ovk, - "PAKAN": &result.Pakan, + filter := strings.ToUpper(strings.TrimSpace(flag)) + + byFlag := map[string]**dto.SapronakCategoryDTO{} + if filter == "" || filter == "DOC" { + result.Doc = &dto.SapronakCategoryDTO{} + byFlag["DOC"] = &result.Doc + } + if filter == "" || filter == "OVK" { + result.Ovk = &dto.SapronakCategoryDTO{} + byFlag["OVK"] = &result.Ovk + } + if filter == "" || filter == "PAKAN" { + result.Pakan = &dto.SapronakCategoryDTO{} + byFlag["PAKAN"] = &result.Pakan } formatDate := func(t *time.Time) string { @@ -63,10 +65,11 @@ func (f *sapronakFormatter) mapFromReport(report *dto.SapronakReportDTO) dto.Sap for _, group := range report.Groups { flag := strings.ToUpper(group.Flag) - target := byFlag[flag] - if target == nil { + ptr := byFlag[flag] + if ptr == nil || *ptr == nil { continue } + target := *ptr for idx, item := range group.Items { qtyUsed := item.QtyKeluar if qtyUsed == 0 { @@ -90,6 +93,9 @@ func (f *sapronakFormatter) mapFromReport(report *dto.SapronakReportDTO) dto.Sap } 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 @@ -111,9 +117,9 @@ func (f *sapronakFormatter) mapFromReport(report *dto.SapronakReportDTO) dto.Sap } } - buildTotals(&result.Doc, "TOTAL DOC") - buildTotals(&result.Ovk, "TOTAL OVK") - buildTotals(&result.Pakan, "TOTAL PAKAN") + 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 3656e854..1f2ca54f 100644 --- a/internal/modules/closings/validations/sapronak.validation.go +++ b/internal/modules/closings/validations/sapronak.validation.go @@ -5,5 +5,5 @@ type SapronakQuery 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"` - Debug bool `query:"debug"` + Flag string `query:"flag" validate:"omitempty,oneof=DOC OVK PAKAN doc ovk pakan"` } From c062d838e0a61597c32d54db088f186a74d575e4 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Thu, 11 Dec 2025 09:26:24 +0700 Subject: [PATCH 03/12] Fix[BE]: fix 500 API Loookup project flock --- .../repositories/product_warehouse.repository.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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..ee92f6ab 100644 --- a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go +++ b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go @@ -93,7 +93,7 @@ func (r *ProductWarehouseRepositoryImpl) GetByCategoryCodeAndWarehouseID(ctx con Joins("JOIN products ON products.id = product_warehouses.product_id"). Joins("JOIN product_categories ON product_categories.id = products.product_category_id"). Where("product_categories.code = ? AND product_warehouses.warehouse_id = ?", categoryCode, warehouseId). - Order("product_warehouses.created_at DESC") + Order("product_warehouses.id DESC") // preload relations so nested Product and Warehouse are populated err := q.Preload("Product").Preload("Warehouse").Find(&productWarehouses).Error @@ -115,7 +115,7 @@ func (r *ProductWarehouseRepositoryImpl) GetLatestByCategoryCodeAndWarehouseID(c Joins("JOIN products ON products.id = product_warehouses.product_id"). Joins("JOIN product_categories ON product_categories.id = products.product_category_id"). Where("product_categories.code = ? AND product_warehouses.warehouse_id = ?", categoryCode, warehouseId). - Order("product_warehouses.created_at DESC"). + Order("product_warehouses.id DESC"). Preload("Product").Preload("Warehouse"). First(&productWarehouse).Error if err != nil { From 3ada837b8b557f13c94af2b45ea4394ae6528435 Mon Sep 17 00:00:00 2001 From: ragilap Date: Thu, 11 Dec 2025 09:38:20 +0700 Subject: [PATCH 04/12] feat/BE/US-284/TASK-289-Create API (GET ONE in tab Perhitungan Sapronak),fix approval unclose issue,fix stock allocation issue --- .../common.stock_allocation.repository.go | 11 ++++++----- .../services/project_flock_kandang.service.go | 14 ++++++++++++++ 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/internal/common/repository/common.stock_allocation.repository.go b/internal/common/repository/common.stock_allocation.repository.go index 38b1a93b..466fbe4a 100644 --- a/internal/common/repository/common.stock_allocation.repository.go +++ b/internal/common/repository/common.stock_allocation.repository.go @@ -63,13 +63,14 @@ func (r *StockAllocationRepositoryImpl) ReleaseByUsable( updates["note"] = *note } - q := r.DB().WithContext(ctx). + baseDB := r.DB() + if modifier != nil { + baseDB = modifier(baseDB) + } + + q := baseDB.WithContext(ctx). Model(&entity.StockAllocation{}). Where("usable_type = ? AND usable_id = ? AND status = ?", usableType, usableID, entity.StockAllocationStatusActive) - if modifier != nil { - q = modifier(q) - } - return q.Updates(updates).Error } diff --git a/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go b/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go index 883e64b0..87af4de1 100644 --- a/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go +++ b/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go @@ -499,6 +499,20 @@ func (s projectFlockKandangService) Closing(c *fiber.Ctx, id uint, req *validati return nil, err } } + if s.ApprovalSvc != nil { + reopenAction := entity.ApprovalActionApproved + if _, aerr := s.ApprovalSvc.CreateApproval( + c.Context(), + utils.ApprovalWorkflowProjectFlockKandang, + id, + utils.ProjectFlockKandangStepDisetujui, + &reopenAction, + actorID, + nil, + ); aerr != nil && !errors.Is(aerr, gorm.ErrDuplicatedKey) { + return nil, aerr + } + } default: return nil, fiber.NewError(fiber.StatusBadRequest, "action harus close atau unclose") } From 0de2021308667384691be852f025f3d525c29b82 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Thu, 11 Dec 2025 09:42:32 +0700 Subject: [PATCH 05/12] FIX[BE] : fix project flock kandang get all API --- .../repositories/projectflock_kandang.repository.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go index 889a95be..2d532335 100644 --- a/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go +++ b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go @@ -117,10 +117,10 @@ func (r *projectFlockKandangRepositoryImpl) GetAllWithFilters(ctx context.Contex AND "approvals"."approvable_type" = ? AND LOWER("approvals"."step_name") = LOWER(?) AND "approvals"."id" IN ( - SELECT "id" FROM "approvals" - WHERE "approvable_id" = "project_flock_kandangs"."id" - AND "approvable_type" = ? - ORDER BY "action_at" DESC + SELECT "approvals"."id" FROM "approvals" + WHERE "approvals"."approvable_id" = "project_flock_kandangs"."id" + AND "approvals"."approvable_type" = ? + ORDER BY "approvals"."id" DESC LIMIT 1 ) ) @@ -238,9 +238,9 @@ func (r *projectFlockKandangRepositoryImpl) GetByProjectFlockAndKandang(ctx cont func (r *projectFlockKandangRepositoryImpl) GetActiveByKandangID(ctx context.Context, kandangID uint) (*entity.ProjectFlockKandang, error) { latestApprovalSubQuery := r.db. Table("approvals"). - Select("DISTINCT ON (approvable_id) approvable_id, step_name, action_at"). + Select("DISTINCT ON (approvable_id) approvable_id, step_name, id"). Where("approvable_type = ?", "PROJECT_FLOCKS"). - Order("approvable_id, action_at DESC") + Order("approvable_id, id DESC") var pfkID uint if err := r.db.WithContext(ctx). From f60564d673a2a3fa092691825ffd6d0a3c73e9a3 Mon Sep 17 00:00:00 2001 From: ragilap Date: Thu, 11 Dec 2025 11:27:50 +0700 Subject: [PATCH 06/12] fix projectflock approval with dto --- internal/middleware/auth.go | 115 +++++++++--------- .../project_flock_kandang.controller.go | 13 +- .../services/project_flock_kandang.service.go | 70 +++++++---- 3 files changed, 117 insertions(+), 81 deletions(-) diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index 85bb8146..a831c25b 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -4,7 +4,7 @@ import ( "strings" "github.com/gofiber/fiber/v2" - // "gitlab.com/mbugroup/lti-api.git/internal/config" + "gitlab.com/mbugroup/lti-api.git/internal/config" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session" service "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -31,65 +31,65 @@ type AuthContext struct { // fine-grained authorization using the SSO access token scopes. func Auth(userService service.UserService, requiredScopes ...string) fiber.Handler { return func(c *fiber.Ctx) error { - // token := bearerToken(c) - // if token == "" { - // token = strings.TrimSpace(c.Cookies(config.SSOAccessCookieName)) - // } - // if token == "" { - // return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") - // } + token := bearerToken(c) + if token == "" { + token = strings.TrimSpace(c.Cookies(config.SSOAccessCookieName)) + } + if token == "" { + return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") + } - // verification, err := sso.VerifyAccessToken(token) - // if err != nil { - // utils.Log.WithError(err).Warn("auth: token verification failed") - // return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") - // } + verification, err := sso.VerifyAccessToken(token) + if err != nil { + utils.Log.WithError(err).Warn("auth: token verification failed") + return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") + } - // if verification.UserID == 0 { - // return fiber.NewError(fiber.StatusForbidden, "Service authentication is not permitted for this endpoint") - // } + if verification.UserID == 0 { + return fiber.NewError(fiber.StatusForbidden, "Service authentication is not permitted for this endpoint") + } - // if err := ensureNotRevoked(c, token, verification); err != nil { - // return err - // } + if err := ensureNotRevoked(c, token, verification); err != nil { + return err + } - // user, err := userService.GetBySSOUserID(c, verification.UserID) - // if err != nil || user == nil { - // utils.Log.WithError(err).Warn("auth: failed to resolve user from repository") - // return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") - // } + user, err := userService.GetBySSOUserID(c, verification.UserID) + if err != nil || user == nil { + utils.Log.WithError(err).Warn("auth: failed to resolve user from repository") + return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") + } - // if len(requiredScopes) > 0 { - // if verification.Claims == nil || !hasAllScopes(verification.Claims.Scopes(), requiredScopes) { - // return fiber.NewError(fiber.StatusForbidden, "Insufficient scope") - // } - // } + if len(requiredScopes) > 0 { + if verification.Claims == nil || !hasAllScopes(verification.Claims.Scopes(), requiredScopes) { + return fiber.NewError(fiber.StatusForbidden, "Insufficient scope") + } + } - // var roles []sso.Role - // permissions := make(map[string]struct{}) - // if verification.UserID != 0 { - // if profile, err := sso.FetchProfile(c.Context(), token, verification); err != nil { - // utils.Log.WithError(err).Warn("auth: failed to fetch sso profile") - // } else if profile != nil { - // roles = profile.Roles - // for _, perm := range profile.PermissionNames() { - // if perm != "" { - // permissions[perm] = struct{}{} - // } - // } - // } - // } + var roles []sso.Role + permissions := make(map[string]struct{}) + if verification.UserID != 0 { + if profile, err := sso.FetchProfile(c.Context(), token, verification); err != nil { + utils.Log.WithError(err).Warn("auth: failed to fetch sso profile") + } else if profile != nil { + roles = profile.Roles + for _, perm := range profile.PermissionNames() { + if perm != "" { + permissions[perm] = struct{}{} + } + } + } + } - // ctx := &AuthContext{ - // Token: token, - // Verification: verification, - // User: user, - // Roles: roles, - // Permissions: permissions, - // } + ctx := &AuthContext{ + Token: token, + Verification: verification, + User: user, + Roles: roles, + Permissions: permissions, + } - // c.Locals(authContextLocalsKey, ctx) - // c.Locals(authUserLocalsKey, user) + c.Locals(authContextLocalsKey, ctx) + c.Locals(authUserLocalsKey, user) return c.Next() } } @@ -104,12 +104,11 @@ func AuthenticatedUser(c *fiber.Ctx) (*entity.User, bool) { } func ActorIDFromContext(c *fiber.Ctx) (uint, error) { - // user, ok := AuthenticatedUser(c) - // if !ok || user == nil || user.Id == 0 { - // return 0, fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") - // } - // return user.Id, nil - return 1, nil + user, ok := AuthenticatedUser(c) + if !ok || user == nil || user.Id == 0 { + return 0, fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") + } + return user.Id, nil } // AuthDetails returns the full authentication context (token, claims, user). diff --git a/internal/modules/production/project-flock-kandangs/controllers/project_flock_kandang.controller.go b/internal/modules/production/project-flock-kandangs/controllers/project_flock_kandang.controller.go index dce7b02b..32ac0e38 100644 --- a/internal/modules/production/project-flock-kandangs/controllers/project_flock_kandang.controller.go +++ b/internal/modules/production/project-flock-kandangs/controllers/project_flock_kandang.controller.go @@ -101,13 +101,22 @@ func (u *ProjectFlockKandangController) Closing(c *fiber.Ctx) error { return err } + detail, availableQtys, productWarehouses, err := u.ProjectFlockKandangService.GetOne(c, result.Id) + if err != nil { + return err + } + + detailDTO := dto.ToProjectFlockKandangDetailDTOWithAvailableQty(*detail, availableQtys, productWarehouses) + return c.Status(fiber.StatusOK). JSON(response.Success{ Code: fiber.StatusOK, Status: "success", Message: "Status closing kandang diperbarui", - // Data: dto.ProjectFlockKandangDetailDTO(*result), - Data: result, + Data: fiber.Map{ + "detail": detailDTO, + "approval": detailDTO.Approval, + }, }) } diff --git a/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go b/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go index 87af4de1..7effdc35 100644 --- a/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go +++ b/internal/modules/production/project-flock-kandangs/services/project_flock_kandang.service.go @@ -432,16 +432,30 @@ func (s projectFlockKandangService) Closing(c *fiber.Ctx, id uint, req *validati } if s.ApprovalSvc != nil { closeAction := entity.ApprovalActionApproved - if _, aerr := s.ApprovalSvc.CreateApproval( - c.Context(), - utils.ApprovalWorkflowProjectFlockKandang, - id, - utils.ProjectFlockKandangStepClosed, - &closeAction, - actorID, - nil, - ); aerr != nil { - return nil, aerr + // Hindari duplikasi jika approval terakhir sudah Closed + Approved + latestPFK, lerr := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowProjectFlockKandang, id, nil) + if lerr != nil { + return nil, lerr + } + shouldCreate := true + if latestPFK != nil && + latestPFK.StepNumber == uint16(utils.ProjectFlockKandangStepClosed) && + latestPFK.Action != nil && *latestPFK.Action == closeAction { + shouldCreate = false + } + + if shouldCreate { + if _, aerr := s.ApprovalSvc.CreateApproval( + c.Context(), + utils.ApprovalWorkflowProjectFlockKandang, + id, + utils.ProjectFlockKandangStepClosed, + &closeAction, + actorID, + nil, + ); aerr != nil { + return nil, aerr + } } // Jika semua kandang dalam project sudah ditutup, set approval project flock ke SELESAI. @@ -500,17 +514,31 @@ func (s projectFlockKandangService) Closing(c *fiber.Ctx, id uint, req *validati } } if s.ApprovalSvc != nil { - reopenAction := entity.ApprovalActionApproved - if _, aerr := s.ApprovalSvc.CreateApproval( - c.Context(), - utils.ApprovalWorkflowProjectFlockKandang, - id, - utils.ProjectFlockKandangStepDisetujui, - &reopenAction, - actorID, - nil, - ); aerr != nil && !errors.Is(aerr, gorm.ErrDuplicatedKey) { - return nil, aerr + reopenAction := entity.ApprovalActionUpdated + // Hindari duplikasi jika approval terakhir sudah Disetujui + Updated + latestPFK, lerr := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowProjectFlockKandang, id, nil) + if lerr != nil { + return nil, lerr + } + shouldCreate := true + if latestPFK != nil && + latestPFK.StepNumber == uint16(utils.ProjectFlockKandangStepDisetujui) && + latestPFK.Action != nil && *latestPFK.Action == reopenAction { + shouldCreate = false + } + + if shouldCreate { + if _, aerr := s.ApprovalSvc.CreateApproval( + c.Context(), + utils.ApprovalWorkflowProjectFlockKandang, + id, + utils.ProjectFlockKandangStepDisetujui, + &reopenAction, + actorID, + nil, + ); aerr != nil && !errors.Is(aerr, gorm.ErrDuplicatedKey) { + return nil, aerr + } } } default: From c79e35c217c446e4a3efdd7dfcb419d6b6248c33 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Thu, 11 Dec 2025 12:34:13 +0700 Subject: [PATCH 07/12] FIX[BE} fixing get all adjustment change respose json --- .../inventory/adjustments/dto/adjustment.dto.go | 14 +++++--------- internal/modules/inventory/adjustments/route.go | 6 +++--- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/internal/modules/inventory/adjustments/dto/adjustment.dto.go b/internal/modules/inventory/adjustments/dto/adjustment.dto.go index 556050f4..008f9966 100644 --- a/internal/modules/inventory/adjustments/dto/adjustment.dto.go +++ b/internal/modules/inventory/adjustments/dto/adjustment.dto.go @@ -33,10 +33,8 @@ type ProductWarehouseDTO struct { type AdjustmentRelationDTO struct { Id uint `json:"id"` - TransactionType string `json:"transaction_type"` - Quantity float64 `json:"quantity"` - BeforeQuantity float64 `json:"before_quantity"` - AfterQuantity float64 `json:"after_quantity"` + Increase float64 `json:"increase"` + Decrease float64 `json:"decrease"` Note string `json:"note,omitempty"` ProductWarehouseId uint `json:"product_warehouse_id"` ProductWarehouse *ProductWarehouseDTO `json:"product_warehouse,omitempty"` @@ -104,12 +102,10 @@ func ToProductWarehouseDTO(e *entity.ProductWarehouse) *ProductWarehouseDTO { func ToAdjustmentRelationDTO(e *entity.StockLog) AdjustmentRelationDTO { return AdjustmentRelationDTO{ - Id: e.Id, - // TransactionType: e.LoggableType, - // Quantity: e.Q, - // BeforeQuantity: e.BeforeQuantity, - // AfterQuantity: e.AfterQuantity, + Id: e.Id, Note: e.Notes, + Increase: e.Increase, + Decrease: e.Decrease, ProductWarehouseId: e.ProductWarehouseId, ProductWarehouse: ToProductWarehouseDTO(e.ProductWarehouse), } diff --git a/internal/modules/inventory/adjustments/route.go b/internal/modules/inventory/adjustments/route.go index 57200215..8c7e5f9f 100644 --- a/internal/modules/inventory/adjustments/route.go +++ b/internal/modules/inventory/adjustments/route.go @@ -14,9 +14,9 @@ func AdjustmentRoutes(v1 fiber.Router, u user.UserService, s adjustment.Adjustme route := v1.Group("/adjustments") route.Use(m.Auth(u)) - // Standard CRUD routes following master data pattern - route.Get("/", ctrl.AdjustmentHistory) // Get all with pagination and filters - route.Post("/", ctrl.Adjustment) // Create adjustment + + route.Get("/", ctrl.AdjustmentHistory) + route.Post("/", ctrl.Adjustment) route.Get("/:id", ctrl.GetOne) } From cbb3368141fa8467c9cb711d9f8ce224f94d9dc5 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Mon, 15 Dec 2025 09:11:26 +0700 Subject: [PATCH 08/12] FEAT[BE]: implement expense report retrieval with filtering options --- .../closings/dto/closingMarketing.dto.go | 13 +- .../expense_realization.repository.go | 83 +++++++++ .../services/adjustment.service.go | 3 +- .../product_warehouse.repository.go | 2 +- .../controllers/projectflock.controller.go | 3 +- .../controllers/repport.controller.go | 67 ++----- internal/modules/repports/dto/repport.dto.go | 16 -- .../repports/dto/repportExpense.dto.go | 173 ++++++++++++++++++ internal/modules/repports/module.go | 11 +- internal/modules/repports/route.go | 4 - .../repports/services/repport.service.go | 98 ++++------ .../validations/repport.validation.go | 15 +- 12 files changed, 330 insertions(+), 158 deletions(-) delete mode 100644 internal/modules/repports/dto/repport.dto.go create mode 100644 internal/modules/repports/dto/repportExpense.dto.go diff --git a/internal/modules/closings/dto/closingMarketing.dto.go b/internal/modules/closings/dto/closingMarketing.dto.go index a442fc9d..ea0ddb81 100644 --- a/internal/modules/closings/dto/closingMarketing.dto.go +++ b/internal/modules/closings/dto/closingMarketing.dto.go @@ -28,10 +28,7 @@ type SalesDTO struct { } type PenjualanRealisasiResponseDTO struct { - ProjectType string `json:"project_type"` - FlockId uint `json:"flock_id"` - Period int `json:"period"` - Sales []SalesDTO `json:"sales"` + Sales []SalesDTO `json:"sales"` } // === Mapper Functions === @@ -87,12 +84,10 @@ func ToSalesDTOs(e []entity.MarketingDeliveryProduct) []SalesDTO { } func ToPenjualanRealisasiResponseDTO(projectType string, projectFlockID uint, e []entity.MarketingDeliveryProduct) PenjualanRealisasiResponseDTO { - period := extractPeriodFromRealisasi(e) + return PenjualanRealisasiResponseDTO{ - ProjectType: projectType, - FlockId: projectFlockID, - Period: period, - Sales: ToSalesDTOs(e), + + Sales: ToSalesDTOs(e), } } diff --git a/internal/modules/expenses/repositories/expense_realization.repository.go b/internal/modules/expenses/repositories/expense_realization.repository.go index e60324ca..592c8d27 100644 --- a/internal/modules/expenses/repositories/expense_realization.repository.go +++ b/internal/modules/expenses/repositories/expense_realization.repository.go @@ -5,6 +5,8 @@ import ( "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/repports/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" "gorm.io/gorm" ) @@ -13,6 +15,7 @@ type ExpenseRealizationRepository interface { IdExists(ctx context.Context, id uint64) (bool, error) GetByExpenseNonstockID(ctx context.Context, expenseNonstockID uint64) (*entity.ExpenseRealization, error) GetByProjectFlockID(ctx context.Context, projectFlockID uint) ([]entity.ExpenseRealization, error) + GetAllWithFilters(ctx context.Context, offset, limit int, filters *validation.Query) ([]entity.ExpenseRealization, int64, error) } type ExpenseRealizationRepositoryImpl struct { @@ -50,3 +53,83 @@ func (r *ExpenseRealizationRepositoryImpl) GetByProjectFlockID(ctx context.Conte Find(&realizations).Error return realizations, err } + +func (r *ExpenseRealizationRepositoryImpl) GetAllWithFilters(ctx context.Context, offset, limit int, filters *validation.Query) ([]entity.ExpenseRealization, int64, error) { + var realizations []entity.ExpenseRealization + var total int64 + + db := r.DB().WithContext(ctx). + Model(&entity.ExpenseRealization{}). + Preload("ExpenseNonstock", func(db *gorm.DB) *gorm.DB { + return db. + Preload("Expense"). + Preload("Expense.Supplier"). + Preload("Kandang"). + Preload("Kandang.Location"). + Preload("Nonstock") + }). + Joins("JOIN expense_nonstocks ON expense_nonstocks.id = expense_realizations.expense_nonstock_id"). + Joins("JOIN expenses ON expenses.id = expense_nonstocks.expense_id"). + Joins("LEFT JOIN suppliers ON suppliers.id = expenses.supplier_id") + + if filters.Search != "" { + db = db.Where("expenses.category LIKE ? OR expenses.reference_number LIKE ? OR expenses.po_number LIKE ? OR expenses.notes LIKE ? OR suppliers.name LIKE ?", + "%"+filters.Search+"%", "%"+filters.Search+"%", "%"+filters.Search+"%", "%"+filters.Search+"%", "%"+filters.Search+"%") + } + + if filters.Category != "" { + db = db.Where("expenses.category = ?", filters.Category) + } + + if filters.SupplierId > 0 { + db = db.Where("expenses.supplier_id = ?", filters.SupplierId) + } + + if filters.KandangId > 0 { + db = db.Where("expense_nonstocks.kandang_id = ?", filters.KandangId) + } + + if filters.ProjectFlockKandangId > 0 { + db = db.Where("expense_nonstocks.project_flock_kandang_id = ?", filters.ProjectFlockKandangId) + } + + if filters.NonstockId > 0 { + db = db.Where("expense_nonstocks.nonstock_id = ?", filters.NonstockId) + } + + locationID := filters.LocationId + areaID := filters.AreaId + + if locationID > 0 || areaID > 0 { + db = db.Joins("JOIN kandangs ON kandangs.id = expense_nonstocks.kandang_id") + + if locationID > 0 { + db = db.Where("kandangs.location_id = ?", uint(locationID)) + } + + if areaID > 0 { + db = db.Joins("JOIN locations ON locations.id = kandangs.location_id"). + Where("locations.area_id = ?", uint(areaID)) + } + } + + if filters.RealizationDate != "" { + if realizationDate, err := utils.ParseDateString(filters.RealizationDate); err == nil { + db = db.Where("DATE(expenses.realization_date) = ?", realizationDate) + } + } + + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + + if err := db. + Offset(offset). + Limit(limit). + Order("expense_realizations.created_at DESC"). + Find(&realizations).Error; err != nil { + return nil, 0, err + } + + return realizations, total, nil +} diff --git a/internal/modules/inventory/adjustments/services/adjustment.service.go b/internal/modules/inventory/adjustments/services/adjustment.service.go index da118438..7bcbca7e 100644 --- a/internal/modules/inventory/adjustments/services/adjustment.service.go +++ b/internal/modules/inventory/adjustments/services/adjustment.service.go @@ -141,7 +141,7 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e if err := common.EnsureProjectFlockNotClosedForProductWarehouses( ctx, s.StockLogsRepository.DB(), - []uint{pw.Id}, + []uint{pw.Id}, ); err != nil { return nil, err } @@ -229,7 +229,6 @@ func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Qu isWarehousesExist, err := s.WarehouseRepo.IdExists(c.Context(), uint(query.WarehouseID)) if err != nil { - s.Log.Errorf("Failed to check warehouse existence: %+v", err) return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate warehouse") } if query.WarehouseID > 0 && !isWarehousesExist { 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 ee92f6ab..2fc8bc3d 100644 --- a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go +++ b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go @@ -115,7 +115,7 @@ func (r *ProductWarehouseRepositoryImpl) GetLatestByCategoryCodeAndWarehouseID(c Joins("JOIN products ON products.id = product_warehouses.product_id"). Joins("JOIN product_categories ON product_categories.id = products.product_category_id"). Where("product_categories.code = ? AND product_warehouses.warehouse_id = ?", categoryCode, warehouseId). - Order("product_warehouses.id DESC"). + Order("product_warehouses.created_at DESC"). Preload("Product").Preload("Warehouse"). First(&productWarehouse).Error if err != nil { diff --git a/internal/modules/production/project_flocks/controllers/projectflock.controller.go b/internal/modules/production/project_flocks/controllers/projectflock.controller.go index 937c9058..c48e1e2a 100644 --- a/internal/modules/production/project_flocks/controllers/projectflock.controller.go +++ b/internal/modules/production/project_flocks/controllers/projectflock.controller.go @@ -281,7 +281,6 @@ func (u *ProjectflockController) LookupProjectFlockKandang(c *fiber.Ctx) error { dtoResult := dto.ToProjectFlockKandangDTO(*result) dtoResult.AvailableQuantity = float64(availableStock) - // populate available quantity for each kandang inside project_flock if dtoResult.ProjectFlock != nil { for i := range dtoResult.ProjectFlock.Kandangs { kand := &dtoResult.ProjectFlock.Kandangs[i] @@ -292,7 +291,7 @@ func (u *ProjectflockController) LookupProjectFlockKandang(c *fiber.Ctx) error { kand.AvailableQuantity = q } } - // remove inner kandangs from project_flock to avoid duplication + dtoResult.ProjectFlock.Kandangs = nil } diff --git a/internal/modules/repports/controllers/repport.controller.go b/internal/modules/repports/controllers/repport.controller.go index e4b6088e..11563f7f 100644 --- a/internal/modules/repports/controllers/repport.controller.go +++ b/internal/modules/repports/controllers/repport.controller.go @@ -2,7 +2,6 @@ package controller import ( "math" - "strconv" "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto" service "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/services" @@ -22,27 +21,35 @@ func NewRepportController(repportService service.RepportService) *RepportControl } } -func (c *RepportController) GetAll(ctx *fiber.Ctx) error { +func (c *RepportController) GetExpense(ctx *fiber.Ctx) error { query := &validation.Query{ - Page: ctx.QueryInt("page", 1), - Limit: ctx.QueryInt("limit", 10), - Search: ctx.Query("search", ""), + Page: ctx.QueryInt("page", 1), + Limit: ctx.QueryInt("limit", 10), + Search: ctx.Query("search", ""), + Category: ctx.Query("category", ""), + SupplierId: int64(ctx.QueryInt("supplier_id", 0)), + KandangId: int64(ctx.QueryInt("kandang_id", 0)), + ProjectFlockKandangId: int64(ctx.QueryInt("project_flock_kandang_id", 0)), + NonstockId: int64(ctx.QueryInt("nonstock_id", 0)), + AreaId: int64(ctx.QueryInt("area_id", 0)), + LocationId: int64(ctx.QueryInt("location_id", 0)), + RealizationDate: ctx.Query("realization_date", ""), } if query.Page < 1 || query.Limit < 1 { return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") } - result, totalResults, err := c.RepportService.GetAll(ctx, query) + result, totalResults, err := c.RepportService.GetExpense(ctx, query) if err != nil { return err } return ctx.Status(fiber.StatusOK). - JSON(response.SuccessWithPaginate[dto.RepportListDTO]{ + JSON(response.SuccessWithPaginate[dto.RepportExpenseListDTO]{ Code: fiber.StatusOK, Status: "success", - Message: "Get all reports successfully", + Message: "Get expense report successfully", Meta: response.Meta{ Page: query.Page, Limit: query.Limit, @@ -52,47 +59,3 @@ func (c *RepportController) GetAll(ctx *fiber.Ctx) error { Data: result, }) } - -func (c *RepportController) GetOne(ctx *fiber.Ctx) error { - param := ctx.Params("id") - - id, err := strconv.Atoi(param) - if err != nil { - return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") - } - - result, err := c.RepportService.GetOne(ctx, uint(id)) - if err != nil { - return err - } - - return ctx.Status(fiber.StatusOK). - JSON(response.Success{ - Code: fiber.StatusOK, - Status: "success", - Message: "Get report successfully", - Data: result, - }) -} - -func (c *RepportController) GetExpense(ctx *fiber.Ctx) error { - param := ctx.Params("id") - - id, err := strconv.Atoi(param) - if err != nil { - return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") - } - - result, err := c.RepportService.GetOne(ctx, uint(id)) - if err != nil { - return err - } - - return ctx.Status(fiber.StatusOK). - JSON(response.Success{ - Code: fiber.StatusOK, - Status: "success", - Message: "Get report successfully", - Data: result, - }) -} diff --git a/internal/modules/repports/dto/repport.dto.go b/internal/modules/repports/dto/repport.dto.go deleted file mode 100644 index 154c6f47..00000000 --- a/internal/modules/repports/dto/repport.dto.go +++ /dev/null @@ -1,16 +0,0 @@ -package dto - -import "time" - -// === DTO Structs === - -type RepportListDTO struct { - Id uint `json:"id"` - Name string `json:"name"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -type RepportDetailDTO struct { - RepportListDTO -} diff --git a/internal/modules/repports/dto/repportExpense.dto.go b/internal/modules/repports/dto/repportExpense.dto.go new file mode 100644 index 00000000..1a17dd5b --- /dev/null +++ b/internal/modules/repports/dto/repportExpense.dto.go @@ -0,0 +1,173 @@ +package dto + +import ( + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" + kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto" + nonstockDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/nonstocks/dto" + supplierDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/dto" +) + +// === DTO Structs === + +type RepportExpenseBaseDTO struct { + Id uint64 `json:"id"` + ReferenceNumber string `json:"reference_number"` + PoNumber string `json:"po_number"` + Category string `json:"category"` + Supplier *supplierDTO.SupplierRelationDTO `json:"supplier,omitempty"` + RealizationDate *time.Time `json:"realization_date,omitempty"` + TransactionDate time.Time `json:"transaction_date"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type RepportExpensePengajuanDTO struct { + Id uint64 `json:"id"` + ExpenseId *uint64 `json:"expense_id,omitempty"` + ProjectFlockKandangId *uint64 `json:"project_flock_kandang_id,omitempty"` + Qty float64 `json:"qty"` + Price float64 `json:"price"` + Notes string `json:"notes"` + Nonstock *nonstockDTO.NonstockRelationDTO `json:"nonstock,omitempty"` + Kandang *kandangDTO.KandangRelationDTO `json:"kandang,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type RepportExpenseRealisasiDTO struct { + Id *uint64 `json:"id,omitempty"` + ExpenseNonstockId *uint64 `json:"expense_nonstock_id,omitempty"` + Qty float64 `json:"qty"` + Price float64 `json:"price"` + Notes string `json:"notes"` + Nonstock *nonstockDTO.NonstockRelationDTO `json:"nonstock,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type RepportExpenseListDTO struct { + RepportExpenseBaseDTO + Pengajuan RepportExpensePengajuanDTO `json:"pengajuan"` + Realisasi RepportExpenseRealisasiDTO `json:"realisasi"` + TotalPengajuan float64 `json:"total_pengajuan"` + TotalRealisasi float64 `json:"total_realisasi"` + LatestApproval *approvalDTO.ApprovalRelationDTO `json:"latest_approval,omitempty"` +} + +// === MAPPERS === + +func ToRepportExpenseBaseDTO(e *entity.Expense) RepportExpenseBaseDTO { + var realizationDate *time.Time + if !e.RealizationDate.IsZero() { + realizationDate = &e.RealizationDate + } + + var supplier *supplierDTO.SupplierRelationDTO + if e.Supplier != nil && e.Supplier.Id != 0 { + mapped := supplierDTO.ToSupplierRelationDTO(*e.Supplier) + supplier = &mapped + } + + return RepportExpenseBaseDTO{ + Id: e.Id, + ReferenceNumber: e.ReferenceNumber, + PoNumber: e.PoNumber, + Category: e.Category, + Supplier: supplier, + RealizationDate: realizationDate, + TransactionDate: e.TransactionDate, + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + } +} + +func ToRepportExpensePengajuanDTO(ns *entity.ExpenseNonstock) RepportExpensePengajuanDTO { + var nonstock *nonstockDTO.NonstockRelationDTO + if ns.Nonstock != nil && ns.Nonstock.Id != 0 { + mapped := nonstockDTO.ToNonstockRelationDTO(*ns.Nonstock) + nonstock = &mapped + } + + var kandang *kandangDTO.KandangRelationDTO + if ns.Kandang != nil && ns.Kandang.Id != 0 { + mapped := kandangDTO.ToKandangRelationDTO(*ns.Kandang) + kandang = &mapped + } + + return RepportExpensePengajuanDTO{ + Id: ns.Id, + ExpenseId: ns.ExpenseId, + ProjectFlockKandangId: ns.ProjectFlockKandangId, + Qty: ns.Qty, + Price: ns.Price, + Notes: ns.Notes, + Nonstock: nonstock, + Kandang: kandang, + CreatedAt: ns.CreatedAt, + } +} + +func ToRepportExpenseRealisasiDTO(r *entity.ExpenseRealization) RepportExpenseRealisasiDTO { + var nonstock *nonstockDTO.NonstockRelationDTO + if r.ExpenseNonstock != nil && r.ExpenseNonstock.Nonstock != nil && r.ExpenseNonstock.Nonstock.Id != 0 { + mapped := nonstockDTO.ToNonstockRelationDTO(*r.ExpenseNonstock.Nonstock) + nonstock = &mapped + } + + return RepportExpenseRealisasiDTO{ + Id: r.ExpenseNonstockId, + ExpenseNonstockId: r.ExpenseNonstockId, + Qty: r.Qty, + Price: r.Price, + Notes: r.Notes, + Nonstock: nonstock, + CreatedAt: r.CreatedAt, + } +} + +func ToRepportExpenseListDTO(baseDTO RepportExpenseBaseDTO, ns *entity.ExpenseNonstock, latestApproval *approvalDTO.ApprovalRelationDTO) RepportExpenseListDTO { + var realisasi RepportExpenseRealisasiDTO + if ns.Realization != nil { + realisasi = ToRepportExpenseRealisasiDTO(ns.Realization) + } + + totalPengajuan := ns.Qty * ns.Price + totalRealisasi := float64(0) + if ns.Realization != nil { + totalRealisasi = ns.Realization.Qty * ns.Realization.Price + } + + return RepportExpenseListDTO{ + RepportExpenseBaseDTO: baseDTO, + Pengajuan: ToRepportExpensePengajuanDTO(ns), + Realisasi: realisasi, + TotalPengajuan: totalPengajuan, + TotalRealisasi: totalRealisasi, + LatestApproval: latestApproval, + } +} + +func ToRepportExpenseListDTOs(realizations []entity.ExpenseRealization) []RepportExpenseListDTO { + result := make([]RepportExpenseListDTO, 0, len(realizations)) + + for _, realization := range realizations { + if realization.ExpenseNonstock == nil || realization.ExpenseNonstock.Expense == nil { + continue + } + + expense := realization.ExpenseNonstock.Expense + baseDTO := ToRepportExpenseBaseDTO(expense) + + var latestApproval *approvalDTO.ApprovalRelationDTO + if expense.LatestApproval != nil { + mapped := approvalDTO.ToApprovalDTO(*expense.LatestApproval) + latestApproval = &mapped + } + + dto := ToRepportExpenseListDTO(baseDTO, realization.ExpenseNonstock, latestApproval) + result = append(result, dto) + } + + return result +} diff --git a/internal/modules/repports/module.go b/internal/modules/repports/module.go index be0ba7a3..108b0a1b 100644 --- a/internal/modules/repports/module.go +++ b/internal/modules/repports/module.go @@ -5,6 +5,8 @@ import ( "github.com/gofiber/fiber/v2" "gorm.io/gorm" + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + approvalService "gitlab.com/mbugroup/lti-api.git/internal/common/service" sRepport "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/services" expenseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" @@ -13,11 +15,12 @@ import ( type RepportModule struct{} func (RepportModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { - // Initialize expense realization repository - expRealizationRepo := expenseRepo.NewExpenseRealizationRepository(db) - // Initialize report service with expense realization repo - repportService := sRepport.NewRepportService(validate, expRealizationRepo) + expenseRealizationRepository := expenseRepo.NewExpenseRealizationRepository(db) + approvalRepository := commonRepo.NewApprovalRepository(db) + + approvalSvc := approvalService.NewApprovalService(approvalRepository) + repportService := sRepport.NewRepportService(validate, expenseRealizationRepository, approvalSvc) RepportRoutes(router, repportService) } diff --git a/internal/modules/repports/route.go b/internal/modules/repports/route.go index d01fd4b2..b312174e 100644 --- a/internal/modules/repports/route.go +++ b/internal/modules/repports/route.go @@ -1,7 +1,6 @@ package repports import ( - controller "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/controllers" repport "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/services" @@ -13,8 +12,5 @@ func RepportRoutes(v1 fiber.Router, s repport.RepportService) { route := v1.Group("/repports") - route.Get("/", ctrl.GetAll) - route.Get("/:id", ctrl.GetOne) - route.Get("expense", ctrl.GetExpense) } diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 82fd5470..15f2d635 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -1,106 +1,74 @@ package service import ( - "strings" - "time" - "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/validations" "gitlab.com/mbugroup/lti-api.git/internal/utils" + approvalService "gitlab.com/mbugroup/lti-api.git/internal/common/service" + approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" expenseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" "github.com/sirupsen/logrus" + "gorm.io/gorm" ) type RepportService interface { - GetAll(ctx *fiber.Ctx, params *validation.Query) ([]dto.RepportListDTO, int64, error) - GetOne(ctx *fiber.Ctx, id uint) (*dto.RepportListDTO, error) - GetExpense(ctx *fiber.Ctx, id uint) (*dto.RepportListDTO, error) + GetExpense(ctx *fiber.Ctx, params *validation.Query) ([]dto.RepportExpenseListDTO, int64, error) } type repportService struct { Log *logrus.Logger Validate *validator.Validate - dummyData map[uint]dto.RepportListDTO ExpenseRealizationRepo expenseRepo.ExpenseRealizationRepository + ApprovalSvc approvalService.ApprovalService } -func NewRepportService(validate *validator.Validate, expenseRealizationRepo expenseRepo.ExpenseRealizationRepository) RepportService { - // Initialize with dummy data - now := time.Now() - dummyData := map[uint]dto.RepportListDTO{ - 1: { - Id: 1, - Name: "Sales Report", - CreatedAt: now, - UpdatedAt: now, - }, - 2: { - Id: 2, - Name: "Inventory Report", - CreatedAt: now, - UpdatedAt: now, - }, - 3: { - Id: 3, - Name: "Production Report", - CreatedAt: now, - UpdatedAt: now, - }, - } - +func NewRepportService(validate *validator.Validate, expenseRealizationRepo expenseRepo.ExpenseRealizationRepository, approvalSvc approvalService.ApprovalService) RepportService { return &repportService{ Log: utils.Log, Validate: validate, - dummyData: dummyData, ExpenseRealizationRepo: expenseRealizationRepo, + ApprovalSvc: approvalSvc, } } -func (s *repportService) GetAll(c *fiber.Ctx, params *validation.Query) ([]dto.RepportListDTO, int64, error) { +func (s *repportService) GetExpense(c *fiber.Ctx, params *validation.Query) ([]dto.RepportExpenseListDTO, int64, error) { if err := s.Validate.Struct(params); err != nil { return nil, 0, err } - // Convert map to slice - var results []dto.RepportListDTO - for _, v := range s.dummyData { - // Apply search filter if provided - if params.Search != "" && !strings.Contains(strings.ToLower(v.Name), strings.ToLower(params.Search)) { - continue - } - results = append(results, v) - } - - // Apply pagination - total := int64(len(results)) offset := (params.Page - 1) * params.Limit - if offset >= int(total) { - return []dto.RepportListDTO{}, total, nil + realizations, total, err := s.ExpenseRealizationRepo.GetAllWithFilters(c.Context(), offset, params.Limit, params) + if err != nil { + s.Log.Errorf("GetAllWithFilters error: %v", err) + return nil, 0, err } - end := offset + params.Limit - if end > int(total) { - end = int(total) + result := dto.ToRepportExpenseListDTOs(realizations) + + expenseIDs := make([]uint, 0, len(result)) + for i := range result { + expenseIDs = append(expenseIDs, uint(result[i].Id)) } - return results[offset:end], total, nil -} - -func (s *repportService) GetOne(c *fiber.Ctx, id uint) (*dto.RepportListDTO, error) { - if data, ok := s.dummyData[id]; ok { - return &data, nil - } - return nil, fiber.NewError(fiber.StatusNotFound, "Report not found") -} - -func (s *repportService) GetExpense(c *fiber.Ctx, id uint) (*dto.RepportListDTO, error) { - if data, ok := s.dummyData[id]; ok { - return &data, nil - } - return nil, fiber.NewError(fiber.StatusNotFound, "Report not found") + approvals, err := s.ApprovalSvc.LatestByTargets(c.Context(), utils.ApprovalWorkflowExpense, expenseIDs, func(db *gorm.DB) *gorm.DB { + return db.Preload("ActionUser") + }) + if err != nil { + s.Log.Warnf("LatestByTargets error: %v", err) + } + + for i := range result { + expenseIDAsUint := uint(result[i].Id) + if approval, exists := approvals[expenseIDAsUint]; exists && approval != nil { + mapped := approvalDTO.ToApprovalDTO(*approval) + result[i].LatestApproval = &mapped + } + } + + return result, total, nil } diff --git a/internal/modules/repports/validations/repport.validation.go b/internal/modules/repports/validations/repport.validation.go index a7ec4a6d..3d0eb344 100644 --- a/internal/modules/repports/validations/repport.validation.go +++ b/internal/modules/repports/validations/repport.validation.go @@ -1,7 +1,16 @@ package validation type Query struct { - Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` - Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` - Search string `query:"search" validate:"omitempty,max=50"` + Page int `query:"page" validate:"omitempty,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,min=1,max=100,gt=0"` + Search string `query:"search" validate:"omitempty,max=100"` + Category string `query:"category" validate:"omitempty,oneof=BOP NON-BOP"` + SupplierId int64 `query:"supplier_id" validate:"omitempty"` + KandangId int64 `query:"kandang_id" validate:"omitempty"` + ProjectFlockKandangId int64 `query:"project_flock_kandang_id" validate:"omitempty"` + ProjectFlockId int64 `query:"project_flock_id" validate:"omitempty"` + NonstockId int64 `query:"nonstock_id" validate:"omitempty"` + AreaId int64 `query:"area_id" validate:"omitempty"` + LocationId int64 `query:"location_id" validate:"omitempty"` + RealizationDate string `query:"realization_date" validate:"omitempty"` } From a0a143b8ac55ad6759de7daa921458bb88556621 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Mon, 15 Dec 2025 09:18:26 +0700 Subject: [PATCH 09/12] FEAT[BE} : adjust wrong response on get repport Expense --- .../repports/dto/repportExpense.dto.go | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/internal/modules/repports/dto/repportExpense.dto.go b/internal/modules/repports/dto/repportExpense.dto.go index 1a17dd5b..3e71df2c 100644 --- a/internal/modules/repports/dto/repportExpense.dto.go +++ b/internal/modules/repports/dto/repportExpense.dto.go @@ -32,7 +32,6 @@ type RepportExpensePengajuanDTO struct { Price float64 `json:"price"` Notes string `json:"notes"` Nonstock *nonstockDTO.NonstockRelationDTO `json:"nonstock,omitempty"` - Kandang *kandangDTO.KandangRelationDTO `json:"kandang,omitempty"` CreatedAt time.Time `json:"created_at"` } @@ -48,6 +47,7 @@ type RepportExpenseRealisasiDTO struct { type RepportExpenseListDTO struct { RepportExpenseBaseDTO + Kandang *kandangDTO.KandangRelationDTO `json:"kandang,omitempty"` Pengajuan RepportExpensePengajuanDTO `json:"pengajuan"` Realisasi RepportExpenseRealisasiDTO `json:"realisasi"` TotalPengajuan float64 `json:"total_pengajuan"` @@ -89,12 +89,6 @@ func ToRepportExpensePengajuanDTO(ns *entity.ExpenseNonstock) RepportExpensePeng nonstock = &mapped } - var kandang *kandangDTO.KandangRelationDTO - if ns.Kandang != nil && ns.Kandang.Id != 0 { - mapped := kandangDTO.ToKandangRelationDTO(*ns.Kandang) - kandang = &mapped - } - return RepportExpensePengajuanDTO{ Id: ns.Id, ExpenseId: ns.ExpenseId, @@ -103,7 +97,6 @@ func ToRepportExpensePengajuanDTO(ns *entity.ExpenseNonstock) RepportExpensePeng Price: ns.Price, Notes: ns.Notes, Nonstock: nonstock, - Kandang: kandang, CreatedAt: ns.CreatedAt, } } @@ -138,8 +131,16 @@ func ToRepportExpenseListDTO(baseDTO RepportExpenseBaseDTO, ns *entity.ExpenseNo totalRealisasi = ns.Realization.Qty * ns.Realization.Price } + // Get kandang data at the main level + var kandang *kandangDTO.KandangRelationDTO + if ns.Kandang != nil && ns.Kandang.Id != 0 { + mapped := kandangDTO.ToKandangRelationDTO(*ns.Kandang) + kandang = &mapped + } + return RepportExpenseListDTO{ RepportExpenseBaseDTO: baseDTO, + Kandang: kandang, Pengajuan: ToRepportExpensePengajuanDTO(ns), Realisasi: realisasi, TotalPengajuan: totalPengajuan, @@ -165,6 +166,11 @@ func ToRepportExpenseListDTOs(realizations []entity.ExpenseRealization) []Reppor latestApproval = &mapped } + // Create a temporary realization with the current realization data + if realization.ExpenseNonstock.Realization == nil { + realization.ExpenseNonstock.Realization = &realization + } + dto := ToRepportExpenseListDTO(baseDTO, realization.ExpenseNonstock, latestApproval) result = append(result, dto) } From efaeb89ca1b3ef50d68be30117573bc2fcbf047b Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Mon, 15 Dec 2025 13:39:02 +0700 Subject: [PATCH 10/12] Fix[BE]: fix typo penamaan route --- internal/modules/repports/route.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/modules/repports/route.go b/internal/modules/repports/route.go index b312174e..2c8a2276 100644 --- a/internal/modules/repports/route.go +++ b/internal/modules/repports/route.go @@ -10,7 +10,8 @@ import ( func RepportRoutes(v1 fiber.Router, s repport.RepportService) { ctrl := controller.NewRepportController(s) - route := v1.Group("/repports") + route := v1.Group("/reports") - route.Get("expense", ctrl.GetExpense) + route.Get("/expense", ctrl.GetExpense) + // route.Get("/marketing", ctrl.GetMarketing) } From d5bc6838c8381813152fecd285cd602d28864730 Mon Sep 17 00:00:00 2001 From: aguhh18 Date: Mon, 15 Dec 2025 16:17:37 +0700 Subject: [PATCH 11/12] FEAT[BE]: create marketing report API --- .../expense_realization.repository.go | 4 +- .../transfers/services/transfer.service.go | 43 +--- .../salesorder_delivery_product.repository.go | 84 +++++++ .../controllers/repport.controller.go | 40 +++- .../repports/dto/repportMarketing.dto.go | 219 ++++++++++++++++++ internal/modules/repports/module.go | 4 +- internal/modules/repports/route.go | 2 +- .../repports/services/repport.service.go | 47 +++- .../validations/repport.validation.go | 15 +- 9 files changed, 412 insertions(+), 46 deletions(-) create mode 100644 internal/modules/repports/dto/repportMarketing.dto.go diff --git a/internal/modules/expenses/repositories/expense_realization.repository.go b/internal/modules/expenses/repositories/expense_realization.repository.go index 592c8d27..e4d57b79 100644 --- a/internal/modules/expenses/repositories/expense_realization.repository.go +++ b/internal/modules/expenses/repositories/expense_realization.repository.go @@ -15,7 +15,7 @@ type ExpenseRealizationRepository interface { IdExists(ctx context.Context, id uint64) (bool, error) GetByExpenseNonstockID(ctx context.Context, expenseNonstockID uint64) (*entity.ExpenseRealization, error) GetByProjectFlockID(ctx context.Context, projectFlockID uint) ([]entity.ExpenseRealization, error) - GetAllWithFilters(ctx context.Context, offset, limit int, filters *validation.Query) ([]entity.ExpenseRealization, int64, error) + GetAllWithFilters(ctx context.Context, offset, limit int, filters *validation.ExpenseQuery) ([]entity.ExpenseRealization, int64, error) } type ExpenseRealizationRepositoryImpl struct { @@ -54,7 +54,7 @@ func (r *ExpenseRealizationRepositoryImpl) GetByProjectFlockID(ctx context.Conte return realizations, err } -func (r *ExpenseRealizationRepositoryImpl) GetAllWithFilters(ctx context.Context, offset, limit int, filters *validation.Query) ([]entity.ExpenseRealization, int64, error) { +func (r *ExpenseRealizationRepositoryImpl) GetAllWithFilters(ctx context.Context, offset, limit int, filters *validation.ExpenseQuery) ([]entity.ExpenseRealization, int64, error) { var realizations []entity.ExpenseRealization var total int64 diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index 3293d21b..f94295f6 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -59,6 +59,7 @@ func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTr ProjectFlockKandangRepo: projectFlockKandangRepo, } } + func (s transferService) withRelations(db *gorm.DB) *gorm.DB { return db. Preload("CreatedUser"). @@ -96,13 +97,11 @@ func (s transferService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit s.Log.Infof("Retrieved %d transfers", len(transfers)) return transfers, total, nil - } func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, error) { var transfer entity.StockTransfer - // gunakan repo secara langsung transferPtr, err := s.StockTransferRepo.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { return s.withRelations(db) }) @@ -120,10 +119,9 @@ func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, e } func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferRequest) (*entity.StockTransfer, error) { - // Validasi stok di gudang asal harus exist dan mencukupi + pwIDs := make([]uint, 0, len(req.Products)) - // Validasi stok di gudang asal harus exist dan mencukupi for _, product := range req.Products { sourcePW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID( c.Context(), uint(product.ProductID), uint(req.SourceWarehouseID), @@ -139,6 +137,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques } pwIDs = append(pwIDs, sourcePW.Id) } + if err := commonSvc.EnsureProjectFlockNotClosedForProductWarehouses( c.Context(), s.StockTransferRepo.DB(), @@ -152,7 +151,6 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques return nil, err } - // validasi total qty harus lebih besar dari atau sama dengan total qty di delivery compare berdasarkan productid deliveryQtyMap := make(map[uint]float64) for _, delivery := range req.Deliveries { for _, prod := range delivery.Products { @@ -160,7 +158,6 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques } } - // Cek: qty delivery tidak boleh melebihi qty di root for _, product := range req.Products { if deliveryQtyMap[product.ProductID] > product.ProductQty { return nil, fiber.NewError(fiber.StatusBadRequest, @@ -168,7 +165,6 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques } } - // cek suplier id caegory BOP cek by id for _, delivery := range req.Deliveries { supplier, err := s.SupplierRepo.GetByID(c.Context(), uint(delivery.SupplierID), nil) if err != nil { @@ -182,8 +178,6 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques } } - // Generate movement number - // Format: PND-MBU-00001 seqNum, err := s.StockTransferRepo.GetNextMovementNumber(c.Context()) if err != nil { s.Log.Errorf("Failed to get next movement number: %+v", err) @@ -201,17 +195,14 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques CreatedBy: uint64(actorID), } - // Save the transfer entity to the database err = s.StockTransferRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { - // Insert header if err := s.StockTransferRepo.WithTx(tx).CreateOne(c.Context(), entityTransfer, nil); err != nil { s.Log.Errorf("Failed to create stock transfer: %+v", err) return err } s.Log.Infof("Stock transfer created: %+v", entityTransfer.Id) - // insert ke details var details []*entity.StockTransferDetail for _, product := range req.Products { details = append(details, &entity.StockTransferDetail{ @@ -226,7 +217,6 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques } s.Log.Infof("Stock transfer details created for transfer ID: %+v", entityTransfer.Id) - // Tambahkan proses insert delivery var deliveries []*entity.StockTransferDelivery for _, delivery := range req.Deliveries { deliveries = append(deliveries, &entity.StockTransferDelivery{ @@ -234,7 +224,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques SupplierId: uint64(delivery.SupplierID), VehiclePlate: delivery.VehiclePlate, DriverName: delivery.DriverName, - DocumentPath: "https://tourism.gov.in/sites/default/files/2019-04/dummy-pdf_2.pdf", // todo: tunggu ada aws baru proses + DocumentPath: "https://tourism.gov.in/sites/default/files/2019-04/dummy-pdf_2.pdf", ShippingCostItem: delivery.DeliveryCostPerItem, ShippingCostTotal: delivery.DeliveryCost, }) @@ -243,7 +233,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques s.Log.Errorf("Failed to create stock transfer deliveries: %+v", err) return err } - // tambahkan insert ke delivery items sebagai pivot + detailMap := make(map[uint64]uint64) for _, d := range details { detailMap[d.ProductId] = d.Id @@ -271,9 +261,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques } s.Log.Infof("Stock transfer delivery items created for transfer ID: %+v", entityTransfer.Id) - // Proses pengurangan stok di gudang asal dan penambahan stok di gudang tujuan for _, product := range req.Products { - // Kurangi stok di gudang asal sourcePW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID(c.Context(), uint(product.ProductID), uint(req.SourceWarehouseID)) if err != nil { s.Log.Errorf("Failed to get source product warehouse: %+v", err) @@ -290,15 +278,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques } s.Log.Infof("Source product warehouse updated: %+v", sourcePW.Id) - // create stock log for decrease (source) - // beforeQty := sourcePW.Quantity + product.ProductQty // sourcePW already decreased decreaseLog := &entity.StockLog{ - // TransactionType: entity.TransactionTypeDecrease, - // Quantity: product.ProductQty, - // BeforeQuantity: beforeQty, - // AfterQuantity: sourcePW.Qty, - // LogType: entity.LogTypeTransfer, - // LogId: uint(entityTransfer.Id), Decrease: product.ProductQty, Notes: "", LoggableType: entity.LogTypeTransfer, @@ -311,7 +291,6 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques return err } - // Tambah stok di gudang tujuan destPW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID( c.Context(), uint(product.ProductID), uint(req.DestinationWarehouseID), ) @@ -320,7 +299,6 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques return fiber.NewError(fiber.StatusInternalServerError, "Failed to get destination product warehouse") } if err != nil && errors.Is(err, gorm.ErrRecordNotFound) { - // Jika belum ada record untuk produk di gudang tujuan, buat baru ctx := c.Context() projectFlockKandangID, err := s.getActiveProjectFlockKandangID(ctx, uint(req.DestinationWarehouseID)) if err != nil { @@ -331,7 +309,6 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques WarehouseId: uint(req.DestinationWarehouseID), Quantity: 0, ProjectFlockKandangId: &projectFlockKandangID, - // CreatedBy: 1, // TODO: should Get from auth middleware } if err := s.ProductWarehouseRepo.WithTx(tx).CreateOne(c.Context(), destPW, nil); err != nil { s.Log.Errorf("Failed to create destination product warehouse: %+v", err) @@ -339,7 +316,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques } s.Log.Infof("Destination product warehouse created: %+v", destPW.Id) } - // Update stok di gudang tujuan + destPW.Quantity += product.ProductQty if err := s.ProductWarehouseRepo.WithTx(tx).UpdateOne(c.Context(), destPW.Id, destPW, nil); err != nil { s.Log.Errorf("Failed to update destination product warehouse: %+v", err) @@ -347,13 +324,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques } s.Log.Infof("Destination product warehouse updated: %+v", destPW.Id) - // create stock log for increase (destination) - // beforeDestQty := destPW.Quantity - product.ProductQty increaseLog := &entity.StockLog{ - // TransactionType: entity.TransactionTypeIncrease, - // Quantity: product.ProductQty, - // BeforeQuantity: beforeDestQty, - // AfterQuantity: destPW.Qty, Increase: product.ProductQty, LoggableType: entity.LogTypeTransfer, LoggableId: uint(entityTransfer.Id), @@ -365,7 +336,6 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques s.Log.Errorf("Failed to create stock log increase: %+v", err) return err } - } return nil @@ -376,7 +346,6 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to process transfer transaction") } - // Ambil data lengkap hasil create dengan GetOne (agar preload relasi sama dengan GetOne) result, err := s.GetOne(c, uint(entityTransfer.Id)) if err != nil { return nil, err diff --git a/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go index a3c2af88..85d850a6 100644 --- a/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go +++ b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go @@ -5,6 +5,8 @@ import ( "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/repports/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" "gorm.io/gorm" ) @@ -13,6 +15,7 @@ type MarketingDeliveryProductRepository interface { GetDeliveryProductsByProjectFlockID(ctx context.Context, projectFlockID uint, callback func(*gorm.DB) *gorm.DB) ([]entity.MarketingDeliveryProduct, error) GetByMarketingId(ctx context.Context, marketingId uint) ([]entity.MarketingDeliveryProduct, error) GetByMarketingProductID(ctx context.Context, marketingProductID uint) (*entity.MarketingDeliveryProduct, error) + GetAllWithFilters(ctx context.Context, offset, limit int, filters *validation.MarketingQuery) ([]entity.MarketingDeliveryProduct, int64, error) } type MarketingDeliveryProductRepositoryImpl struct { @@ -74,3 +77,84 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetByMarketingProductID(ctx con return &deliveryProduct, nil } + +func (r *MarketingDeliveryProductRepositoryImpl) GetAllWithFilters(ctx context.Context, offset, limit int, filters *validation.MarketingQuery) ([]entity.MarketingDeliveryProduct, int64, error) { + var deliveryProducts []entity.MarketingDeliveryProduct + var total int64 + + db := r.DB().WithContext(ctx). + Model(&entity.MarketingDeliveryProduct{}). + Preload("MarketingProduct", func(db *gorm.DB) *gorm.DB { + return db. + Preload("Marketing"). + Preload("Marketing.Customer"). + Preload("Marketing.SalesPerson"). + Preload("ProductWarehouse"). + Preload("ProductWarehouse.Product"). + Preload("ProductWarehouse.Warehouse") + }). + Joins("JOIN marketing_products ON marketing_products.id = marketing_delivery_products.marketing_product_id"). + Joins("JOIN marketings ON marketings.id = marketing_products.marketing_id") + + if filters.ProductId > 0 || filters.WarehouseId > 0 || filters.ProjectFlockKandangId > 0 { + db = db.Joins("LEFT JOIN product_warehouses ON product_warehouses.id = marketing_products.product_warehouse_id") + } + + if filters.ProductId > 0 { + db = db.Joins("LEFT JOIN products ON products.id = product_warehouses.product_id") + } + + if filters.WarehouseId > 0 { + db = db.Joins("LEFT JOIN warehouses ON warehouses.id = product_warehouses.warehouse_id") + } + + if filters.Search != "" { + db = db.Where("marketing_delivery_products.vehicle_number ILIKE ?", + "%"+filters.Search+"%") + } + + if filters.CustomerId > 0 { + db = db.Where("marketings.customer_id = ?", filters.CustomerId) + } + + if filters.SalesPersonId > 0 { + db = db.Where("marketings.sales_person_id = ?", filters.SalesPersonId) + } + + if filters.MarketingId > 0 { + db = db.Where("marketings.id = ?", filters.MarketingId) + } + + if filters.ProductId > 0 { + db = db.Where("product_warehouses.product_id = ?", filters.ProductId) + } + + if filters.WarehouseId > 0 { + db = db.Where("product_warehouses.warehouse_id = ?", filters.WarehouseId) + } + + if filters.ProjectFlockKandangId > 0 { + db = db.Where("product_warehouses.project_flock_kandang_id = ?", filters.ProjectFlockKandangId) + } + + if filters.DeliveryDate != "" { + if deliveryDate, err := utils.ParseDateString(filters.DeliveryDate); err == nil { + nextDate := deliveryDate.AddDate(0, 0, 1) + db = db.Where("marketing_delivery_products.delivery_date >= ? AND marketing_delivery_products.delivery_date < ?", deliveryDate, nextDate) + } + } + + if err := db.Count(&total).Error; err != nil { + return nil, 0, err + } + + if err := db. + Offset(offset). + Limit(limit). + Order("marketing_delivery_products.id DESC"). + Find(&deliveryProducts).Error; err != nil { + return nil, 0, err + } + + return deliveryProducts, total, nil +} diff --git a/internal/modules/repports/controllers/repport.controller.go b/internal/modules/repports/controllers/repport.controller.go index 11563f7f..21d3c49a 100644 --- a/internal/modules/repports/controllers/repport.controller.go +++ b/internal/modules/repports/controllers/repport.controller.go @@ -22,7 +22,7 @@ func NewRepportController(repportService service.RepportService) *RepportControl } func (c *RepportController) GetExpense(ctx *fiber.Ctx) error { - query := &validation.Query{ + query := &validation.ExpenseQuery{ Page: ctx.QueryInt("page", 1), Limit: ctx.QueryInt("limit", 10), Search: ctx.Query("search", ""), @@ -59,3 +59,41 @@ func (c *RepportController) GetExpense(ctx *fiber.Ctx) error { Data: result, }) } + +func (c *RepportController) GetMarketing(ctx *fiber.Ctx) error { + query := &validation.MarketingQuery{ + Page: ctx.QueryInt("page", 1), + Limit: ctx.QueryInt("limit", 10), + Search: ctx.Query("search", ""), + CustomerId: int64(ctx.QueryInt("customer_id", 0)), + ProjectFlockKandangId: int64(ctx.QueryInt("project_flock_kandang_id", 0)), + DeliveryDate: ctx.Query("delivery_date", ""), + ProductId: int64(ctx.QueryInt("product_id", 0)), + WarehouseId: int64(ctx.QueryInt("warehouse_id", 0)), + SalesPersonId: int64(ctx.QueryInt("sales_person_id", 0)), + MarketingId: int64(ctx.QueryInt("marketing_id", 0)), + } + + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + + result, totalResults, err := c.RepportService.GetMarketing(ctx, query) + if err != nil { + return err + } + + return ctx.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.RepportMarketingListDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get marketing report successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: result, + }) +} diff --git a/internal/modules/repports/dto/repportMarketing.dto.go b/internal/modules/repports/dto/repportMarketing.dto.go new file mode 100644 index 00000000..9cbd57ba --- /dev/null +++ b/internal/modules/repports/dto/repportMarketing.dto.go @@ -0,0 +1,219 @@ +package dto + +import ( + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" + marketingDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/dto" + customerDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/dto" + productDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/dto" + userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" +) + +// === DTO Structs === + +type RepportMarketingBaseDTO struct { + Id uint `json:"id"` + SoNumber string `json:"so_number"` + SoDate time.Time `json:"so_date"` + Customer *customerDTO.CustomerRelationDTO `json:"customer,omitempty"` + SalesPerson *userDTO.UserRelationDTO `json:"sales_person,omitempty"` + Notes string `json:"notes"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type RepportMarketingProductDTO struct { + Id uint `json:"id"` + MarketingProductId uint `json:"marketing_product_id"` + Qty float64 `json:"qty"` + UnitPrice float64 `json:"unit_price"` + AvgWeight float64 `json:"avg_weight"` + TotalWeight float64 `json:"total_weight"` + TotalPrice float64 `json:"total_price"` + Product *productDTO.ProductRelationDTO `json:"product,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type RepportMarketingDeliveryDTO struct { + Id uint `json:"id"` + MarketingProductId uint `json:"marketing_product_id"` + Qty float64 `json:"qty"` + UnitPrice float64 `json:"unit_price"` + TotalWeight float64 `json:"total_weight"` + AvgWeight float64 `json:"avg_weight"` + TotalPrice float64 `json:"total_price"` + DeliveryDate *time.Time `json:"delivery_date,omitempty"` + VehicleNumber string `json:"vehicle_number"` + DoNumber string `json:"do_number"` + Product *productDTO.ProductRelationDTO `json:"product,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type RepportMarketingListDTO struct { + RepportMarketingBaseDTO + MarketingProduct RepportMarketingProductDTO `json:"marketing_product"` + MarketingDelivery RepportMarketingDeliveryDTO `json:"marketing_delivery"` + TotalMarketingProduct float64 `json:"total_marketing_product"` + TotalMarketingDelivery float64 `json:"total_marketing_delivery"` + LatestApproval *approvalDTO.ApprovalRelationDTO `json:"latest_approval,omitempty"` +} + +// === MAPPERS === + +func ToRepportMarketingBaseDTO(m *entity.Marketing) RepportMarketingBaseDTO { + if m == nil { + return RepportMarketingBaseDTO{} + } + + var customer *customerDTO.CustomerRelationDTO + if m.Customer.Id != 0 { + mapped := customerDTO.ToCustomerRelationDTO(m.Customer) + customer = &mapped + } + + var salesPerson *userDTO.UserRelationDTO + if m.SalesPerson.Id != 0 { + mapped := userDTO.ToUserRelationDTO(m.SalesPerson) + salesPerson = &mapped + } + + return RepportMarketingBaseDTO{ + Id: m.Id, + SoNumber: m.SoNumber, + SoDate: m.SoDate, + Customer: customer, + SalesPerson: salesPerson, + Notes: m.Notes, + CreatedAt: m.CreatedAt, + UpdatedAt: m.UpdatedAt, + } +} + +func ToRepportMarketingProductDTO(mp *entity.MarketingProduct) RepportMarketingProductDTO { + if mp == nil { + return RepportMarketingProductDTO{} + } + + var product *productDTO.ProductRelationDTO + if mp.ProductWarehouse.Product.Id != 0 { + mapped := productDTO.ToProductRelationDTO(mp.ProductWarehouse.Product) + product = &mapped + } + + return RepportMarketingProductDTO{ + Id: mp.Id, + MarketingProductId: mp.Id, + Qty: mp.Qty, + UnitPrice: mp.UnitPrice, + AvgWeight: mp.AvgWeight, + TotalWeight: mp.TotalWeight, + TotalPrice: mp.TotalPrice, + Product: product, + CreatedAt: time.Now(), + } +} + +func ToRepportMarketingDeliveryDTO(mdp *entity.MarketingDeliveryProduct, soNumber string) RepportMarketingDeliveryDTO { + if mdp == nil { + return RepportMarketingDeliveryDTO{} + } + + var product *productDTO.ProductRelationDTO + if mdp.MarketingProduct.ProductWarehouse.Product.Id != 0 { + mapped := productDTO.ToProductRelationDTO(mdp.MarketingProduct.ProductWarehouse.Product) + product = &mapped + } + + warehouseId := uint(0) + if mdp.MarketingProduct.ProductWarehouse.Id != 0 { + warehouseId = mdp.MarketingProduct.ProductWarehouse.WarehouseId + } + + doNumber := marketingDTO.GenerateDeliveryOrderNumber(soNumber, mdp.DeliveryDate, warehouseId) + + return RepportMarketingDeliveryDTO{ + Id: mdp.Id, + MarketingProductId: mdp.MarketingProductId, + Qty: mdp.Qty, + UnitPrice: mdp.UnitPrice, + TotalWeight: mdp.TotalWeight, + AvgWeight: mdp.AvgWeight, + TotalPrice: mdp.TotalPrice, + DeliveryDate: mdp.DeliveryDate, + VehicleNumber: mdp.VehicleNumber, + DoNumber: doNumber, + Product: product, + CreatedAt: time.Now(), + } +} + +func ToRepportMarketingListDTO(baseDTO RepportMarketingBaseDTO, mp *entity.MarketingProduct, mdp *entity.MarketingDeliveryProduct, latestApproval *approvalDTO.ApprovalRelationDTO) RepportMarketingListDTO { + var marketingProduct RepportMarketingProductDTO + var marketingDelivery RepportMarketingDeliveryDTO + + if mp != nil { + marketingProduct = ToRepportMarketingProductDTO(mp) + } + + if mdp != nil { + marketingDelivery = ToRepportMarketingDeliveryDTO(mdp, baseDTO.SoNumber) + } + + totalMarketingProduct := float64(0) + totalMarketingDelivery := float64(0) + + if mp != nil { + totalMarketingProduct = mp.Qty * mp.UnitPrice + } + + if mdp != nil { + totalMarketingDelivery = mdp.Qty * mdp.UnitPrice + } + + return RepportMarketingListDTO{ + RepportMarketingBaseDTO: baseDTO, + MarketingProduct: marketingProduct, + MarketingDelivery: marketingDelivery, + TotalMarketingProduct: totalMarketingProduct, + TotalMarketingDelivery: totalMarketingDelivery, + LatestApproval: latestApproval, + } +} + +func ToRepportMarketingListDTOs(deliveryProducts []entity.MarketingDeliveryProduct) []RepportMarketingListDTO { + result := make([]RepportMarketingListDTO, 0, len(deliveryProducts)) + + marketingMap := make(map[uint]entity.MarketingDeliveryProduct) + for _, dp := range deliveryProducts { + if dp.MarketingProduct.Marketing.Id == 0 { + continue + } + marketingID := dp.MarketingProduct.Marketing.Id + if _, exists := marketingMap[marketingID]; !exists { + marketingMap[marketingID] = dp + } + } + + for _, deliveryProduct := range marketingMap { + if deliveryProduct.MarketingProduct.Marketing.Id == 0 { + continue + } + + marketing := &deliveryProduct.MarketingProduct.Marketing + baseDTO := ToRepportMarketingBaseDTO(marketing) + + var latestApproval *approvalDTO.ApprovalRelationDTO + if marketing.LatestApproval != nil { + mapped := approvalDTO.ToApprovalDTO(*marketing.LatestApproval) + latestApproval = &mapped + } + + mdp := &deliveryProduct + dto := ToRepportMarketingListDTO(baseDTO, &deliveryProduct.MarketingProduct, mdp, latestApproval) + result = append(result, dto) + } + + return result +} diff --git a/internal/modules/repports/module.go b/internal/modules/repports/module.go index 108b0a1b..4479b733 100644 --- a/internal/modules/repports/module.go +++ b/internal/modules/repports/module.go @@ -10,6 +10,7 @@ import ( sRepport "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/services" expenseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" + marketingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" ) type RepportModule struct{} @@ -17,10 +18,11 @@ type RepportModule struct{} func (RepportModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { expenseRealizationRepository := expenseRepo.NewExpenseRealizationRepository(db) + marketingDeliveryProductRepository := marketingRepo.NewMarketingDeliveryProductRepository(db) approvalRepository := commonRepo.NewApprovalRepository(db) approvalSvc := approvalService.NewApprovalService(approvalRepository) - repportService := sRepport.NewRepportService(validate, expenseRealizationRepository, approvalSvc) + repportService := sRepport.NewRepportService(validate, expenseRealizationRepository, marketingDeliveryProductRepository, approvalSvc) RepportRoutes(router, repportService) } diff --git a/internal/modules/repports/route.go b/internal/modules/repports/route.go index 2c8a2276..4aea831c 100644 --- a/internal/modules/repports/route.go +++ b/internal/modules/repports/route.go @@ -13,5 +13,5 @@ func RepportRoutes(v1 fiber.Router, s repport.RepportService) { route := v1.Group("/reports") route.Get("/expense", ctrl.GetExpense) - // route.Get("/marketing", ctrl.GetMarketing) + route.Get("/marketing", ctrl.GetMarketing) } diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go index 15f2d635..3adc5c0a 100644 --- a/internal/modules/repports/services/repport.service.go +++ b/internal/modules/repports/services/repport.service.go @@ -8,6 +8,7 @@ import ( approvalService "gitlab.com/mbugroup/lti-api.git/internal/common/service" approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" expenseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" + marketingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" @@ -16,26 +17,29 @@ import ( ) type RepportService interface { - GetExpense(ctx *fiber.Ctx, params *validation.Query) ([]dto.RepportExpenseListDTO, int64, error) + GetExpense(ctx *fiber.Ctx, params *validation.ExpenseQuery) ([]dto.RepportExpenseListDTO, int64, error) + GetMarketing(ctx *fiber.Ctx, params *validation.MarketingQuery) ([]dto.RepportMarketingListDTO, int64, error) } type repportService struct { Log *logrus.Logger Validate *validator.Validate ExpenseRealizationRepo expenseRepo.ExpenseRealizationRepository + MarketingDeliveryRepo marketingRepo.MarketingDeliveryProductRepository ApprovalSvc approvalService.ApprovalService } -func NewRepportService(validate *validator.Validate, expenseRealizationRepo expenseRepo.ExpenseRealizationRepository, approvalSvc approvalService.ApprovalService) RepportService { +func NewRepportService(validate *validator.Validate, expenseRealizationRepo expenseRepo.ExpenseRealizationRepository, marketingDeliveryRepo marketingRepo.MarketingDeliveryProductRepository, approvalSvc approvalService.ApprovalService) RepportService { return &repportService{ Log: utils.Log, Validate: validate, ExpenseRealizationRepo: expenseRealizationRepo, + MarketingDeliveryRepo: marketingDeliveryRepo, ApprovalSvc: approvalSvc, } } -func (s *repportService) GetExpense(c *fiber.Ctx, params *validation.Query) ([]dto.RepportExpenseListDTO, int64, error) { +func (s *repportService) GetExpense(c *fiber.Ctx, params *validation.ExpenseQuery) ([]dto.RepportExpenseListDTO, int64, error) { if err := s.Validate.Struct(params); err != nil { return nil, 0, err } @@ -72,3 +76,40 @@ func (s *repportService) GetExpense(c *fiber.Ctx, params *validation.Query) ([]d return result, total, nil } + +func (s *repportService) GetMarketing(c *fiber.Ctx, params *validation.MarketingQuery) ([]dto.RepportMarketingListDTO, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + offset := (params.Page - 1) * params.Limit + + deliveryProducts, total, err := s.MarketingDeliveryRepo.GetAllWithFilters(c.Context(), offset, params.Limit, params) + if err != nil { + return nil, 0, err + } + + marketingIDMap := make(map[uint]bool) + marketingIDs := make([]uint, 0) + for _, dp := range deliveryProducts { + if marketingID := dp.MarketingProduct.Marketing.Id; marketingID > 0 && !marketingIDMap[marketingID] { + marketingIDs = append(marketingIDs, marketingID) + marketingIDMap[marketingID] = true + } + } + + approvals, err := s.ApprovalSvc.LatestByTargets(c.Context(), utils.ApprovalWorkflowMarketing, marketingIDs, func(db *gorm.DB) *gorm.DB { + return db.Preload("ActionUser") + }) + if err != nil { + s.Log.Warnf("LatestByTargets error: %v", err) + } + + for i := range deliveryProducts { + if approval, exists := approvals[deliveryProducts[i].MarketingProduct.Marketing.Id]; exists && approval != nil { + deliveryProducts[i].MarketingProduct.Marketing.LatestApproval = approval + } + } + + return dto.ToRepportMarketingListDTOs(deliveryProducts), total, nil +} diff --git a/internal/modules/repports/validations/repport.validation.go b/internal/modules/repports/validations/repport.validation.go index 3d0eb344..7efc51f9 100644 --- a/internal/modules/repports/validations/repport.validation.go +++ b/internal/modules/repports/validations/repport.validation.go @@ -1,6 +1,6 @@ package validation -type Query struct { +type ExpenseQuery struct { Page int `query:"page" validate:"omitempty,min=1,gt=0"` Limit int `query:"limit" validate:"omitempty,min=1,max=100,gt=0"` Search string `query:"search" validate:"omitempty,max=100"` @@ -14,3 +14,16 @@ type Query struct { LocationId int64 `query:"location_id" validate:"omitempty"` RealizationDate string `query:"realization_date" validate:"omitempty"` } + +type MarketingQuery struct { + Page int `query:"page" validate:"omitempty,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,min=1,max=100,gt=0"` + Search string `query:"search" validate:"omitempty,max=100"` + CustomerId int64 `query:"customer_id" validate:"omitempty"` + ProjectFlockKandangId int64 `query:"project_flock_kandang_id" validate:"omitempty"` + DeliveryDate string `query:"delivery_date" validate:"omitempty"` + ProductId int64 `query:"product_id" validate:"omitempty"` + WarehouseId int64 `query:"warehouse_id" validate:"omitempty"` + SalesPersonId int64 `query:"sales_person_id" validate:"omitempty"` + MarketingId int64 `query:"marketing_id" validate:"omitempty"` +} From cf7b3418a552e2c850463bd308b7c77f1f31bced Mon Sep 17 00:00:00 2001 From: ragilap Date: Tue, 16 Dec 2025 10:44:19 +0700 Subject: [PATCH 12/12] 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 }