diff --git a/internal/capabilities/capabilities.go b/internal/capabilities/capabilities.go index 742d7acb..47f774ba 100644 --- a/internal/capabilities/capabilities.go +++ b/internal/capabilities/capabilities.go @@ -3,7 +3,7 @@ package capabilities import ( "strings" - recordings "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings" + permission "gitlab.com/mbugroup/lti-api.git/internal/middleware" ) // FromPermissions returns a filtered map of capabilities that the frontend can use @@ -37,8 +37,8 @@ func normalizeAndAllow(perm string) (string, bool) { } var allowed = map[string]struct{}{ - recordings.PermissionRecordingRead: {}, - recordings.PermissionRecordingCreate: {}, - recordings.PermissionRecordingUpdate: {}, - recordings.PermissionRecordingDelete: {}, + permission.PermissionRecordingRead: {}, + permission.PermissionRecordingCreate: {}, + permission.PermissionRecordingUpdate: {}, + permission.PermissionRecordingDelete: {}, } 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/common/service/common.closing.service.go b/internal/common/service/common.closing.service.go new file mode 100644 index 00000000..3e5e88f8 --- /dev/null +++ b/internal/common/service/common.closing.service.go @@ -0,0 +1,120 @@ +package service + +import ( + "context" + "errors" + "fmt" + + productWarehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + warehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" + projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" +) + +// Dipakai untuk semua module yang butuh cek: +// "PW ini → warehouse → kandang → project_flock_kandang sudah closing atau belum" +func EnsureProjectFlockNotClosedForProductWarehouses( + ctx context.Context, + db *gorm.DB, + productWarehouseIDs []uint, +) error { + if len(productWarehouseIDs) == 0 { + return nil + } + + pwRepo := productWarehouseRepo.NewProductWarehouseRepository(db) + wRepo := warehouseRepo.NewWarehouseRepository(db) + pfkRepo := projectFlockKandangRepo.NewProjectFlockKandangRepository(db) + + seenPW := make(map[uint]struct{}) + seenKandang := make(map[uint]struct{}) + + for _, pwID := range productWarehouseIDs { + if pwID == 0 { + continue + } + if _, ok := seenPW[pwID]; ok { + continue + } + seenPW[pwID] = struct{}{} + + pw, err := pwRepo.GetByID(ctx, pwID, nil) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusBadRequest, + fmt.Sprintf("Product warehouse %d tidak ditemukan", pwID)) + } + return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product warehouse") + } + + wh, err := wRepo.GetByID(ctx, uint(pw.WarehouseId), nil) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusBadRequest, + fmt.Sprintf("Warehouse %d tidak ditemukan", pw.WarehouseId)) + } + return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate warehouse") + } + + // Warehouse tanpa kandang → bukan kandang produksi → skip + if wh.KandangId == nil || *wh.KandangId == 0 { + continue + } + + kandangID := uint(*wh.KandangId) + if _, ok := seenKandang[kandangID]; ok { + continue + } + seenKandang[kandangID] = struct{}{} + + pfk, err := pfkRepo.GetActiveByKandangID(ctx, kandangID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + // nggak ada project aktif untuk kandang ini → aman + continue + } + return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate project flock") + } + // INTI RULE: kalau aktif tapi sudah punya ClosedAt → anggap "project sudah closing" + if pfk != nil && pfk.ClosedAt != nil { + return fiber.NewError(fiber.StatusBadRequest, "Project sudah closing") + } + } + + return nil +} + +func EnsureProjectFlockNotClosedByProjectFlockKandangID( + ctx context.Context, + db *gorm.DB, + pfkIDs []uint, +) error { + pfkRepo := projectFlockKandangRepo.NewProjectFlockKandangRepository(db) + + seen := make(map[uint]struct{}) + for _, id := range pfkIDs { + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + + pfk, err := pfkRepo.GetByID(ctx, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusBadRequest, + fmt.Sprintf("Project flock kandang %d tidak ditemukan", id)) + } + return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate project flock") + } + + if pfk.ClosedAt != nil { + return fiber.NewError(fiber.StatusBadRequest, "Project sudah closing") + } + } + return nil +} diff --git a/internal/database/migrations/20251128081118_add_closing_project_flock_kandangs.down.sql b/internal/database/migrations/20251128081118_add_closing_project_flock_kandangs.down.sql new file mode 100644 index 00000000..2003bc61 --- /dev/null +++ b/internal/database/migrations/20251128081118_add_closing_project_flock_kandangs.down.sql @@ -0,0 +1,3 @@ +DROP INDEX IF EXISTS idx_project_flock_kandangs_closed_at; +ALTER TABLE project_flock_kandangs + DROP COLUMN IF EXISTS closed_at; diff --git a/internal/database/migrations/20251128081118_add_closing_project_flock_kandangs.up.sql b/internal/database/migrations/20251128081118_add_closing_project_flock_kandangs.up.sql new file mode 100644 index 00000000..dc2114b1 --- /dev/null +++ b/internal/database/migrations/20251128081118_add_closing_project_flock_kandangs.up.sql @@ -0,0 +1,5 @@ +ALTER TABLE project_flock_kandangs + ADD COLUMN IF NOT EXISTS closed_at TIMESTAMPTZ; + +CREATE INDEX IF NOT EXISTS idx_project_flock_kandangs_closed_at + ON project_flock_kandangs (closed_at); diff --git a/internal/database/migrations/20251203083339_add_project_flock_kandang_to_product_warehouses.down.sql b/internal/database/migrations/20251203083339_add_project_flock_kandang_to_product_warehouses.down.sql index 38b661a4..059e8ca5 100644 --- a/internal/database/migrations/20251203083339_add_project_flock_kandang_to_product_warehouses.down.sql +++ b/internal/database/migrations/20251203083339_add_project_flock_kandang_to_product_warehouses.down.sql @@ -20,7 +20,7 @@ ALTER TABLE product_warehouses -- Restore audit/soft-delete columns ALTER TABLE product_warehouses - ADD COLUMN IF NOT EXISTS created_by BIGINT NOT NULL REFERENCES users (id), + ADD COLUMN IF NOT EXISTS created_by BIGINT REFERENCES users (id), ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ DEFAULT NOW(), ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT NOW(), ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; diff --git a/internal/database/migrations/20251210044651_create_so_number_sequence.down.sql b/internal/database/migrations/20251210044651_create_so_number_sequence.down.sql new file mode 100644 index 00000000..4d80dd2c --- /dev/null +++ b/internal/database/migrations/20251210044651_create_so_number_sequence.down.sql @@ -0,0 +1,3 @@ +-- Drop function and sequence for sales order numbers +DROP FUNCTION IF EXISTS generate_so_number(); +DROP SEQUENCE IF EXISTS so_number_seq; diff --git a/internal/database/migrations/20251210044651_create_so_number_sequence.up.sql b/internal/database/migrations/20251210044651_create_so_number_sequence.up.sql new file mode 100644 index 00000000..833a8323 --- /dev/null +++ b/internal/database/migrations/20251210044651_create_so_number_sequence.up.sql @@ -0,0 +1,12 @@ +-- Create sequence for sales order numbers +CREATE SEQUENCE so_number_seq START WITH 1 INCREMENT BY 1; + +CREATE OR REPLACE FUNCTION generate_so_number() +RETURNS VARCHAR AS $$ +DECLARE + next_val INTEGER; +BEGIN + next_val := nextval('so_number_seq'); + RETURN 'SO-' || LPAD(next_val::TEXT, 5, '0'); +END; +$$ LANGUAGE plpgsql; diff --git a/internal/database/migrations/20251210125335_add_column_credit_term_purchase_table.down.sql b/internal/database/migrations/20251210125335_add_column_credit_term_purchase_table.down.sql new file mode 100644 index 00000000..866c12b9 --- /dev/null +++ b/internal/database/migrations/20251210125335_add_column_credit_term_purchase_table.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE purchases + DROP COLUMN IF EXISTS credit_term; diff --git a/internal/database/migrations/20251210125335_add_column_credit_term_purchase_table.up.sql b/internal/database/migrations/20251210125335_add_column_credit_term_purchase_table.up.sql new file mode 100644 index 00000000..2cae8d6a --- /dev/null +++ b/internal/database/migrations/20251210125335_add_column_credit_term_purchase_table.up.sql @@ -0,0 +1,5 @@ +ALTER TABLE purchases + ADD COLUMN IF NOT EXISTS credit_term INT NOT NULL DEFAULT 0; + +ALTER TABLE purchases + ALTER COLUMN credit_term DROP DEFAULT; diff --git a/internal/entities/kandang.go b/internal/entities/kandang.go index 7c083d95..e4db5655 100644 --- a/internal/entities/kandang.go +++ b/internal/entities/kandang.go @@ -20,5 +20,6 @@ type Kandang struct { CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` Location Location `gorm:"foreignKey:LocationId;references:Id"` Pic User `gorm:"foreignKey:PicId;references:Id"` + Warehouses []Warehouse `gorm:"foreignKey:KandangId;references:Id"` ProjectFlockKandangs []ProjectFlockKandang `gorm:"foreignKey:KandangId;references:Id" json:"-"` } diff --git a/internal/entities/projectflock_kandang.go b/internal/entities/projectflock_kandang.go index d4bd7452..0ce4fc25 100644 --- a/internal/entities/projectflock_kandang.go +++ b/internal/entities/projectflock_kandang.go @@ -3,11 +3,12 @@ package entities import "time" type ProjectFlockKandang struct { - Id uint `gorm:"primaryKey"` - ProjectFlockId uint `gorm:"not null;index:idx_project_flock_kandangs_project;uniqueIndex:idx_project_flock_kandangs_unique"` - KandangId uint `gorm:"not null;index:idx_project_flock_kandangs_kandang;uniqueIndex:idx_project_flock_kandangs_unique"` - Period int `gorm:"not null"` - CreatedAt time.Time `gorm:"autoCreateTime"` + Id uint `gorm:"primaryKey"` + ProjectFlockId uint `gorm:"not null;index:idx_project_flock_kandangs_project;uniqueIndex:idx_project_flock_kandangs_unique"` + KandangId uint `gorm:"not null;index:idx_project_flock_kandangs_kandang;uniqueIndex:idx_project_flock_kandangs_unique"` + Period int `gorm:"not null"` + ClosedAt *time.Time `gorm:"index"` + CreatedAt time.Time `gorm:"autoCreateTime"` ProjectFlock ProjectFlock `gorm:"foreignKey:ProjectFlockId;references:Id"` Kandang Kandang `gorm:"foreignKey:KandangId;references:Id"` diff --git a/internal/entities/purchase.go b/internal/entities/purchase.go index fe9b7100..66b88c63 100644 --- a/internal/entities/purchase.go +++ b/internal/entities/purchase.go @@ -5,17 +5,18 @@ import ( ) type Purchase struct { - Id uint `gorm:"primaryKey;autoIncrement"` + Id uint `gorm:"primaryKey;autoIncrement"` PrNumber string `gorm:"not null"` PoNumber *string PoDate *time.Time SupplierId uint `gorm:"not null"` + CreditTerm int `gorm:"column:credit_term;not null;default:0"` DueDate *time.Time Notes *string CreatedAt time.Time `gorm:"autoCreateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"` DeletedAt *time.Time `gorm:"index"` - CreatedBy uint `gorm:"not null"` + CreatedBy uint `gorm:"not null"` // Relations Supplier Supplier `gorm:"foreignKey:SupplierId;references:Id"` diff --git a/internal/entities/purchase_item.go b/internal/entities/purchase_item.go index 22cb62ed..724c6376 100644 --- a/internal/entities/purchase_item.go +++ b/internal/entities/purchase_item.go @@ -23,6 +23,7 @@ type PurchaseItem struct { ExpenseNonstockId *uint64 // Relations + ExpenseNonstock *ExpenseNonstock `gorm:"foreignKey:ExpenseNonstockId;references:Id"` Purchase *Purchase `gorm:"foreignKey:PurchaseId;references:Id"` Product *Product `gorm:"foreignKey:ProductId;references:Id"` Warehouse *Warehouse `gorm:"foreignKey:WarehouseId;references:Id"` diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index cf5ce1f3..a831c25b 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -4,12 +4,12 @@ import ( "strings" "github.com/gofiber/fiber/v2" + "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" "gitlab.com/mbugroup/lti-api.git/internal/sso" "gitlab.com/mbugroup/lti-api.git/internal/utils" - "gitlab.com/mbugroup/lti-api.git/internal/config" ) const ( @@ -90,7 +90,6 @@ func Auth(userService service.UserService, requiredScopes ...string) fiber.Handl c.Locals(authContextLocalsKey, ctx) c.Locals(authUserLocalsKey, user) - return c.Next() } } @@ -106,7 +105,7 @@ 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 { + if !ok || user == nil || user.Id == 0 { return 0, fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") } return user.Id, nil @@ -265,4 +264,4 @@ func canonicalPermissions(perms []string) []string { func canonicalPermission(perm string) string { return strings.ToLower(strings.TrimSpace(perm)) -} \ No newline at end of file +} diff --git a/internal/modules/closings/controllers/closing.controller.go b/internal/modules/closings/controllers/closing.controller.go index a9282f21..a04fc5f9 100644 --- a/internal/modules/closings/controllers/closing.controller.go +++ b/internal/modules/closings/controllers/closing.controller.go @@ -3,6 +3,7 @@ package controller import ( "math" "strconv" + "strings" "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/dto" service "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/services" @@ -13,12 +14,14 @@ import ( ) type ClosingController struct { - ClosingService service.ClosingService + ClosingService service.ClosingService + SapronakService service.SapronakService } -func NewClosingController(closingService service.ClosingService) *ClosingController { +func NewClosingController(closingService service.ClosingService, sapronakService service.SapronakService) *ClosingController { return &ClosingController{ - ClosingService: closingService, + ClosingService: closingService, + SapronakService: sapronakService, } } @@ -39,17 +42,17 @@ func (u *ClosingController) GetAll(c *fiber.Ctx) error { } return c.Status(fiber.StatusOK). - JSON(response.SuccessWithPaginate[dto.ClosingListDTO]{ + JSON(response.SuccessWithPaginate[dto.ClosingListItemDTO]{ Code: fiber.StatusOK, Status: "success", - Message: "Get all closings successfully", + Message: "Retrieved closing projects list successfully", Meta: response.Meta{ Page: query.Page, Limit: query.Limit, TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), TotalResults: totalResults, }, - Data: dto.ToClosingListDTOs(result), + Data: result, }) } @@ -123,3 +126,122 @@ func (u *ClosingController) GetPenjualan(c *fiber.Ctx) error { Data: dto.ToPenjualanRealisasiResponseDTO(projectFlock.Category, uint(projectFlockID), result), }) } + +func (u *ClosingController) GetOverhead(c *fiber.Ctx) error { + param := c.Params("project_flock_id") + + projectFlockID, err := strconv.Atoi(param) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Project Flock Id") + } + + result, err := u.ClosingService.GetOverhead(c, uint(projectFlockID)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get overhead successfully", + Data: result, + }) +} + +func (u *ClosingController) GetClosingSapronak(c *fiber.Ctx) error { + param := c.Params("projectFlockId") + + id, err := strconv.Atoi(param) + if err != nil || id <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid projectFlockId") + } + + query := &validation.ClosingSapronakQuery{ + Type: strings.ToLower(c.Query("type")), + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + } + + if query.Page < 1 || query.Limit < 1 { + return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0") + } + + if query.Type != validation.SapronakTypeIncoming && query.Type != validation.SapronakTypeOutgoing { + return fiber.NewError(fiber.StatusBadRequest, "type must be either incoming or outgoing") + } + + result, totalResults, err := u.ClosingService.GetClosingSapronak(c, uint(id), query) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.ClosingSapronakItemDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Retrieved closing report (sapronak) successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + Data: result, + }) +} + +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), flag) + if err != nil { + return err + } + + payload := dto.ToSapronakProjectAggregatedFromReports(result, flag) + + 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") + flag := c.Query("flag", "") + + 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), flag) + if err != nil { + return err + } + + payload := dto.ToSapronakProjectAggregatedFromReport(result, flag) + + 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/closing.dto.go b/internal/modules/closings/dto/closing.dto.go index 6a280312..1f1cb492 100644 --- a/internal/modules/closings/dto/closing.dto.go +++ b/internal/modules/closings/dto/closing.dto.go @@ -27,20 +27,35 @@ type ClosingDetailDTO struct { ClosingListDTO } +type ClosingListItemDTO struct { + Id uint `json:"id"` + LocationID uint `json:"location_id"` + LocationName string `json:"location_name"` + ProjectCategory string `json:"project_category"` + Period int `json:"period"` + ClosingDate string `json:"closing_date"` + ShedLabel string `json:"shed_label"` + ShedCount int `json:"shed_count"` + SalesPaidAmount int64 `json:"sales_paid_amount"` + SalesRemainingAmount int64 `json:"sales_remaining_amount"` + SalesPaymentStatus string `json:"sales_payment_status"` + ProjectStatus string `json:"project_status"` +} + type ClosingSummaryDTO struct { - LocationID uint `json:"location_id"` - Periode int `json:"periode"` - JenisProduk string `json:"jenis_produk"` - LabelPopulasi string `json:"label_populasi"` - JumlahPopulasi int `json:"jumlah_populasi"` - JumlahPopulasiFormatted string `json:"jumlah_populasi_formatted"` - JenisProject string `json:"jenis_project"` - KandangAktif int `json:"kandang_aktif"` - KandangAktifFormatted string `json:"kandang_aktif_formatted"` - StatusPembayaranPenjualan string `json:"status_pembayaran_penjualan"` - StatusPembayaranMitra string `json:"status_pembayaran_mitra"` - StatusProject string `json:"status_project"` - StatusClosing string `json:"status_closing"` + FlockID uint `json:"flock_id"` + Period int `json:"period"` + // JenisProduk string `json:"jenis_produk"` + // LabelPopulasi string `json:"label_populasi"` + Population int `json:"population"` + PopulationFormatted string `json:"population_formatted"` + ProjectType string `json:"project_type"` + ActiveHouseCount int `json:"active_house_count"` + ActiveHouseLabel string `json:"active_house_label"` + SalesPaymentStatus string `json:"sales_payment_status"` + // StatusPembayaranMitra string `json:"status_pembayaran_mitra"` + StatusProject string `json:"project_status"` + StatusClosing string `json:"closing_status"` } func ToClosingSummaryDTO(project entity.ProjectFlock, statusProject, statusClosing string) ClosingSummaryDTO { @@ -52,19 +67,38 @@ func ToClosingSummaryDTO(project entity.ProjectFlock, statusProject, statusClosi populationInt := int(population) return ClosingSummaryDTO{ - LocationID: project.LocationId, - Periode: period, - JenisProduk: project.Category, - LabelPopulasi: "", - JumlahPopulasi: populationInt, - JumlahPopulasiFormatted: fmt.Sprintf("%d Ekor", populationInt), - JenisProject: "", - KandangAktif: kandangCount, - KandangAktifFormatted: fmt.Sprintf("%d Kandang", kandangCount), - StatusPembayaranPenjualan: "Tempo", - StatusPembayaranMitra: "", - StatusProject: statusProject, - StatusClosing: statusClosing, + FlockID: project.Id, + Period: period, + // JenisProduk: project.Category, + // LabelPopulasi: "", + Population: populationInt, + PopulationFormatted: fmt.Sprintf("%d Ekor", populationInt), + ProjectType: project.Category, + ActiveHouseCount: kandangCount, + ActiveHouseLabel: fmt.Sprintf("%d Kandang", kandangCount), + SalesPaymentStatus: "Tempo", + // StatusPembayaranMitra: "", + StatusProject: statusProject, + StatusClosing: statusClosing, + } +} + +func ToClosingListItemDTO(project entity.ProjectFlock, projectStatus string) ClosingListItemDTO { + shedCount := len(project.KandangHistory) + + return ClosingListItemDTO{ + Id: project.Id, + LocationID: project.LocationId, + LocationName: project.Location.Name, + ProjectCategory: project.Category, + Period: maxPeriod(project.KandangHistory), + ClosingDate: "17-Nov-2025", + ShedLabel: fmt.Sprintf("%d Kandang", shedCount), + ShedCount: shedCount, + SalesPaidAmount: 21993726, + SalesRemainingAmount: 11075919, + SalesPaymentStatus: "Lunas", + ProjectStatus: projectStatus, } } diff --git a/internal/modules/closings/dto/closingMarketing.dto.go b/internal/modules/closings/dto/closingMarketing.dto.go index 4c47a7e0..ea0ddb81 100644 --- a/internal/modules/closings/dto/closingMarketing.dto.go +++ b/internal/modules/closings/dto/closingMarketing.dto.go @@ -4,14 +4,13 @@ import ( "time" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" - deliveryOrdersDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/delivery-orderss/dto" + deliveryOrdersDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/dto" customerDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/dto" kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto" productDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/dto" ) // === Response DTO === - type SalesDTO struct { Id uint `json:"id"` RealizationDate time.Time `json:"realization_date"` @@ -29,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 === @@ -88,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/closings/dto/closingOverhead.dto.go b/internal/modules/closings/dto/closingOverhead.dto.go new file mode 100644 index 00000000..95f3e10b --- /dev/null +++ b/internal/modules/closings/dto/closingOverhead.dto.go @@ -0,0 +1,175 @@ +package dto + +import ( + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" +) + +// === DTO Structs === + +type OverheadDTO struct { + ItemName string `json:"item_name"` + UOMName string `json:"uom_name"` + BudgetQuantity float64 `json:"budget_quantity"` + BudgetUnitPrice float64 `json:"budget_unit_price"` + BudgetTotalAmount float64 `json:"budget_total_amount"` + ActualDate string `json:"actual_date"` + ActualQuantity float64 `json:"actual_quantity"` + ActualUnitPrice float64 `json:"actual_unit_price"` + ActualTotalAmount float64 `json:"actual_total_amount"` + CostPerBird float64 `json:"cost_per_bird"` +} + +type TotalDTO struct { + BudgetQuantity float64 `json:"budget_quantity"` + BudgetTotalAmount float64 `json:"budget_total_amount"` + ActualQuantity float64 `json:"actual_quantity"` + ActualTotalAmount float64 `json:"actual_total_amount"` + CostPerBird float64 `json:"cost_per_bird"` +} + +type OverheadListDTO struct { + Total TotalDTO `json:"total"` + Overheads []OverheadDTO `json:"overheads"` +} + +// === Mapper Functions === + +func ToOverheadDTO(budget *entity.ProjectBudget, realization *entity.ExpenseRealization) OverheadDTO { + if budget == nil && realization == nil { + return OverheadDTO{} + } + + var itemName, itemUOM string + if budget != nil { + itemName, itemUOM = getItemInfo(budget.Nonstock) + } + + if itemName == "" && realization != nil && realization.ExpenseNonstock != nil { + itemName, itemUOM = getItemInfo(realization.ExpenseNonstock.Nonstock) + } + + dto := OverheadDTO{ + ItemName: itemName, + UOMName: itemUOM, + } + + if budget != nil { + dto.BudgetQuantity = budget.Qty + dto.BudgetUnitPrice = budget.Price + dto.BudgetTotalAmount = calculateTotal(budget.Qty, budget.Price) + } + + if realization != nil { + dto.ActualQuantity = realization.Qty + dto.ActualUnitPrice = realization.Price + dto.ActualTotalAmount = calculateTotal(realization.Qty, realization.Price) + dto.ActualDate = formatRealizationDate(realization) + } + + return dto +} + +func ToOverheadListDTOs(budgets []entity.ProjectBudget, realizations []entity.ExpenseRealization, totalChickinQty float64) OverheadListDTO { + overheadsByNonstockID := make(map[uint]*OverheadDTO) + latestDateByNonstockID := make(map[uint]string) + + for i := range budgets { + nonstockID := budgets[i].NonstockId + if overheadsByNonstockID[nonstockID] == nil { + overheadsByNonstockID[nonstockID] = &OverheadDTO{} + } + + itemName, itemUOM := getItemInfo(budgets[i].Nonstock) + overheadsByNonstockID[nonstockID].ItemName = itemName + overheadsByNonstockID[nonstockID].UOMName = itemUOM + overheadsByNonstockID[nonstockID].BudgetQuantity = budgets[i].Qty + overheadsByNonstockID[nonstockID].BudgetUnitPrice = budgets[i].Price + overheadsByNonstockID[nonstockID].BudgetTotalAmount = calculateTotal(budgets[i].Qty, budgets[i].Price) + } + + for i := range realizations { + if realizations[i].ExpenseNonstock == nil || realizations[i].ExpenseNonstock.NonstockId == nil { + continue + } + + nonstockID := uint(*realizations[i].ExpenseNonstock.NonstockId) + if overheadsByNonstockID[nonstockID] == nil { + overheadsByNonstockID[nonstockID] = &OverheadDTO{} + } + + overheadsByNonstockID[nonstockID].ActualQuantity += realizations[i].Qty + overheadsByNonstockID[nonstockID].ActualTotalAmount += calculateTotal(realizations[i].Qty, realizations[i].Price) + + if overheadsByNonstockID[nonstockID].ItemName == "" { + itemName, itemUOM := getItemInfo(realizations[i].ExpenseNonstock.Nonstock) + overheadsByNonstockID[nonstockID].ItemName = itemName + overheadsByNonstockID[nonstockID].UOMName = itemUOM + } + + realizationDateStr := formatRealizationDate(&realizations[i]) + if realizationDateStr != "" { + if latestDateByNonstockID[nonstockID] == "" || realizationDateStr > latestDateByNonstockID[nonstockID] { + latestDateByNonstockID[nonstockID] = realizationDateStr + } + } + } + + var totalBudgetQuantity, totalBudgetAmount, totalActualQuantity, totalActualAmount float64 + overheadItems := make([]OverheadDTO, 0, len(overheadsByNonstockID)) + + for nonstockID, overhead := range overheadsByNonstockID { + overhead.ActualDate = latestDateByNonstockID[nonstockID] + overhead.CostPerBird = calculateCostPerBird(overhead.ActualTotalAmount, totalChickinQty) + + if overhead.ActualQuantity > 0 { + overhead.ActualUnitPrice = overhead.ActualTotalAmount / overhead.ActualQuantity + } + + totalBudgetQuantity += overhead.BudgetQuantity + totalBudgetAmount += overhead.BudgetTotalAmount + totalActualQuantity += overhead.ActualQuantity + totalActualAmount += overhead.ActualTotalAmount + + overheadItems = append(overheadItems, *overhead) + } + + return OverheadListDTO{ + Total: TotalDTO{ + BudgetQuantity: totalBudgetQuantity, + BudgetTotalAmount: totalBudgetAmount, + ActualQuantity: totalActualQuantity, + ActualTotalAmount: totalActualAmount, + CostPerBird: calculateCostPerBird(totalActualAmount, totalChickinQty), + }, + Overheads: overheadItems, + } +} + +// === Helper Functions === + +func getItemInfo(nonstock *entity.Nonstock) (string, string) { + if nonstock != nil && nonstock.Id != 0 { + return nonstock.Name, nonstock.Uom.Name + } + return "", "" +} + +func calculateTotal(qty, price float64) float64 { + return qty * price +} + +func calculateCostPerBird(totalPrice, totalChickinQty float64) float64 { + if totalChickinQty > 0 { + return totalPrice / totalChickinQty + } + return 0 +} + +func formatRealizationDate(realization *entity.ExpenseRealization) string { + if realization != nil && realization.ExpenseNonstock != nil && realization.ExpenseNonstock.Expense != nil { + if !realization.ExpenseNonstock.Expense.RealizationDate.IsZero() { + return realization.ExpenseNonstock.Expense.RealizationDate.Format("2006-01-02T15:04:05Z07:00") + } + } + return "" +} diff --git a/internal/modules/closings/dto/sapronak.dto.go b/internal/modules/closings/dto/sapronak.dto.go new file mode 100644 index 00000000..13044efd --- /dev/null +++ b/internal/modules/closings/dto/sapronak.dto.go @@ -0,0 +1,252 @@ +package dto + +import ( + "strings" + "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,omitempty"` + Ovk *SapronakCategoryDTO `json:"ovk,omitempty"` + Pakan *SapronakCategoryDTO `json:"pakan,omitempty"` + Pullet *SapronakCategoryDTO `json:"pullet,omitempty"` +} + +type ClosingSapronakItemDTO struct { + Id uint64 `json:"id"` + Date string `json:"date"` + ReferenceNumber string `json:"reference_number"` + TransactionType string `json:"transaction_type"` + ProductName string `json:"product_name"` + ProductCategory string `json:"product_category"` + ProductSubCategory string `json:"product_sub_category"` + SourceWarehouse string `json:"source_warehouse"` + DestinationWarehouse string `json:"destination_warehouse,omitempty"` + // Destination string `json:"destination,omitempty"` + Quantity float64 `json:"quantity"` + Unit string `json:"unit"` + FormattedQuantity string `json:"formatted_quantity"` + Notes string `json:"notes"` + SortDate time.Time `json:"-"` +} + +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 77941256..c3de4a86 100644 --- a/internal/modules/closings/module.go +++ b/internal/modules/closings/module.go @@ -9,7 +9,9 @@ import ( commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" rClosing "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/repositories" sClosing "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/services" - rMarketings "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/repositories" + rExpenseRealization "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" + rMarketings "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" + rChickin "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories" rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" @@ -22,13 +24,18 @@ 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) + expenseRealizationRepo := rExpenseRealization.NewExpenseRealizationRepository(db) + chickinRepo := rChickin.NewChickinRepository(db) approvalRepo := commonRepo.NewApprovalRepository(db) approvalService := commonSvc.NewApprovalService(approvalRepo) - closingService := sClosing.NewClosingService(closingRepo, projectFlockRepo, marketingRepo, marketingDeliveryProductRepo, approvalService, validate) + closingService := sClosing.NewClosingService(closingRepo, projectFlockRepo, marketingRepo, marketingDeliveryProductRepo, approvalService, expenseRealizationRepo, projectBudgetRepo, chickinRepo, validate) + sapronakService := sClosing.NewSapronakService(closingRepo, projectFlockKandangRepo, 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..7b568801 100644 --- a/internal/modules/closings/repositories/closing.repository.go +++ b/internal/modules/closings/repositories/closing.repository.go @@ -1,13 +1,29 @@ package repository import ( + "context" + "fmt" + "strings" + "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] + GetSapronak(ctx context.Context, params SapronakQueryParams) ([]SapronakRow, int64, 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 { @@ -19,3 +35,566 @@ func NewClosingRepository(db *gorm.DB) ClosingRepository { BaseRepositoryImpl: repository.NewBaseRepository[entity.ProjectFlock](db), } } + +type SapronakRow struct { + Id uint64 `gorm:"column:id"` + SortDate time.Time `gorm:"column:sort_date"` + DateText string `gorm:"column:date_text"` + ReferenceNumber string `gorm:"column:reference_number"` + TransactionType string `gorm:"column:transaction_type"` + ProductName string `gorm:"column:product_name"` + ProductCategory string `gorm:"column:product_category"` + ProductSubCategory string `gorm:"column:product_sub_category"` + SourceWarehouse string `gorm:"column:source_warehouse"` + DestinationWarehouse string `gorm:"column:destination_warehouse"` + Destination string `gorm:"column:destination"` + Quantity float64 `gorm:"column:quantity"` + Unit string `gorm:"column:unit"` + Notes string `gorm:"column:notes"` +} + +type SapronakQueryParams struct { + Type string + WarehouseIDs []uint + ProjectFlockKandangIDs []uint + Limit int + Offset int +} + +func (r *ClosingRepositoryImpl) GetSapronak(ctx context.Context, params SapronakQueryParams) ([]SapronakRow, int64, error) { + db := r.DB().WithContext(ctx) + + var ( + unionParts []string + args []any + ) + + switch params.Type { + case validation.SapronakTypeIncoming: + if len(params.WarehouseIDs) == 0 { + return []SapronakRow{}, 0, nil + } + unionParts = append(unionParts, sapronakIncomingPurchasesSQL, sapronakIncomingTransfersSQL) + args = append(args, params.WarehouseIDs, params.WarehouseIDs) + case validation.SapronakTypeOutgoing: + if len(params.WarehouseIDs) > 0 { + unionParts = append(unionParts, sapronakOutgoingTransfersSQL) + args = append(args, params.WarehouseIDs) + } + if len(params.ProjectFlockKandangIDs) > 0 { + unionParts = append(unionParts, sapronakOutgoingMarketingsSQL) + args = append(args, params.ProjectFlockKandangIDs) + } + if len(unionParts) == 0 { + return []SapronakRow{}, 0, nil + } + default: + return nil, 0, fmt.Errorf("invalid sapronak type: %s", params.Type) + } + + unionSQL := strings.Join(unionParts, " UNION ALL ") + + var totalResults int64 + countSQL := fmt.Sprintf("SELECT COUNT(*) FROM (%s) AS combined", unionSQL) + if err := db.Raw(countSQL, args...).Scan(&totalResults).Error; err != nil { + return nil, 0, err + } + + dataArgs := append(append([]any{}, args...), params.Limit, params.Offset) + dataSQL := fmt.Sprintf("SELECT * FROM (%s) AS combined ORDER BY sort_date ASC, id ASC LIMIT ? OFFSET ?", unionSQL) + + var rows []SapronakRow + if err := db.Raw(dataSQL, dataArgs...).Scan(&rows).Error; err != nil { + return nil, 0, err + } + + return rows, totalResults, nil +} + +const ( + sapronakIncomingPurchasesSQL = ` +SELECT + CAST(pi.id AS BIGINT) AS id, + COALESCE(pi.received_date, '1970-01-01') AS sort_date, + COALESCE(TO_CHAR(pi.received_date, 'DD-Mon-YYYY'), '') AS date_text, + COALESCE(p.po_number, '') AS reference_number, + 'Purchase' AS transaction_type, + prod.name AS product_name, + pc.name AS product_category, + COALESCE(( + SELECT string_agg(f.name, ' ') + FROM flags f + WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id + ), '') AS product_sub_category, + 'External Supplier' AS source_warehouse, + w.name AS destination_warehouse, + '' AS destination, + pi.total_qty AS quantity, + u.name AS unit, + COALESCE(p.notes, '') AS notes +FROM purchase_items pi +JOIN purchases p ON p.id = pi.purchase_id +JOIN products prod ON prod.id = pi.product_id +JOIN product_categories pc ON pc.id = prod.product_category_id +JOIN uoms u ON u.id = prod.uom_id +JOIN warehouses w ON w.id = pi.warehouse_id +WHERE pi.warehouse_id IN ? +` + + sapronakIncomingTransfersSQL = ` +SELECT + CAST(st.id AS BIGINT) AS id, + st.transfer_date AS sort_date, + TO_CHAR(st.transfer_date, 'DD-Mon-YYYY') AS date_text, + st.movement_number AS reference_number, + 'Internal Transfer In' AS transaction_type, + prod.name AS product_name, + pc.name AS product_category, + COALESCE(( + SELECT string_agg(f.name, ' ') + FROM flags f + WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id + ), '') AS product_sub_category, + COALESCE(fw.name, '') AS source_warehouse, + COALESCE(tw.name, '') AS destination_warehouse, + '' AS destination, + std.quantity AS quantity, + u.name AS unit, + 'Stock Refill' AS notes +FROM stock_transfer_details std +JOIN stock_transfers st ON st.id = std.stock_transfer_id +LEFT JOIN warehouses fw ON fw.id = st.from_warehouse_id +LEFT JOIN warehouses tw ON tw.id = st.to_warehouse_id +JOIN products prod ON prod.id = std.product_id +JOIN product_categories pc ON pc.id = prod.product_category_id +JOIN uoms u ON u.id = prod.uom_id +WHERE st.to_warehouse_id IN ? +` + + sapronakOutgoingTransfersSQL = ` +SELECT + CAST(st.id AS BIGINT) AS id, + st.transfer_date AS sort_date, + TO_CHAR(st.transfer_date, 'DD-Mon-YYYY') AS date_text, + st.movement_number AS reference_number, + 'Internal Transfer Out' AS transaction_type, + prod.name AS product_name, + pc.name AS product_category, + COALESCE(( + SELECT string_agg(f.name, ' ') + FROM flags f + WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id + ), '') AS product_sub_category, + COALESCE(fw.name, '') AS source_warehouse, + '' AS destination_warehouse, + COALESCE(tw.name, '') AS destination, + std.quantity AS quantity, + u.name AS unit, + 'Transfer to other unit' AS notes +FROM stock_transfer_details std +JOIN stock_transfers st ON st.id = std.stock_transfer_id +LEFT JOIN warehouses fw ON fw.id = st.from_warehouse_id +LEFT JOIN warehouses tw ON tw.id = st.to_warehouse_id +JOIN products prod ON prod.id = std.product_id +JOIN product_categories pc ON pc.id = prod.product_category_id +JOIN uoms u ON u.id = prod.uom_id +WHERE st.from_warehouse_id IN ? +` + + sapronakOutgoingMarketingsSQL = ` +SELECT + CAST(mp.id AS BIGINT) AS id, + m.so_date AS sort_date, + TO_CHAR(m.so_date, 'DD-Mon-YYYY') AS date_text, + m.so_number AS reference_number, + 'Trading Sales' AS transaction_type, + prod.name AS product_name, + pc.name AS product_category, + COALESCE(( + SELECT string_agg(f.name, ' ') + FROM flags f + WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id + ), '') AS product_sub_category, + w.name AS source_warehouse, + '' AS destination_warehouse, + 'RETAIL CUSTOMER' AS destination, + mp.qty AS quantity, + u.name AS unit, + m.notes AS notes +FROM marketing_products mp +JOIN marketings m ON m.id = mp.marketing_id +JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id +JOIN products prod ON prod.id = pw.product_id +JOIN product_categories pc ON pc.id = prod.product_category_id +JOIN uoms u ON u.id = prod.uom_id +JOIN warehouses w ON w.id = pw.warehouse_id +WHERE pw.project_flock_kandang_id IN ? +` +) + +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) 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) + } + } + return db +} + +func sapronakFlags(flags ...utils.FlagType) []string { + out := make([]string, len(flags)) + for i, f := range flags { + out[i] = string(f) + } + return out +} + +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 { + m[row.ProductID] = append(m[row.ProductID], row) + } + return m +} + +func scanAndGroupDetails(db *gorm.DB) (map[uint][]SapronakDetailRow, error) { + rows := make([]SapronakDetailRow, 0) + if err := db.Scan(&rows).Error; err != nil { + return nil, err + } + return groupSapronakDetails(rows), nil +} + +// ========================= +// Usage (summary + details) +// ========================= + +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, + 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 + `, + "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 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") + + db = applyJoins(db, joins...) + + 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 + } + + return rows, nil +} + +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 { + base := SapronakDetailRow{ + ProductID: row.ProductID, + ProductName: row.ProductName, + Flag: row.Flag, + Date: row.CreatedAt, + Reference: refFn(row), + Price: row.Price, + } + + if row.Increase > 0 { + d := base + d.QtyIn = row.Increase + in[row.ProductID] = append(in[row.ProductID], d) + } + if row.Decrease > 0 { + d := base + d.QtyOut = row.Decrease + out[row.ProductID] = append(out[row.ProductID], d) + } + } + + return in, out +} + +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 + } + 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 059eb764..5033f989 100644 --- a/internal/modules/closings/route.go +++ b/internal/modules/closings/route.go @@ -9,11 +9,12 @@ 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) { + ctrl := controller.NewClosingController(s, sapronakSvc) - route := v1.Group("/closing") + route := v1.Group("/closings") route.Use(m.Auth(u)) + // route.Get("/", m.Auth(u), ctrl.GetAll) // route.Post("/", m.Auth(u), ctrl.CreateOne) // route.Get("/:id", m.Auth(u), ctrl.GetOne) @@ -23,4 +24,11 @@ func ClosingRoutes(v1 fiber.Router, u user.UserService, s closing.ClosingService route.Get("/",m.RequirePermissions(m.P_ClosingGetAll), ctrl.GetAll) route.Get("/:project_flock_id/penjualan",m.RequirePermissions(m.P_ClosingPenjualan), ctrl.GetPenjualan) route.Get("/:projectFlockId",m.RequirePermissions(m.P_ClosingGetSummary), ctrl.GetClosingSummary) + route.Get("/", ctrl.GetAll) + route.Get("/:project_flock_id/penjualan", ctrl.GetPenjualan) + route.Get("/:project_flock_id/overhead", ctrl.GetOverhead) + 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) + route.Get("/:projectFlockId/sapronak", ctrl.GetClosingSapronak) } diff --git a/internal/modules/closings/services/closing.service.go b/internal/modules/closings/services/closing.service.go index 7fcd51ec..b1780359 100644 --- a/internal/modules/closings/services/closing.service.go +++ b/internal/modules/closings/services/closing.service.go @@ -3,14 +3,17 @@ package service import ( "context" "errors" + "strconv" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" 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" - marketingDeliveryProductRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/repositories" - marketingRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/repositories" + expenseRealizationRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" + marketingDeliveryProductRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" + marketingRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" + chickinRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories" projectflockRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" @@ -22,10 +25,12 @@ import ( ) type ClosingService interface { - GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlock, int64, error) + GetAll(ctx *fiber.Ctx, params *validation.Query) ([]dto.ClosingListItemDTO, int64, error) GetProjectFlockByID(ctx *fiber.Ctx, id uint) (*entity.ProjectFlock, error) GetPenjualan(ctx *fiber.Ctx, projectFlockID uint) ([]entity.MarketingDeliveryProduct, error) GetClosingSummary(ctx *fiber.Ctx, projectFlockID uint) (*dto.ClosingSummaryDTO, error) + GetOverhead(ctx *fiber.Ctx, projectFlockID uint) (*dto.OverheadListDTO, error) + GetClosingSapronak(ctx *fiber.Ctx, projectFlockID uint, params *validation.ClosingSapronakQuery) ([]dto.ClosingSapronakItemDTO, int64, error) } type closingService struct { @@ -36,9 +41,12 @@ type closingService struct { MarketingRepo marketingRepository.MarketingRepository MarketingDeliveryProductRepo marketingDeliveryProductRepository.MarketingDeliveryProductRepository ApprovalSvc commonSvc.ApprovalService + ExpenseRealizationRepo expenseRealizationRepository.ExpenseRealizationRepository + ProjectBudgetRepo projectflockRepository.ProjectBudgetRepository + ChickinRepo chickinRepository.ProjectChickinRepository } -func NewClosingService(repo repository.ClosingRepository, projectFlockRepo projectflockRepository.ProjectflockRepository, marketingRepo marketingRepository.MarketingRepository, marketingDeliveryProductRepo marketingDeliveryProductRepository.MarketingDeliveryProductRepository, approvalSvc commonSvc.ApprovalService, validate *validator.Validate) ClosingService { +func NewClosingService(repo repository.ClosingRepository, projectFlockRepo projectflockRepository.ProjectflockRepository, marketingRepo marketingRepository.MarketingRepository, marketingDeliveryProductRepo marketingDeliveryProductRepository.MarketingDeliveryProductRepository, approvalSvc commonSvc.ApprovalService, expenseRealizationRepo expenseRealizationRepository.ExpenseRealizationRepository, projectBudgetRepo projectflockRepository.ProjectBudgetRepository, chickinRepo chickinRepository.ProjectChickinRepository, validate *validator.Validate) ClosingService { return &closingService{ Log: utils.Log, Validate: validate, @@ -47,6 +55,9 @@ func NewClosingService(repo repository.ClosingRepository, projectFlockRepo proje MarketingRepo: marketingRepo, MarketingDeliveryProductRepo: marketingDeliveryProductRepo, ApprovalSvc: approvalSvc, + ExpenseRealizationRepo: expenseRealizationRepo, + ProjectBudgetRepo: projectBudgetRepo, + ChickinRepo: chickinRepo, } } @@ -56,11 +67,12 @@ func (s closingService) withRelations(db *gorm.DB) *gorm.DB { func (s closingService) withClosingRelations(db *gorm.DB) *gorm.DB { return s.withRelations(db). + Preload("Location"). Preload("KandangHistory"). Preload("KandangHistory.Chickins") } -func (s closingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlock, int64, error) { +func (s closingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]dto.ClosingListItemDTO, int64, error) { if err := s.Validate.Struct(params); err != nil { return nil, 0, err } @@ -68,9 +80,9 @@ func (s closingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity offset := (params.Page - 1) * params.Limit closings, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { - db = s.withRelations(db) + db = s.withClosingRelations(db) if params.Search != "" { - return db.Where("name LIKE ?", "%"+params.Search+"%") + return db.Where("flock_name LIKE ?", "%"+params.Search+"%") } return db.Order("created_at DESC").Order("updated_at DESC") }) @@ -79,7 +91,19 @@ func (s closingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity s.Log.Errorf("Failed to get closings: %+v", err) return nil, 0, err } - return closings, total, nil + + result := make([]dto.ClosingListItemDTO, 0, len(closings)) + for _, closing := range closings { + statusProject, _, err := s.getApprovalStatuses(c.Context(), closing.Id) + if err != nil { + s.Log.Errorf("Failed to retrieve approval statuses for project flock %d: %+v", closing.Id, err) + return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch approval status") + } + + result = append(result, dto.ToClosingListItemDTO(closing, statusProject)) + } + + return result, total, nil } func (s closingService) GetProjectFlockByID(c *fiber.Ctx, id uint) (*entity.ProjectFlock, error) { @@ -144,6 +168,147 @@ func (s closingService) GetClosingSummary(c *fiber.Ctx, projectFlockID uint) (*d return &summary, nil } +func (s closingService) GetClosingSapronak(c *fiber.Ctx, projectFlockID uint, params *validation.ClosingSapronakQuery) ([]dto.ClosingSapronakItemDTO, int64, error) { + if projectFlockID == 0 { + return nil, 0, fiber.NewError(fiber.StatusBadRequest, "Invalid project flock id") + } + + if params == nil { + params = &validation.ClosingSapronakQuery{} + } + + if params.Page == 0 { + params.Page = 1 + } + if params.Limit == 0 { + params.Limit = 10 + } + + if err := s.Validate.Struct(params); err != nil { + return nil, 0, fiber.NewError(fiber.StatusBadRequest, err.Error()) + } + + if params.Type != validation.SapronakTypeIncoming && params.Type != validation.SapronakTypeOutgoing { + return nil, 0, fiber.NewError(fiber.StatusBadRequest, "type must be either incoming or outgoing") + } + + if _, err := s.Repository.GetByID(c.Context(), projectFlockID, nil); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, 0, fiber.NewError(fiber.StatusNotFound, "Project flock tidak ditemukan") + } + s.Log.Errorf("Failed get project flock %d for sapronak closing: %+v", projectFlockID, err) + return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock") + } + + warehouseIDs, err := s.getWarehouseIDsByProjectFlock(c.Context(), projectFlockID) + if err != nil { + s.Log.Errorf("Failed to fetch warehouses for project flock %d: %+v", projectFlockID, err) + return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch warehouses for project flock") + } + + var projectFlockKandangIDs []uint + if params.Type == validation.SapronakTypeOutgoing { + projectFlockKandangIDs, err = s.getProjectFlockKandangIDs(c.Context(), projectFlockID) + if err != nil { + s.Log.Errorf("Failed to fetch project flock kandang IDs for project flock %d: %+v", projectFlockID, err) + return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandang") + } + } + + offset := (params.Page - 1) * params.Limit + rows, totalResults, err := s.Repository.GetSapronak(c.Context(), repository.SapronakQueryParams{ + Type: params.Type, + WarehouseIDs: warehouseIDs, + ProjectFlockKandangIDs: projectFlockKandangIDs, + Limit: params.Limit, + Offset: offset, + }) + if err != nil { + s.Log.Errorf("Failed to fetch sapronak %s for project flock %d: %+v", params.Type, projectFlockID, err) + return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch sapronak data") + } + + items := make([]dto.ClosingSapronakItemDTO, 0, len(rows)) + for _, row := range rows { + dateStr := row.DateText + if dateStr == "" && !row.SortDate.IsZero() { + dateStr = row.SortDate.Format("02-Jan-2006") + } + items = append(items, dto.ClosingSapronakItemDTO{ + Id: row.Id, + Date: dateStr, + ReferenceNumber: row.ReferenceNumber, + TransactionType: row.TransactionType, + ProductName: row.ProductName, + ProductCategory: row.ProductCategory, + ProductSubCategory: row.ProductSubCategory, + SourceWarehouse: row.SourceWarehouse, + DestinationWarehouse: row.DestinationWarehouse, + // Destination: row.Destination, + Quantity: row.Quantity, + Unit: row.Unit, + FormattedQuantity: formatQuantity(row.Quantity, row.Unit), + Notes: row.Notes, + SortDate: row.SortDate, + }) + } + + return items, totalResults, nil +} + +func (s closingService) getWarehouseIDsByProjectFlock(ctx context.Context, projectFlockID uint) ([]uint, error) { + var kandangIDs []uint + db := s.Repository.DB().WithContext(ctx) + + if err := db.Model(&entity.ProjectFlockKandang{}). + Where("project_flock_id = ?", projectFlockID). + Pluck("kandang_id", &kandangIDs).Error; err != nil { + return nil, err + } + + if len(kandangIDs) == 0 { + return []uint{}, nil + } + + var warehouses []entity.Warehouse + if err := db.Where("kandang_id IN ?", kandangIDs).Find(&warehouses).Error; err != nil { + return nil, err + } + + unique := make(map[uint]struct{}) + for _, warehouse := range warehouses { + unique[warehouse.Id] = struct{}{} + } + + ids := make([]uint, 0, len(unique)) + for id := range unique { + ids = append(ids, id) + } + + return ids, nil +} + +func (s closingService) getProjectFlockKandangIDs(ctx context.Context, projectFlockID uint) ([]uint, error) { + var ids []uint + err := s.Repository.DB().WithContext(ctx). + Model(&entity.ProjectFlockKandang{}). + Where("project_flock_id = ?", projectFlockID). + Pluck("id", &ids).Error + if err != nil { + return nil, err + } + + return ids, nil +} + +func formatQuantity(qty float64, uom string) string { + qtyStr := strconv.FormatFloat(qty, 'f', -1, 64) + if uom == "" { + return qtyStr + } + return qtyStr + " " + uom +} + func (s closingService) getApprovalStatuses(ctx context.Context, projectFlockID uint) (string, string, error) { if s.ApprovalSvc == nil { return "", "Belum Selesai", nil @@ -165,7 +330,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++ } } @@ -188,3 +353,29 @@ func (s closingService) getApprovalStatuses(ctx context.Context, projectFlockID return statusProject, statusClosing, nil } + +func (s closingService) GetOverhead(c *fiber.Ctx, projectFlockID uint) (*dto.OverheadListDTO, error) { + budgets, err := s.ProjectBudgetRepo.GetByProjectFlockID(c.Context(), projectFlockID) + if err != nil { + return nil, err + } + + realizations, err := s.ExpenseRealizationRepo.GetByProjectFlockID(c.Context(), projectFlockID) + if err != nil { + return nil, err + } + + chickins, err := s.ChickinRepo.GetByProjectFlockID(c.Context(), projectFlockID) + if err != nil { + return nil, err + } + + var totalChickinQty float64 + for _, chickin := range chickins { + totalChickinQty += chickin.UsageQty + } + + result := dto.ToOverheadListDTOs(budgets, realizations, totalChickinQty) + + return &result, nil +} diff --git a/internal/modules/closings/services/sapronak.service.go b/internal/modules/closings/services/sapronak.service.go new file mode 100644 index 00000000..3c1843dd --- /dev/null +++ b/internal/modules/closings/services/sapronak.service.go @@ -0,0 +1,681 @@ +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" + 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) +} + +type sapronakService struct { + Log *logrus.Logger + Validate *validator.Validate + Repository repository.ClosingRepository + ProjectFlockKandangRepo projectflockRepository.ProjectFlockKandangRepository +} + +func NewSapronakService( + repo repository.ClosingRepository, + pfkRepo projectflockRepository.ProjectFlockKandangRepository, + validate *validator.Validate, +) SapronakService { + return &sapronakService{ + Log: utils.Log, + Validate: validate, + Repository: repo, + ProjectFlockKandangRepo: pfkRepo, + } +} + +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.CountSapronakQuery{ + ProjectFlockID: projectFlockID, + Status: "all", + Flag: flag, + }) + 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, 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") + } + + results, err := s.computeSapronakReports(c.Context(), &validation.CountSapronakQuery{ + ProjectFlockID: projectFlockID, + ProjectFlockKandangID: pfkID, + Status: "all", + Flag: flag, + }) + 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.CountSapronakQuery) ([]dto.SapronakReportDTO, error) { + pfks, err := s.loadProjectFlockKandangs(ctx, params) + if err != nil { + return nil, err + } + if len(pfks) == 0 { + return []dto.SapronakReportDTO{}, nil + } + + filterStatus := strings.ToLower(strings.TrimSpace(params.Status)) + if filterStatus == "" { + filterStatus = "all" + } + + results := make([]dto.SapronakReportDTO, 0, len(pfks)) + for _, pfk := range pfks { + status := "closing" + if pfk.ClosedAt == nil { + status = "active" + } + + if (filterStatus == "active" && status != "active") || (filterStatus == "closing" && status != "closing") { + continue + } + + // 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") + } + + 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: nil, + EndDate: nil, + TotalIncomingValue: totalIncoming, + TotalUsageValue: totalUsage, + Items: items, + Groups: groups, + }) + } + + return results, nil +} + +func (s sapronakService) loadProjectFlockKandangs(ctx context.Context, params *validation.CountSapronakQuery) ([]entity.ProjectFlockKandang, error) { + 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) combineSapronakReports(reports []dto.SapronakReportDTO, projectID uint) dto.SapronakReportDTO { + if len(reports) == 0 { + return dto.SapronakReportDTO{} + } + + var ( + totalIncoming float64 + totalUsage float64 + 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 + + 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: nil, + TotalIncomingValue: totalIncoming, + TotalUsageValue: totalUsage, + Items: items, + Groups: groups, + } +} + +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 + TransferOut 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, + transferOutRows 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), + TransferOut: 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) + 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) { + // 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) + if err != nil { + return nil, nil, 0, 0, err + } + usageRows, err := s.Repository.FetchSapronakUsage(ctx, pfk.Id) + if err != nil { + return nil, nil, 0, 0, err + } + chickinUsageRows, err := s.Repository.FetchSapronakChickinUsage(ctx, pfk.Id) + if err != nil { + return nil, nil, 0, 0, err + } + usageDetailsRows, err := s.Repository.FetchSapronakUsageDetails(ctx, pfk.Id) + if err != nil { + return nil, nil, 0, 0, err + } + 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 + } + + filterFlag := strings.ToUpper(strings.TrimSpace(flagFilter)) + matchesFlag := func(f string) bool { + if filterFlag == "" { + return true + } + return strings.ToUpper(f) == filterFlag + } + + // 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) + + 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 { + return g + } + groupMap[flag] = &dto.SapronakGroupDTO{Flag: flag} + return groupMap[flag] + } + + for _, row := range incoming { + if !matchesFlag(row.Flag) { + continue + } + 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 { + if !matchesFlag(row.Flag) { + continue + } + 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 { + if !matchesFlag(d.Flag) { + continue + } + 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 { + if !matchesFlag(d.Flag) { + continue + } + 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 { + if !matchesFlag(d.Flag) { + continue + } + 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 + } + if !matchesFlag(flag) { + continue + } + 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 + } + if !matchesFlag(flag) { + continue + } + 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 + } + 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 + } + } + + for productID, details := range adjOutgoing { + 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 + } + } + + for productID, details := range transIncoming { + 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.TotalMasuk += d.QtyMasuk + group.TotalNilai += d.Nilai + group.SaldoAkhir += d.QtyMasuk + } + } + + 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) + } + + return items, groups, totalIncoming, totalUsage, nil +} diff --git a/internal/modules/closings/validations/closing.validation.go b/internal/modules/closings/validations/closing.validation.go index 7d16d3ee..610e89b8 100644 --- a/internal/modules/closings/validations/closing.validation.go +++ b/internal/modules/closings/validations/closing.validation.go @@ -1,11 +1,11 @@ package validation type Create struct { - Name string `json:"name" validate:"required_strict,min=3"` + Name string `json:"name" validate:"required_strict,min=3"` } type Update struct { - Name *string `json:"name,omitempty" validate:"omitempty"` + Name *string `json:"name,omitempty" validate:"omitempty"` } type Query struct { @@ -13,3 +13,14 @@ type Query struct { Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` Search string `query:"search" validate:"omitempty,max=50"` } + +const ( + SapronakTypeIncoming = "incoming" + SapronakTypeOutgoing = "outgoing" +) + +type ClosingSapronakQuery struct { + Type string `query:"type" validate:"required,oneof=incoming outgoing"` + Page int `query:"page" validate:"omitempty,number,min=1,gt=0"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` +} diff --git a/internal/modules/closings/validations/sapronak.validation.go b/internal/modules/closings/validations/sapronak.validation.go new file mode 100644 index 00000000..78f64d08 --- /dev/null +++ b/internal/modules/closings/validations/sapronak.validation.go @@ -0,0 +1,9 @@ +package validation + +type CountSapronakQuery 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"` + Flag string `query:"flag" validate:"omitempty,oneof=DOC OVK PAKAN PULLET doc ovk pakan pullet"` +} diff --git a/internal/modules/expenses/module.go b/internal/modules/expenses/module.go index 2f71a349..6d276b5d 100644 --- a/internal/modules/expenses/module.go +++ b/internal/modules/expenses/module.go @@ -11,7 +11,6 @@ import ( commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" rExpense "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" sExpense "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/services" - rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" diff --git a/internal/modules/expenses/repositories/expense.repository.go b/internal/modules/expenses/repositories/expense.repository.go index 9e97a180..844a6409 100644 --- a/internal/modules/expenses/repositories/expense.repository.go +++ b/internal/modules/expenses/repositories/expense.repository.go @@ -2,9 +2,11 @@ package repository import ( "context" + "errors" "gitlab.com/mbugroup/lti-api.git/internal/common/repository" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gitlab.com/mbugroup/lti-api.git/internal/utils" "gorm.io/gorm" ) @@ -13,6 +15,8 @@ type ExpenseRepository interface { IdExists(ctx context.Context, id uint) (bool, error) GetNextSequence(ctx context.Context) (int, error) GetWithSupplier(ctx context.Context, id uint64) (*entity.Expense, error) + WithProjectFlockKandangFilter(pfkID, kandangID uint) func(*gorm.DB) *gorm.DB + CountUnfinishedByProjectFlockKandang(ctx context.Context, pfkID, kandangID uint, isFinished func(*entity.Approval) bool) (int64, error) } type ExpenseRepositoryImpl struct { @@ -49,3 +53,57 @@ func (r *ExpenseRepositoryImpl) GetWithSupplier(ctx context.Context, id uint64) } return &expense, nil } + +func (r *ExpenseRepositoryImpl) WithProjectFlockKandangFilter(pfkID, kandangID uint) func(*gorm.DB) *gorm.DB { + return func(db *gorm.DB) *gorm.DB { + if pfkID == 0 && kandangID == 0 { + return db + } + q := db.Joins("JOIN expense_nonstocks ON expense_nonstocks.expense_id = expenses.id") + if pfkID > 0 && kandangID > 0 { + return q.Where("expense_nonstocks.project_flock_kandang_id = ? OR expense_nonstocks.kandang_id = ?", pfkID, kandangID) + } + if pfkID > 0 { + return q.Where("expense_nonstocks.project_flock_kandang_id = ?", pfkID) + } + return q.Where("expense_nonstocks.kandang_id = ?", kandangID) + } +} + +func (r *ExpenseRepositoryImpl) CountUnfinishedByProjectFlockKandang(ctx context.Context, pfkID, kandangID uint, isFinished func(*entity.Approval) bool) (int64, error) { + if pfkID == 0 && kandangID == 0 { + return 0, nil + } + + var ids []uint64 + if err := r.DB().WithContext(ctx). + Table("expenses"). + Scopes(r.WithProjectFlockKandangFilter(pfkID, kandangID)). + Group("expenses.id").Where("expenses.deleted_at IS NULL"). + Pluck("expenses.id", &ids).Error; err != nil { + return 0, err + } + if len(ids) == 0 { + return 0, nil + } + + var unfinished int64 + for _, id := range ids { + var latest entity.Approval + err := r.DB().WithContext(ctx). + Table("approvals"). + Where("approvable_type = ? AND approvable_id = ?", utils.ApprovalWorkflowExpense.String(), id). + Order("action_at DESC"). + Limit(1). + First(&latest).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return 0, err + } + if isFinished != nil { + if !isFinished(&latest) { + unfinished++ + } + } + } + return unfinished, nil +} diff --git a/internal/modules/expenses/repositories/expense_realization.repository.go b/internal/modules/expenses/repositories/expense_realization.repository.go index 77f075f7..e4d57b79 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" ) @@ -12,6 +14,8 @@ type ExpenseRealizationRepository interface { repository.BaseRepository[entity.ExpenseRealization] 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.ExpenseQuery) ([]entity.ExpenseRealization, int64, error) } type ExpenseRealizationRepositoryImpl struct { @@ -30,11 +34,102 @@ func (r *ExpenseRealizationRepositoryImpl) IdExists(ctx context.Context, id uint func (r *ExpenseRealizationRepositoryImpl) GetByExpenseNonstockID(ctx context.Context, expenseNonstockID uint64) (*entity.ExpenseRealization, error) { var realization entity.ExpenseRealization - err := r.DB().WithContext(ctx). - Where("expense_nonstock_id = ?", expenseNonstockID). - First(&realization).Error - if err != nil { - return nil, err - } - return &realization, nil + err := r.DB().WithContext(ctx).Where("expense_nonstock_id = ?", expenseNonstockID).First(&realization).Error + return &realization, err +} + +func (r *ExpenseRealizationRepositoryImpl) GetByProjectFlockID(ctx context.Context, projectFlockID uint) ([]entity.ExpenseRealization, error) { + var realizations []entity.ExpenseRealization + err := r.DB().WithContext(ctx). + Preload("ExpenseNonstock"). + Preload("ExpenseNonstock.Nonstock"). + Preload("ExpenseNonstock.Nonstock.Uom"). + Preload("ExpenseNonstock.Expense"). + Joins("JOIN expense_nonstocks ON expense_nonstocks.id = expense_realizations.expense_nonstock_id"). + Joins("JOIN project_flock_kandangs ON project_flock_kandangs.id = expense_nonstocks.project_flock_kandang_id"). + Joins("JOIN expenses ON expenses.id = expense_nonstocks.expense_id"). + Where("project_flock_kandangs.project_flock_id = ?", projectFlockID). + Where("expenses.category = ?", "BOP"). + Find(&realizations).Error + return realizations, err +} + +func (r *ExpenseRealizationRepositoryImpl) GetAllWithFilters(ctx context.Context, offset, limit int, filters *validation.ExpenseQuery) ([]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/expenses/services/expense.service.go b/internal/modules/expenses/services/expense.service.go index 7de05689..dbfb00c2 100644 --- a/internal/modules/expenses/services/expense.service.go +++ b/internal/modules/expenses/services/expense.service.go @@ -11,6 +11,7 @@ import ( commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + middleware "gitlab.com/mbugroup/lti-api.git/internal/middleware" expenseDto "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/dto" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/validations" @@ -188,7 +189,11 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen return fiber.NewError(fiber.StatusInternalServerError, "Failed to generate reference number") } - createdBy := uint64(1) //todo get from auth + actorID, err := middleware.ActorIDFromContext(c) + if err != nil { + return fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context") + } + createdBy := uint64(actorID) expense = &entity.Expense{ ReferenceNumber: referenceNumber, PoNumber: req.PoNumber, @@ -360,6 +365,9 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) return fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense") } + if err := s.ensureProjectFlockNotClosedForExpense(c.Context(), currentExpense); err != nil { + return err + } categoryChanged := false var newCategory string if req.Category != nil && *req.Category != currentExpense.Category { @@ -404,6 +412,9 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) if ens.KandangId != nil { projectFlockKandang, err := projectFlockKandangRepoTx.GetActiveByKandangID(c.Context(), uint(*ens.KandangId)) if err != nil { + if err := s.ensureProjectFlockNotClosedForExpense(c.Context(), currentExpense); err != nil { + return err + } if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusNotFound, "No active project flock kandang found for this kandang") } @@ -496,7 +507,10 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) } } - actorID := uint(1) // TODO: replace with authenticated user id + actorID, err := middleware.ActorIDFromContext(c) + if err != nil { + return fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context") + } if *latestApproval.Action != entity.ApprovalActionUpdated { approvalAction := entity.ApprovalActionUpdated @@ -543,7 +557,21 @@ func (s expenseService) DeleteOne(c *fiber.Ctx, id uint) error { ); err != nil { return err } + expense, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { + return db.Preload("Nonstocks") + }) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Expense not found for ID %d: %+v", id, err) + return fiber.NewError(fiber.StatusNotFound, "Expense not found") + } + s.Log.Errorf("Failed to get expense for ID %d: %+v", id, err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense") + } + if err := s.ensureProjectFlockNotClosedForExpense(c.Context(), expense); err != nil { + return err + } if err := s.Repository.DeleteOne(c.Context(), id); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { s.Log.Errorf("Expense not found for ID %d: %+v", id, err) @@ -572,6 +600,20 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid realization_date format") } + expense, err := s.Repository.GetByID(c.Context(), expenseID, func(db *gorm.DB) *gorm.DB { + return db.Preload("Nonstocks") + }) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Expense not found") + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense") + } + + if err := s.ensureProjectFlockNotClosedForExpense(c.Context(), expense); err != nil { + return nil, err + } + if err := s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx)) @@ -655,7 +697,10 @@ func (s *expenseService) CompleteExpense(c *fiber.Ctx, id uint, notes *string) ( return nil, err } - actorID := uint(1) // TODO: replace with authenticated user id + actorID, err := middleware.ActorIDFromContext(c) + if err != nil { + return nil, fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context") + } latestApproval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowExpense, id, nil) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { @@ -712,7 +757,19 @@ func (s *expenseService) UpdateRealization(c *fiber.Ctx, expenseID uint, req *va ); err != nil { return nil, err } + expense, err := s.Repository.GetByID(c.Context(), expenseID, func(db *gorm.DB) *gorm.DB { + return db.Preload("Nonstocks") + }) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Expense not found") + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get expense") + } + if err := s.ensureProjectFlockNotClosedForExpense(c.Context(), expense); err != nil { + return nil, err + } latestApproval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowExpense, expenseID, nil) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate workflow") @@ -960,11 +1017,14 @@ func (s *expenseService) Approval(c *fiber.Ctx, req *validation.ApprovalRequest, return nil, fiber.NewError(fiber.StatusBadRequest, "No expense IDs provided") } - actorID := uint(1) // TODO: replace with authenticated user id + actorID, err := middleware.ActorIDFromContext(c) + if err != nil { + return nil, fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context") + } var results []expenseDto.ExpenseDetailDTO - err := s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { approvalSvcTx := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx)) expenseRepoTx := repository.NewExpenseRepository(tx) @@ -1010,6 +1070,21 @@ func (s *expenseService) Approval(c *fiber.Ctx, req *validation.ApprovalRequest, } else { return fiber.NewError(fiber.StatusBadRequest, "Invalid approval action") } + if approvalAction == entity.ApprovalActionApproved { + expense, err := expenseRepoTx.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { + return db.Preload("Nonstocks") + }) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, "Expense not found") + } + return fiber.NewError(fiber.StatusInternalServerError, "Failed to load expense") + } + + if err := s.ensureProjectFlockNotClosedForExpense(c.Context(), expense); err != nil { + return err + } + } if _, err := approvalSvcTx.CreateApproval( c.Context(), @@ -1079,13 +1154,45 @@ func (s *expenseService) validateExpenseNonstockRelation(ctx *fiber.Ctx, expense return nil } -// func actorIDFromContext(c *fiber.Ctx) (uint, error) { -// user, ok := authmiddleware.AuthenticatedUser(c) -// if !ok || user == nil || user.Id == 0 { -// return 0, fiber.NewError(fiber.StatusUnauthorized, "Please authenticate") -// } -// return user.Id, nil -// } +func (s *expenseService) ensureProjectFlockNotClosedForExpense( + ctx context.Context, + expense *entity.Expense, +) error { + // Kalau repo belum di-wire atau expense kosong → gak usah ngecek apa-apa + if s.ProjectFlockKandangRepo == nil || expense == nil { + return nil + } -// return user.Id, nil -// } + seen := make(map[uint]struct{}) + + for _, ens := range expense.Nonstocks { + // Field ini pointer, bisa nil + if ens.ProjectFlockKandangId == nil || *ens.ProjectFlockKandangId == 0 { + continue + } + + pfkID := uint(*ens.ProjectFlockKandangId) + if _, ok := seen[pfkID]; ok { + continue + } + seen[pfkID] = struct{}{} + + pfk, err := s.ProjectFlockKandangRepo.GetByID(ctx, pfkID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError( + fiber.StatusBadRequest, + fmt.Sprintf("Project flock %d tidak ditemukan", pfkID), + ) + } + s.Log.Errorf("Failed to validate project flock %d for expense %d: %+v", pfkID, expense.Id, err) + return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate project flock") + } + // ❗ RULE: kalau ClosedAt tidak nil → project sudah closing + if pfk.ClosedAt != nil { + return fiber.NewError(fiber.StatusBadRequest, "Project sudah closing") + } + } + + return nil +} 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/module.go b/internal/modules/inventory/adjustments/module.go index b3e12676..610dc11e 100644 --- a/internal/modules/inventory/adjustments/module.go +++ b/internal/modules/inventory/adjustments/module.go @@ -9,8 +9,8 @@ import ( rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" rproduct "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories" rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" + rProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" - rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" ) @@ -21,10 +21,11 @@ func (AdjustmentModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validat stockLogsRepo := rStockLogs.NewStockLogRepository(db) warehouseRepo := rWarehouse.NewWarehouseRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) userRepo := rUser.NewUserRepository(db) productRepo := rproduct.NewProductRepository(db) - adjustmentService := sAdjustment.NewAdjustmentService(productRepo, stockLogsRepo, warehouseRepo, productWarehouseRepo, validate) + adjustmentService := sAdjustment.NewAdjustmentService(productRepo, stockLogsRepo, warehouseRepo, productWarehouseRepo, validate, projectFlockKandangRepo) userService := sUser.NewUserService(userRepo, validate) AdjustmentRoutes(router, userService, adjustmentService) diff --git a/internal/modules/inventory/adjustments/services/adjustment.service.go b/internal/modules/inventory/adjustments/services/adjustment.service.go index be4ae7a2..7bcbca7e 100644 --- a/internal/modules/inventory/adjustments/services/adjustment.service.go +++ b/internal/modules/inventory/adjustments/services/adjustment.service.go @@ -1,9 +1,14 @@ package service import ( + "context" "errors" + "fmt" "strings" + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" common "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" m "gitlab.com/mbugroup/lti-api.git/internal/middleware" @@ -11,13 +16,10 @@ import ( ProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" productRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories" warehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" + projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" stockLogsRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" "gorm.io/gorm" - - "github.com/go-playground/validator/v10" - "github.com/gofiber/fiber/v2" - "github.com/sirupsen/logrus" ) type AdjustmentService interface { @@ -27,22 +29,24 @@ type AdjustmentService interface { } type adjustmentService struct { - Log *logrus.Logger - Validate *validator.Validate - StockLogsRepository stockLogsRepo.StockLogRepository - WarehouseRepo warehouseRepo.WarehouseRepository - ProductWarehouseRepo ProductWarehouse.ProductWarehouseRepository - ProductRepo productRepo.ProductRepository + Log *logrus.Logger + Validate *validator.Validate + StockLogsRepository stockLogsRepo.StockLogRepository + WarehouseRepo warehouseRepo.WarehouseRepository + ProductWarehouseRepo ProductWarehouse.ProductWarehouseRepository + ProductRepo productRepo.ProductRepository + ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository } -func NewAdjustmentService(productRepo productRepo.ProductRepository, stockLogsRepo stockLogsRepo.StockLogRepository, warehouseRepo warehouseRepo.WarehouseRepository, productWarehouseRepo ProductWarehouse.ProductWarehouseRepository, validate *validator.Validate) AdjustmentService { +func NewAdjustmentService(productRepo productRepo.ProductRepository, stockLogsRepo stockLogsRepo.StockLogRepository, warehouseRepo warehouseRepo.WarehouseRepository, productWarehouseRepo ProductWarehouse.ProductWarehouseRepository, validate *validator.Validate, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository) AdjustmentService { return &adjustmentService{ - Log: utils.Log, - Validate: validate, - StockLogsRepository: stockLogsRepo, - WarehouseRepo: warehouseRepo, - ProductWarehouseRepo: productWarehouseRepo, - ProductRepo: productRepo, + Log: utils.Log, + Validate: validate, + StockLogsRepository: stockLogsRepo, + WarehouseRepo: warehouseRepo, + ProductWarehouseRepo: productWarehouseRepo, + ProductRepo: productRepo, + ProjectFlockKandangRepo: projectFlockKandangRepo, } } @@ -105,11 +109,15 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product warehouse") } if !isProductWarehouseExist { - + projectFlockKandangID, err := s.getActiveProjectFlockKandangID(ctx, uint(req.WarehouseID)) + if err != nil { + return nil, err + } newPW := &entity.ProductWarehouse{ - ProductId: uint(req.ProductID), - WarehouseId: uint(req.WarehouseID), - Quantity: 0, + ProductId: uint(req.ProductID), + WarehouseId: uint(req.WarehouseID), + Quantity: 0, + ProjectFlockKandangId: &projectFlockKandangID, // CreatedBy: 1, // TODO: should Get from auth middleware } @@ -120,6 +128,23 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e s.Log.Infof("Product warehouse created: %+v", newPW.Id) } + pw, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID( + ctx, + uint(req.ProductID), + uint(req.WarehouseID), + ) + if err != nil { + s.Log.Errorf("Failed to get product warehouse for project flock check: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product warehouse") + } + + if err := common.EnsureProjectFlockNotClosedForProductWarehouses( + ctx, + s.StockLogsRepository.DB(), + []uint{pw.Id}, + ); err != nil { + return nil, err + } err = s.StockLogsRepository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { productWarehouse, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID(ctx, uint(req.ProductID), uint(req.WarehouseID)) if err != nil { @@ -170,6 +195,32 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e return s.GetOne(c, createdLogId) } +func (s *adjustmentService) getActiveProjectFlockKandangID(ctx context.Context, warehouseID uint) (uint, error) { + warehouse, err := s.WarehouseRepo.GetByID(ctx, warehouseID, nil) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return 0, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Gudang dengan ID %d tidak ditemukan", warehouseID)) + } + s.Log.Errorf("Failed to get warehouse %d: %+v", warehouseID, err) + return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data gudang") + } + + if warehouse.KandangId == nil || *warehouse.KandangId == 0 { + return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gudang %d belum terhubung ke kandang", warehouseID)) + } + + projectFlockKandang, err := s.ProjectFlockKandangRepo.GetActiveByKandangID(ctx, uint(*warehouse.KandangId)) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Kandang %d belum memiliki project flock aktif", *warehouse.KandangId)) + } + s.Log.Errorf("Failed to get active project flock for kandang %d: %+v", *warehouse.KandangId, err) + return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil project flock kandang") + } + + return uint(projectFlockKandang.Id), nil +} + func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Query) ([]*entity.StockLog, int64, error) { if err := s.Validate.Struct(query); err != nil { return nil, 0, err @@ -178,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 641ce531..4f213f2c 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 { @@ -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 @@ -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 { @@ -258,7 +270,10 @@ func (r *ProductWarehouseRepositoryImpl) GetByFlagAndWarehouseID(ctx context.Con Joins("JOIN flags ON flags.flagable_id = products.id AND flags.flagable_type = 'products'"). Where("flags.name = ? AND product_warehouses.warehouse_id = ?", flagName, warehouseId). Order("product_warehouses.id DESC"). - Preload("Product").Preload("Warehouse"). + Preload("Product"). + Preload("Product.ProductCategory"). + Preload("Product.Uom"). + Preload("Warehouse"). Find(&productWarehouses).Error if err != nil { return nil, err diff --git a/internal/modules/inventory/transfers/module.go b/internal/modules/inventory/transfers/module.go index 734f0f03..19a0ded6 100644 --- a/internal/modules/inventory/transfers/module.go +++ b/internal/modules/inventory/transfers/module.go @@ -9,6 +9,8 @@ import ( rStockTransfer "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/repositories" sTransfer "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/services" rSupplier "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/repositories" + rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" + rProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" @@ -25,8 +27,10 @@ func (TransferModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate supplierRepo := rSupplier.NewSupplierRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) userRepo := rUser.NewUserRepository(db) + warehouseRepo := rWarehouse.NewWarehouseRepository(db) + projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) - transferService := sTransfer.NewTransferService(validate, stockTransferRepo, stockTransferDetailRepo, stockTransferDeliveryRepo, StockTransferDeliveryItemRepo, stockLogsRepo, productWarehouseRepo, supplierRepo) + transferService := sTransfer.NewTransferService(validate, stockTransferRepo, stockTransferDetailRepo, stockTransferDeliveryRepo, StockTransferDeliveryItemRepo, stockLogsRepo, productWarehouseRepo, supplierRepo, warehouseRepo, projectFlockKandangRepo) userService := sUser.NewUserService(userRepo, validate) TransferRoutes(router, userService, transferService) diff --git a/internal/modules/inventory/transfers/services/transfer.service.go b/internal/modules/inventory/transfers/services/transfer.service.go index ef273664..f94295f6 100644 --- a/internal/modules/inventory/transfers/services/transfer.service.go +++ b/internal/modules/inventory/transfers/services/transfer.service.go @@ -1,21 +1,26 @@ package service import ( + "context" "errors" "fmt" + "strings" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" m "gitlab.com/mbugroup/lti-api.git/internal/middleware" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" rStockTransfer "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/repositories" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/transfers/validations" rSupplier "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/repositories" + warehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" + projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" - "strings" - "github.com/go-playground/validator/v10" - "github.com/gofiber/fiber/v2" - "github.com/sirupsen/logrus" "gorm.io/gorm" ) @@ -35,9 +40,11 @@ type transferService struct { StockLogsRepository rStockLogs.StockLogRepository ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository SupplierRepo rSupplier.SupplierRepository + WarehouseRepo warehouseRepo.WarehouseRepository + ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository } -func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTransfer.StockTransferRepository, stockTransferDetailRepo rStockTransfer.StockTransferDetailRepository, stockTransferDeliveryRepo rStockTransfer.StockTransferDeliveryRepository, stockTransferDeliveryItemRepo rStockTransfer.StockTransferDeliveryItemRepository, stockLogsRepo rStockLogs.StockLogRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, supplierRepo rSupplier.SupplierRepository) TransferService { +func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTransfer.StockTransferRepository, stockTransferDetailRepo rStockTransfer.StockTransferDetailRepository, stockTransferDeliveryRepo rStockTransfer.StockTransferDeliveryRepository, stockTransferDeliveryItemRepo rStockTransfer.StockTransferDeliveryItemRepository, stockLogsRepo rStockLogs.StockLogRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, supplierRepo rSupplier.SupplierRepository, warehouseRepo warehouseRepo.WarehouseRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository) TransferService { return &transferService{ Log: utils.Log, Validate: validate, @@ -48,8 +55,11 @@ func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTr StockLogsRepository: stockLogsRepo, ProductWarehouseRepo: productWarehouseRepo, SupplierRepo: supplierRepo, + WarehouseRepo: warehouseRepo, + ProjectFlockKandangRepo: projectFlockKandangRepo, } } + func (s transferService) withRelations(db *gorm.DB) *gorm.DB { return db. Preload("CreatedUser"). @@ -87,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) }) @@ -112,7 +120,8 @@ 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)) + for _, product := range req.Products { sourcePW, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID( c.Context(), uint(product.ProductID), uint(req.SourceWarehouseID), @@ -126,13 +135,22 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques if sourcePW.Quantity < product.ProductQty { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok produk %d di gudang asal tidak cukup", product.ProductID)) } + pwIDs = append(pwIDs, sourcePW.Id) } + + if err := commonSvc.EnsureProjectFlockNotClosedForProductWarehouses( + c.Context(), + s.StockTransferRepo.DB(), + pwIDs, + ); err != nil { + return nil, err + } + actorID, err := m.ActorIDFromContext(c) if err != nil { 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 { @@ -140,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, @@ -148,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 { @@ -162,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) @@ -181,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{ @@ -206,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{ @@ -214,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, }) @@ -223,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 @@ -251,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) @@ -270,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, @@ -291,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), ) @@ -300,12 +299,16 @@ 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 { + return err + } destPW = &entity.ProductWarehouse{ - ProductId: uint(product.ProductID), - WarehouseId: uint(req.DestinationWarehouseID), - Quantity: 0, - // CreatedBy: 1, // TODO: should Get from auth middleware + ProductId: uint(product.ProductID), + WarehouseId: uint(req.DestinationWarehouseID), + Quantity: 0, + ProjectFlockKandangId: &projectFlockKandangID, } if err := s.ProductWarehouseRepo.WithTx(tx).CreateOne(c.Context(), destPW, nil); err != nil { s.Log.Errorf("Failed to create destination product warehouse: %+v", err) @@ -313,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) @@ -321,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), @@ -339,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 @@ -350,10 +346,35 @@ 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 } return result, nil } + +func (s *transferService) getActiveProjectFlockKandangID(ctx context.Context, warehouseID uint) (uint, error) { + warehouse, err := s.WarehouseRepo.GetByID(ctx, warehouseID, nil) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return 0, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Gudang dengan ID %d tidak ditemukan", warehouseID)) + } + s.Log.Errorf("Failed to get warehouse %d: %+v", warehouseID, err) + return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data gudang") + } + + if warehouse.KandangId == nil || *warehouse.KandangId == 0 { + return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gudang %d belum terhubung ke kandang", warehouseID)) + } + + projectFlockKandang, err := s.ProjectFlockKandangRepo.GetActiveByKandangID(ctx, uint(*warehouse.KandangId)) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Kandang %d belum memiliki project flock aktif", *warehouse.KandangId)) + } + s.Log.Errorf("Failed to get active project flock for kandang %d: %+v", *warehouse.KandangId, err) + return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil project flock kandang") + } + + return uint(projectFlockKandang.Id), nil +} diff --git a/internal/modules/marketing/delivery-orderss/controllers/delivery-orders.controller.go b/internal/modules/marketing/controllers/deliveryorder.controller.go similarity index 92% rename from internal/modules/marketing/delivery-orderss/controllers/delivery-orders.controller.go rename to internal/modules/marketing/controllers/deliveryorder.controller.go index 292381d0..73904cc3 100644 --- a/internal/modules/marketing/delivery-orderss/controllers/delivery-orders.controller.go +++ b/internal/modules/marketing/controllers/deliveryorder.controller.go @@ -4,9 +4,9 @@ import ( "math" "strconv" - "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/delivery-orderss/dto" - service "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/delivery-orderss/services" - validation "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/delivery-orderss/validations" + "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/validations" "gitlab.com/mbugroup/lti-api.git/internal/response" "github.com/gofiber/fiber/v2" @@ -23,7 +23,7 @@ func NewDeliveryOrdersController(deliveryOrdersService service.DeliveryOrdersSer } func (u *DeliveryOrdersController) GetAll(c *fiber.Ctx) error { - query := &validation.Query{ + query := &validation.DeliveryOrderQuery{ Page: c.QueryInt("page", 1), Limit: c.QueryInt("limit", 10), MarketingId: uint(c.QueryInt("marketing_id", 0)), @@ -76,7 +76,7 @@ func (u *DeliveryOrdersController) GetOne(c *fiber.Ctx) error { } func (u *DeliveryOrdersController) CreateOne(c *fiber.Ctx) error { - req := new(validation.Create) + req := new(validation.DeliveryOrderCreate) if err := c.BodyParser(req); err != nil { return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") @@ -97,7 +97,7 @@ func (u *DeliveryOrdersController) CreateOne(c *fiber.Ctx) error { } func (u *DeliveryOrdersController) UpdateOne(c *fiber.Ctx) error { - req := new(validation.Update) + req := new(validation.DeliveryOrderUpdate) param := c.Params("id") id, err := strconv.Atoi(param) diff --git a/internal/modules/marketing/sales-orders/controllers/sales-orders.controller.go b/internal/modules/marketing/controllers/salesorder.controller.go similarity index 95% rename from internal/modules/marketing/sales-orders/controllers/sales-orders.controller.go rename to internal/modules/marketing/controllers/salesorder.controller.go index 16d3b5be..416af20f 100644 --- a/internal/modules/marketing/sales-orders/controllers/sales-orders.controller.go +++ b/internal/modules/marketing/controllers/salesorder.controller.go @@ -3,9 +3,9 @@ package controller import ( "strconv" - "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/dto" - service "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/services" - validation "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/validations" + "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/validations" "gitlab.com/mbugroup/lti-api.git/internal/response" "github.com/gofiber/fiber/v2" diff --git a/internal/modules/marketing/delivery-orderss/module.go b/internal/modules/marketing/delivery-orderss/module.go deleted file mode 100644 index efe3737d..00000000 --- a/internal/modules/marketing/delivery-orderss/module.go +++ /dev/null @@ -1,38 +0,0 @@ -package delivery_orderss - -import ( - "fmt" - - "github.com/go-playground/validator/v10" - "github.com/gofiber/fiber/v2" - "gorm.io/gorm" - - commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" - commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" - sDeliveryOrders "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/delivery-orderss/services" - rMarketing "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/repositories" - rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" - sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" - "gitlab.com/mbugroup/lti-api.git/internal/utils" -) - -type DeliveryOrdersModule struct{} - -func (DeliveryOrdersModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { - marketingRepo := rMarketing.NewMarketingRepository(db) - marketingProductRepo := rMarketing.NewMarketingProductRepository(db) - marketingDeliveryProductRepo := rMarketing.NewMarketingDeliveryProductRepository(db) - userRepo := rUser.NewUserRepository(db) - approvalRepo := commonRepo.NewApprovalRepository(db) - approvalSvc := commonSvc.NewApprovalService(approvalRepo) - - // Register workflow steps for MARKETINGS approval - if err := approvalSvc.RegisterWorkflowSteps(utils.ApprovalWorkflowMarketing, utils.MarketingApprovalSteps); err != nil { - panic(fmt.Sprintf("failed to register marketing approval workflow: %v", err)) - } - - deliveryOrdersService := sDeliveryOrders.NewDeliveryOrdersService(marketingRepo, marketingProductRepo, marketingDeliveryProductRepo, approvalSvc, validate) - userService := sUser.NewUserService(userRepo, validate) - - DeliveryOrdersRoutes(router, userService, deliveryOrdersService) -} diff --git a/internal/modules/marketing/delivery-orderss/route.go b/internal/modules/marketing/delivery-orderss/route.go deleted file mode 100644 index f4c08457..00000000 --- a/internal/modules/marketing/delivery-orderss/route.go +++ /dev/null @@ -1,30 +0,0 @@ -package delivery_orderss - -import ( - m "gitlab.com/mbugroup/lti-api.git/internal/middleware" - controller "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/delivery-orderss/controllers" - deliveryOrders "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/delivery-orderss/services" - user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" - - "github.com/gofiber/fiber/v2" -) - -func DeliveryOrdersRoutes(v1 fiber.Router, u user.UserService, s deliveryOrders.DeliveryOrdersService) { - ctrl := controller.NewDeliveryOrdersController(s) - v1.Use(m.Auth(u)) - v1.Get("/",m.RequirePermissions(m.P_DeliveryGetAll), ctrl.GetAll) - v1.Get("/:id",m.RequirePermissions(m.P_DeliveryGetOne), ctrl.GetOne) - - // Sisanya di group /delivery-orders - route := v1.Group("/delivery-orders") - - // route.Get("/", m.Auth(u), ctrl.GetAll) - // route.Post("/", m.Auth(u), ctrl.CreateOne) - // route.Get("/:id", m.Auth(u), ctrl.GetOne) - // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) - // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) - - route.Post("/",m.RequirePermissions(m.P_DeliveryCreateOne), ctrl.CreateOne) - route.Patch("/:id",m.RequirePermissions(m.P_DeliveryUpdateOne), ctrl.UpdateOne) - -} diff --git a/internal/modules/marketing/delivery-orderss/dto/delivery-orders.dto.go b/internal/modules/marketing/dto/deliveryorder.dto.go similarity index 94% rename from internal/modules/marketing/delivery-orderss/dto/delivery-orders.dto.go rename to internal/modules/marketing/dto/deliveryorder.dto.go index 69037499..b2bb70d7 100644 --- a/internal/modules/marketing/delivery-orderss/dto/delivery-orders.dto.go +++ b/internal/modules/marketing/dto/deliveryorder.dto.go @@ -24,7 +24,7 @@ type MarketingListDTO struct { Customer customerDTO.CustomerRelationDTO `json:"customer"` SalesPerson userDTO.UserRelationDTO `json:"sales_person"` SoDocs string `json:"so_docs"` - SalesOrder []MarketingProductDTO `json:"sales_order"` + SalesOrder []DeliveryMarketingProductDTO `json:"sales_order"` CreatedUser userDTO.UserRelationDTO `json:"created_user"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -36,13 +36,14 @@ type MarketingDetailDTO struct { Customer customerDTO.CustomerRelationDTO `json:"customer"` SalesPerson userDTO.UserRelationDTO `json:"sales_person"` SoDocs string `json:"so_docs"` - SalesOrder []MarketingProductDTO `json:"sales_order"` + SalesOrder []DeliveryMarketingProductDTO `json:"sales_order"` DeliveryOrder []DeliveryGroupDTO `json:"delivery_order"` CreatedUser userDTO.UserRelationDTO `json:"created_user"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` LatestApproval approvalDTO.ApprovalRelationDTO `json:"latest_approval"` } + type MarketingDeliveryProductDTO struct { Id uint `json:"id"` MarketingProductId uint `json:"marketing_product_id"` @@ -73,7 +74,7 @@ type DeliveryGroupDTO struct { Deliveries []DeliveryItemDTO `json:"deliveries"` } -type MarketingProductDTO struct { +type DeliveryMarketingProductDTO struct { Id uint `json:"id"` MarketingId uint `json:"marketing_id"` ProductWarehouseId uint `json:"product_warehouse_id"` @@ -95,14 +96,14 @@ func ToMarketingRelationDTO(marketing *entity.Marketing) MarketingRelationDTO { } } -func ToMarketingProductDTO(e entity.MarketingProduct) MarketingProductDTO { +func ToDeliveryMarketingProductDTO(e entity.MarketingProduct) DeliveryMarketingProductDTO { var productWarehouse *productwarehouseDTO.ProductWarehousNestedDTO if e.ProductWarehouse.Id != 0 { mapped := productwarehouseDTO.ToProductWarehouseNestedDTO(e.ProductWarehouse) productWarehouse = &mapped } - return MarketingProductDTO{ + return DeliveryMarketingProductDTO{ Id: e.Id, MarketingId: e.MarketingId, ProductWarehouseId: e.ProductWarehouseId, @@ -155,11 +156,11 @@ func ToMarketingListDTO(marketing *entity.Marketing, deliveryProducts []entity.M latestApproval = mapped } - var salesOrderProducts []MarketingProductDTO + var salesOrderProducts []DeliveryMarketingProductDTO if len(marketing.Products) > 0 { - salesOrderProducts = make([]MarketingProductDTO, len(marketing.Products)) + salesOrderProducts = make([]DeliveryMarketingProductDTO, len(marketing.Products)) for i, product := range marketing.Products { - salesOrderProducts[i] = ToMarketingProductDTO(product) + salesOrderProducts[i] = ToDeliveryMarketingProductDTO(product) } } @@ -195,11 +196,11 @@ func ToMarketingDetailDTO(marketing *entity.Marketing, deliveryProducts []entity salesPerson = mapped } - var salesOrderProducts []MarketingProductDTO + var salesOrderProducts []DeliveryMarketingProductDTO if len(marketing.Products) > 0 { - salesOrderProducts = make([]MarketingProductDTO, len(marketing.Products)) + salesOrderProducts = make([]DeliveryMarketingProductDTO, len(marketing.Products)) for i, product := range marketing.Products { - salesOrderProducts[i] = ToMarketingProductDTO(product) + salesOrderProducts[i] = ToDeliveryMarketingProductDTO(product) } } diff --git a/internal/modules/marketing/sales-orders/dto/sales-orders.dto.go b/internal/modules/marketing/dto/salesorder.dto.go similarity index 100% rename from internal/modules/marketing/sales-orders/dto/sales-orders.dto.go rename to internal/modules/marketing/dto/salesorder.dto.go diff --git a/internal/modules/marketing/module.go b/internal/modules/marketing/module.go index 9bf4f018..586e7961 100644 --- a/internal/modules/marketing/module.go +++ b/internal/modules/marketing/module.go @@ -1,13 +1,52 @@ package marketing import ( + "fmt" + "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" "gorm.io/gorm" + + commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/services" + rCustomer "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/repositories" + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" + rProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + "gitlab.com/mbugroup/lti-api.git/internal/utils" ) type MarketingModule struct{} func (MarketingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { - RegisterRoutes(router, db, validate) + // Initialize repositories + marketingRepo := repository.NewMarketingRepository(db) + marketingProductRepo := repository.NewMarketingProductRepository(db) + marketingDeliveryProductRepo := repository.NewMarketingDeliveryProductRepository(db) + userRepo := rUser.NewUserRepository(db) + customerRepo := rCustomer.NewCustomerRepository(db) + productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + + // Initialize approval service + approvalRepo := commonRepo.NewApprovalRepository(db) + approvalSvc := commonSvc.NewApprovalService(approvalRepo) + + // Register workflow steps for marketing approval + if err := approvalSvc.RegisterWorkflowSteps(utils.ApprovalWorkflowMarketing, utils.MarketingApprovalSteps); err != nil { + panic(fmt.Sprintf("failed to register marketing approval workflow: %v", err)) + } + + warehouseRepo := rWarehouse.NewWarehouseRepository(db) + projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) + // Initialize services + salesOrdersService := service.NewSalesOrdersService(marketingRepo, customerRepo, productWarehouseRepo, userRepo, approvalSvc,warehouseRepo,projectFlockKandangRepo, validate) + deliveryOrdersService := service.NewDeliveryOrdersService(marketingRepo, marketingProductRepo, marketingDeliveryProductRepo, approvalSvc, validate) + userService := sUser.NewUserService(userRepo, validate) + + // Register routes + RegisterRoutes(router, userService, salesOrdersService, deliveryOrdersService) } diff --git a/internal/modules/marketing/repositories/salesorder.repository.go b/internal/modules/marketing/repositories/salesorder.repository.go new file mode 100644 index 00000000..51351e55 --- /dev/null +++ b/internal/modules/marketing/repositories/salesorder.repository.go @@ -0,0 +1,57 @@ +package repository + +import ( + "context" + "fmt" + + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type MarketingRepository interface { + repository.BaseRepository[entity.Marketing] + IdExists(ctx context.Context, id uint) (bool, error) + GetNextSequence(ctx context.Context) (uint, error) + NextSoNumber(ctx context.Context, tx *gorm.DB) (string, error) +} + +type MarketingRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.Marketing] +} + +func NewMarketingRepository(db *gorm.DB) MarketingRepository { + return &MarketingRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.Marketing](db), + } +} + +func (r *MarketingRepositoryImpl) IdExists(ctx context.Context, id uint) (bool, error) { + return repository.Exists[entity.Marketing](ctx, r.DB(), id) +} + +func (r *MarketingRepositoryImpl) GetNextSequence(ctx context.Context) (uint, error) { + var maxID uint + if err := r.DB().WithContext(ctx).Model(&entity.Marketing{}).Select("COALESCE(MAX(id), 0)").Scan(&maxID).Error; err != nil { + return 0, err + } + return maxID + 1, nil +} + +func (r *MarketingRepositoryImpl) NextSoNumber(ctx context.Context, tx *gorm.DB) (string, error) { + db := tx + if db == nil { + db = r.DB() + } + + var soNumber string + err := db.WithContext(ctx). + Raw("SELECT generate_so_number()"). + Scan(&soNumber).Error + + if err != nil { + return "", fmt.Errorf("failed to generate SO number: %w", err) + } + + return soNumber, nil +} diff --git a/internal/modules/marketing/sales-orders/repositories/marketing-delivery-products.repository.go b/internal/modules/marketing/repositories/salesorder_delivery_product.repository.go similarity index 51% rename from internal/modules/marketing/sales-orders/repositories/marketing-delivery-products.repository.go rename to internal/modules/marketing/repositories/salesorder_delivery_product.repository.go index a3c2af88..85d850a6 100644 --- a/internal/modules/marketing/sales-orders/repositories/marketing-delivery-products.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/marketing/sales-orders/repositories/marketing-products.repository.go b/internal/modules/marketing/repositories/salesorder_product.repository.go similarity index 100% rename from internal/modules/marketing/sales-orders/repositories/marketing-products.repository.go rename to internal/modules/marketing/repositories/salesorder_product.repository.go diff --git a/internal/modules/marketing/route.go b/internal/modules/marketing/route.go index 1ab03896..75ecc0f6 100644 --- a/internal/modules/marketing/route.go +++ b/internal/modules/marketing/route.go @@ -1,27 +1,31 @@ package marketing import ( - "gitlab.com/mbugroup/lti-api.git/internal/modules" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/controllers" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/services" + user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" - "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" - "gorm.io/gorm" - - salesOrderss "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders" - deliveryOrderss "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/delivery-orderss" - // MODULE IMPORTS ) -func RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { - group := router.Group("/marketing") +func RegisterRoutes(router fiber.Router, userService user.UserService, salesOrdersService service.SalesOrdersService, deliveryOrdersService service.DeliveryOrdersService) { + salesOrdersCtrl := controller.NewSalesOrdersController(salesOrdersService) + deliveryOrdersCtrl := controller.NewDeliveryOrdersController(deliveryOrdersService) - allModules := []modules.Module{ - salesOrderss.SalesOrdersModule{}, - deliveryOrderss.DeliveryOrdersModule{}, - // MODULE REGISTRY - } + route := router.Group("/marketing") + route.Use(m.Auth(userService)) - for _, m := range allModules { - m.RegisterRoutes(group, db, validate) - } + route.Get("/", deliveryOrdersCtrl.GetAll) + route.Get("/:id", deliveryOrdersCtrl.GetOne) + route.Delete("/:id", salesOrdersCtrl.DeleteOne) + + route.Post("/sales-orders", salesOrdersCtrl.CreateOne) + route.Patch("/sales-orders/:id", salesOrdersCtrl.UpdateOne) + route.Post("/sales-orders/approvals", salesOrdersCtrl.Approval) + + route.Get("/delivery-orders", deliveryOrdersCtrl.GetAll) + route.Get("/delivery-orders/:id", deliveryOrdersCtrl.GetOne) + route.Post("/delivery-orders", deliveryOrdersCtrl.CreateOne) + route.Patch("/delivery-orders/:id", deliveryOrdersCtrl.UpdateOne) } diff --git a/internal/modules/marketing/sales-orders/module.go b/internal/modules/marketing/sales-orders/module.go deleted file mode 100644 index 0d9583d0..00000000 --- a/internal/modules/marketing/sales-orders/module.go +++ /dev/null @@ -1,39 +0,0 @@ -package sales_orders - -import ( - "fmt" - - "github.com/go-playground/validator/v10" - "github.com/gofiber/fiber/v2" - "gorm.io/gorm" - - commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" - commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" - rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" - rSalesOrders "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/repositories" - sSalesOrders "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/services" - rCustomer "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/repositories" - rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" - sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" - "gitlab.com/mbugroup/lti-api.git/internal/utils" -) - -type SalesOrdersModule struct{} - -func (SalesOrdersModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { - marketingRepo := rSalesOrders.NewMarketingRepository(db) - userRepo := rUser.NewUserRepository(db) - customerRepo := rCustomer.NewCustomerRepository(db) - productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) - - approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(db)) - - if err := approvalSvc.RegisterWorkflowSteps(utils.ApprovalWorkflowMarketing, utils.MarketingApprovalSteps); err != nil { - panic(fmt.Sprintf("failed to register marketing approval workflow: %v", err)) - } - - salesOrdersService := sSalesOrders.NewSalesOrdersService(marketingRepo, customerRepo, productWarehouseRepo, userRepo, approvalSvc, validate) - userService := sUser.NewUserService(userRepo, validate) - - SalesOrdersRoutes(router, userService, salesOrdersService) -} diff --git a/internal/modules/marketing/sales-orders/repositories/marketings.repository.go b/internal/modules/marketing/sales-orders/repositories/marketings.repository.go deleted file mode 100644 index df8a7c98..00000000 --- a/internal/modules/marketing/sales-orders/repositories/marketings.repository.go +++ /dev/null @@ -1,122 +0,0 @@ -package repository - -import ( - "context" - "fmt" - "strconv" - "strings" - - "gitlab.com/mbugroup/lti-api.git/internal/common/repository" - entity "gitlab.com/mbugroup/lti-api.git/internal/entities" - "gitlab.com/mbugroup/lti-api.git/internal/utils" - "gorm.io/gorm" - "gorm.io/gorm/clause" -) - -type MarketingRepository interface { - repository.BaseRepository[entity.Marketing] - IdExists(ctx context.Context, id uint) (bool, error) - GetNextSequence(ctx context.Context) (uint, error) - NextSoNumber(ctx context.Context, tx *gorm.DB) (string, error) -} - -type MarketingRepositoryImpl struct { - *repository.BaseRepositoryImpl[entity.Marketing] -} - -func NewMarketingRepository(db *gorm.DB) MarketingRepository { - return &MarketingRepositoryImpl{ - BaseRepositoryImpl: repository.NewBaseRepository[entity.Marketing](db), - } -} - -func (r *MarketingRepositoryImpl) IdExists(ctx context.Context, id uint) (bool, error) { - return repository.Exists[entity.Marketing](ctx, r.DB(), id) -} - -func (r *MarketingRepositoryImpl) GetNextSequence(ctx context.Context) (uint, error) { - var maxID uint - if err := r.DB().WithContext(ctx).Model(&entity.Marketing{}).Select("COALESCE(MAX(id), 0)").Scan(&maxID).Error; err != nil { - return 0, err - } - return maxID + 1, nil -} - -func (r *MarketingRepositoryImpl) NextSoNumber(ctx context.Context, tx *gorm.DB) (string, error) { - return r.generateSequentialNumber(ctx, tx, "so_number", utils.MarketingSoNumberPrefix, utils.MarketingNumberPadding) -} - -func parseNumericSuffix(value, prefix string) (int, bool) { - if !strings.HasPrefix(value, prefix) { - return 0, false - } - suffix := strings.TrimPrefix(value, prefix) - if suffix == "" { - return 0, false - } - trimmed := strings.TrimLeft(suffix, "0") - if trimmed == "" { - trimmed = "0" - } - number, err := strconv.Atoi(trimmed) - if err != nil { - return 0, false - } - return number, true -} - -func (r *MarketingRepositoryImpl) numberExists(ctx context.Context, db *gorm.DB, column, value string) (bool, error) { - var count int64 - if err := db.WithContext(ctx). - Model(&entity.Marketing{}). - Where(fmt.Sprintf("%s = ?", column), value). - Count(&count).Error; err != nil { - return false, err - } - return count > 0, nil -} - -func (r *MarketingRepositoryImpl) generateSequentialNumber(ctx context.Context, tx *gorm.DB, column, prefix string, padding int) (string, error) { - - db := tx - if db == nil { - db = r.DB() - } - - var values []string - err := db.WithContext(ctx). - Model(&entity.Marketing{}). - Where(fmt.Sprintf("%s LIKE ?", column), prefix+"%"). - Select(column). - Order(fmt.Sprintf("%s DESC", column)). - Limit(20). - Clauses(clause.Locking{Strength: "UPDATE"}). - Pluck(column, &values).Error - if err != nil { - return "", err - } - - next := 1 - for _, value := range values { - if number, ok := parseNumericSuffix(value, prefix); ok { - next = number + 1 - break - } - } - - const maxAttempts = 20 - for attempt := 0; attempt < maxAttempts; attempt++ { - candidate := fmt.Sprintf("%s%0*d", prefix, padding, next) - exists, err := r.numberExists(ctx, db, column, candidate) - if err != nil { - return "", err - } - if !exists { - return candidate, nil - } - next++ - } - - return "", fmt.Errorf("unable to generate unique %s", column) - -} diff --git a/internal/modules/marketing/sales-orders/route.go b/internal/modules/marketing/sales-orders/route.go deleted file mode 100644 index 17249840..00000000 --- a/internal/modules/marketing/sales-orders/route.go +++ /dev/null @@ -1,26 +0,0 @@ -package sales_orders - -import ( - m "gitlab.com/mbugroup/lti-api.git/internal/middleware" - controller "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/controllers" - salesOrders "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/services" - user "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" - - "github.com/gofiber/fiber/v2" -) - -func SalesOrdersRoutes(v1 fiber.Router, u user.UserService, s salesOrders.SalesOrdersService) { - ctrl := controller.NewSalesOrdersController(s) - v1.Use(m.Auth(u)) - v1.Delete("/:id",m.RequirePermissions(m.P_SalesOrderDelete), ctrl.DeleteOne) - route := v1.Group("/sales-orders") - - // route.Post("/", m.Auth(u), ctrl.CreateOne) - // route.Patch("/:id", m.Auth(u), ctrl.UpdateOne) - // route.Delete("/:id", m.Auth(u), ctrl.DeleteOne) - - route.Post("/",m.RequirePermissions(m.P_SalesOrderCreateOne), ctrl.CreateOne) - route.Patch("/:id",m.RequirePermissions(m.P_SalesOrderUpdateOne), ctrl.UpdateOne) - - route.Post("/approvals",m.RequirePermissions(m.P_SalesOrderApproval), ctrl.Approval) -} diff --git a/internal/modules/marketing/delivery-orderss/services/delivery-orders.service.go b/internal/modules/marketing/services/deliveryorder.service.go similarity index 93% rename from internal/modules/marketing/delivery-orderss/services/delivery-orders.service.go rename to internal/modules/marketing/services/deliveryorder.service.go index 52ced7d7..793ed716 100644 --- a/internal/modules/marketing/delivery-orderss/services/delivery-orders.service.go +++ b/internal/modules/marketing/services/deliveryorder.service.go @@ -11,9 +11,9 @@ import ( entity "gitlab.com/mbugroup/lti-api.git/internal/entities" m "gitlab.com/mbugroup/lti-api.git/internal/middleware" productWarehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" - "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/delivery-orderss/dto" - validation "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/delivery-orderss/validations" - marketingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/repositories" + "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/dto" + marketingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/validations" "gitlab.com/mbugroup/lti-api.git/internal/utils" "github.com/go-playground/validator/v10" @@ -23,10 +23,10 @@ import ( ) type DeliveryOrdersService interface { - GetAll(ctx *fiber.Ctx, params *validation.Query) ([]dto.MarketingListDTO, int64, error) + GetAll(ctx *fiber.Ctx, params *validation.DeliveryOrderQuery) ([]dto.MarketingListDTO, int64, error) GetOne(ctx *fiber.Ctx, id uint) (*dto.MarketingDetailDTO, error) - CreateOne(ctx *fiber.Ctx, req *validation.Create) (*dto.MarketingDetailDTO, error) - UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*dto.MarketingDetailDTO, error) + CreateOne(ctx *fiber.Ctx, req *validation.DeliveryOrderCreate) (*dto.MarketingDetailDTO, error) + UpdateOne(ctx *fiber.Ctx, req *validation.DeliveryOrderUpdate, id uint) (*dto.MarketingDetailDTO, error) } type deliveryOrdersService struct { @@ -85,7 +85,7 @@ func (s deliveryOrdersService) getMarketingWithDeliveries(c *fiber.Ctx, marketin return &responseDTO, nil } -func (s deliveryOrdersService) GetAll(c *fiber.Ctx, params *validation.Query) ([]dto.MarketingListDTO, int64, error) { +func (s deliveryOrdersService) GetAll(c *fiber.Ctx, params *validation.DeliveryOrderQuery) ([]dto.MarketingListDTO, int64, error) { if err := s.Validate.Struct(params); err != nil { return nil, 0, err } @@ -164,7 +164,7 @@ func (s deliveryOrdersService) GetOne(c *fiber.Ctx, id uint) (*dto.MarketingDeta return &responseDTO, nil } -func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create) (*dto.MarketingDetailDTO, error) { +func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.DeliveryOrderCreate) (*dto.MarketingDetailDTO, error) { if err := s.Validate.Struct(req); err != nil { return nil, err } @@ -222,6 +222,14 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create) return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Marketing product %d not found for this marketing", requestedProduct.MarketingProductId)) } + if err := commonSvc.EnsureProjectFlockNotClosedForProductWarehouses( + c.Context(), + dbTransaction, + []uint{foundMarketingProduct.ProductWarehouseId}, + ); err != nil { + return err + } + deliveryProduct, err := marketingDeliveryProductRepositoryTx.GetByMarketingProductID(c.Context(), foundMarketingProduct.Id) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -285,7 +293,7 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create) return s.getMarketingWithDeliveries(c, req.MarketingId) } -func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*dto.MarketingDetailDTO, error) { +func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryOrderUpdate, id uint) (*dto.MarketingDetailDTO, error) { if err := s.Validate.Struct(req); err != nil { return nil, err } @@ -319,6 +327,13 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, i if foundMarketingProduct == nil { return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Marketing product %d not found for this marketing", requestedProduct.MarketingProductId)) } + if err := commonSvc.EnsureProjectFlockNotClosedForProductWarehouses( + c.Context(), + dbTransaction, + []uint{foundMarketingProduct.ProductWarehouseId}, + ); err != nil { + return err + } deliveryProduct, err := marketingDeliveryProductRepositoryTx.GetByMarketingProductID(c.Context(), foundMarketingProduct.Id) if err != nil { diff --git a/internal/modules/marketing/sales-orders/services/sales-orders.service.go b/internal/modules/marketing/services/salesorder.service.go similarity index 85% rename from internal/modules/marketing/sales-orders/services/sales-orders.service.go rename to internal/modules/marketing/services/salesorder.service.go index 061ffaf7..02cd2e42 100644 --- a/internal/modules/marketing/sales-orders/services/sales-orders.service.go +++ b/internal/modules/marketing/services/salesorder.service.go @@ -11,9 +11,11 @@ import ( entity "gitlab.com/mbugroup/lti-api.git/internal/entities" m "gitlab.com/mbugroup/lti-api.git/internal/middleware" productWarehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" - repository "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/repositories" - validation "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/sales-orders/validations" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/validations" customerRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/repositories" + warehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" + projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" userRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" @@ -32,24 +34,29 @@ type SalesOrdersService interface { } type salesOrdersService struct { - Log *logrus.Logger - Validate *validator.Validate - MarketingRepo repository.MarketingRepository - CustomerRepo customerRepo.CustomerRepository - ProductWarehouseRepo productWarehouseRepo.ProductWarehouseRepository - UserRepo userRepo.UserRepository - ApprovalSvc commonSvc.ApprovalService + Log *logrus.Logger + Validate *validator.Validate + MarketingRepo repository.MarketingRepository + CustomerRepo customerRepo.CustomerRepository + ProductWarehouseRepo productWarehouseRepo.ProductWarehouseRepository + UserRepo userRepo.UserRepository + ApprovalSvc commonSvc.ApprovalService + WarehouseRepo warehouseRepo.WarehouseRepository + ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository } -func NewSalesOrdersService(marketingRepo repository.MarketingRepository, customerRepo customerRepo.CustomerRepository, productWarehouseRepo productWarehouseRepo.ProductWarehouseRepository, userRepo userRepo.UserRepository, approvalSvc commonSvc.ApprovalService, validate *validator.Validate) SalesOrdersService { +func NewSalesOrdersService(marketingRepo repository.MarketingRepository, customerRepo customerRepo.CustomerRepository, productWarehouseRepo productWarehouseRepo.ProductWarehouseRepository, userRepo userRepo.UserRepository, approvalSvc commonSvc.ApprovalService, warehouseRepo warehouseRepo.WarehouseRepository, + projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, validate *validator.Validate) SalesOrdersService { return &salesOrdersService{ - Log: utils.Log, - Validate: validate, - MarketingRepo: marketingRepo, - CustomerRepo: customerRepo, - ProductWarehouseRepo: productWarehouseRepo, - UserRepo: userRepo, - ApprovalSvc: approvalSvc, + Log: utils.Log, + Validate: validate, + MarketingRepo: marketingRepo, + CustomerRepo: customerRepo, + ProductWarehouseRepo: productWarehouseRepo, + UserRepo: userRepo, + ApprovalSvc: approvalSvc, + WarehouseRepo: warehouseRepo, + ProjectFlockKandangRepo: projectFlockKandangRepo, } } @@ -140,10 +147,18 @@ func (s *salesOrdersService) CreateOne(c *fiber.Ctx, req *validation.Create) (*e } if len(req.MarketingProducts) > 0 { + pwIDs := make([]uint, 0, len(req.MarketingProducts)) for _, product := range req.MarketingProducts { + if product.ProductWarehouseId != 0 { + pwIDs = append(pwIDs, product.ProductWarehouseId) + } if err := s.createMarketingProductWithDelivery(c.Context(), marketing.Id, product, marketingProductRepoTx, invDeliveryRepoTx); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to create marketing product") } + + } + if err := commonSvc.EnsureProjectFlockNotClosedForProductWarehouses(c.Context(), s.MarketingRepo.DB(), pwIDs); err != nil { + return err } } @@ -213,6 +228,18 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u } } } + if len(req.MarketingProducts) > 0 { + pwIDs := make([]uint, 0, len(req.MarketingProducts)) + for _, item := range req.MarketingProducts { + if item.ProductWarehouseId != 0 { + pwIDs = append(pwIDs, item.ProductWarehouseId) + } + } + + if err := commonSvc.EnsureProjectFlockNotClosedForProductWarehouses(c.Context(), s.MarketingRepo.DB(), pwIDs); err != nil { + return nil, err + } + } err = s.MarketingRepo.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { @@ -367,7 +394,18 @@ func (s salesOrdersService) DeleteOne(c *fiber.Ctx, id uint) error { if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch sales order") } + if len(marketing.Products) > 0 { + pwIDs := make([]uint, 0, len(marketing.Products)) + for _, p := range marketing.Products { + if p.ProductWarehouseId != 0 { + pwIDs = append(pwIDs, p.ProductWarehouseId) + } + } + if err := commonSvc.EnsureProjectFlockNotClosedForProductWarehouses(c.Context(), s.MarketingRepo.DB(), pwIDs); err != nil { + return err + } + } err = s.MarketingRepo.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { marketingProductRepoTx := repository.NewMarketingProductRepository(dbTransaction) @@ -458,6 +496,27 @@ func (s salesOrdersService) Approval(c *fiber.Ctx, req *validation.Approve) ([]e fmt.Sprintf("Marketing %d cannot be approved - current step is %d", id, latestApproval.StepNumber)) } } + marketing, mErr := s.MarketingRepo.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { + return db.Preload("Products") + }) + if mErr != nil { + if errors.Is(mErr, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("SalesOrders %d not found", id)) + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch sales order for project validation") + } + + if len(marketing.Products) > 0 { + pwIDs := make([]uint, 0, len(marketing.Products)) + for _, p := range marketing.Products { + if p.ProductWarehouseId != 0 { + pwIDs = append(pwIDs, p.ProductWarehouseId) + } + } + if err := commonSvc.EnsureProjectFlockNotClosedForProductWarehouses(c.Context(), s.MarketingRepo.DB(), pwIDs); err != nil { + return nil, err + } + } } err = s.MarketingRepo.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { diff --git a/internal/modules/marketing/delivery-orderss/validations/delivery-orders.validation.go b/internal/modules/marketing/validations/deliveryorder.validation.go similarity index 91% rename from internal/modules/marketing/delivery-orderss/validations/delivery-orders.validation.go rename to internal/modules/marketing/validations/deliveryorder.validation.go index 3317e952..7db2cdd1 100644 --- a/internal/modules/marketing/delivery-orderss/validations/delivery-orders.validation.go +++ b/internal/modules/marketing/validations/deliveryorder.validation.go @@ -11,22 +11,22 @@ type DeliveryProduct struct { VehicleNumber string `json:"vehicle_number" validate:"omitempty,max=50"` } -type Create struct { +type DeliveryOrderCreate struct { MarketingId uint `json:"marketing_id" validate:"required,gt=0"` DeliveryProducts []DeliveryProduct `json:"delivery_products" validate:"required,min=1,dive"` } -type Update struct { +type DeliveryOrderUpdate struct { DeliveryProducts []DeliveryProduct `json:"delivery_products" validate:"omitempty,min=1,dive"` } -type Query struct { +type DeliveryOrderQuery 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"` MarketingId uint `query:"marketing_id" validate:"omitempty,gt=0"` } -type Approve struct { +type DeliveryOrderApprove struct { Action string `json:"action" validate:"required_strict"` ApprovableIds []uint `json:"approvable_ids" validate:"required_strict,min=1,dive,gt=0"` Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` diff --git a/internal/modules/marketing/sales-orders/validations/sales-orders.validation.go b/internal/modules/marketing/validations/salesorder.validation.go similarity index 100% rename from internal/modules/marketing/sales-orders/validations/sales-orders.validation.go rename to internal/modules/marketing/validations/salesorder.validation.go diff --git a/internal/modules/master/nonstocks/dto/nonstock.dto.go b/internal/modules/master/nonstocks/dto/nonstock.dto.go index dd187230..b2af526c 100644 --- a/internal/modules/master/nonstocks/dto/nonstock.dto.go +++ b/internal/modules/master/nonstocks/dto/nonstock.dto.go @@ -1,11 +1,11 @@ package dto import ( - "time" - entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + supplierDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/dto" uomDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/uoms/dto" userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto" + "time" ) // === DTO Structs === @@ -18,13 +18,14 @@ type NonstockRelationDTO struct { } type NonstockListDTO struct { - Id uint `json:"id"` - Name string `json:"name"` - Flags []string `json:"flags"` - Uom *uomDTO.UomRelationDTO `json:"uom"` - CreatedUser *userDTO.UserRelationDTO `json:"created_user"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + Id uint `json:"id"` + Name string `json:"name"` + Flags []string `json:"flags"` + Uom *uomDTO.UomRelationDTO `json:"uom"` + Suppliers []supplierDTO.SupplierRelationDTO `json:"suppliers,omitempty"` + CreatedUser *userDTO.UserRelationDTO `json:"created_user"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type NonstockDetailDTO struct { @@ -76,6 +77,7 @@ func ToNonstockListDTO(e entity.Nonstock) NonstockListDTO { Name: e.Name, Flags: flags, Uom: uomRef, + Suppliers: toNonstockSupplierDTOs(e.NonstockSuppliers), CreatedAt: e.CreatedAt, UpdatedAt: e.UpdatedAt, CreatedUser: createdUser, @@ -95,3 +97,23 @@ func ToNonstockDetailDTO(e entity.Nonstock) NonstockDetailDTO { NonstockListDTO: ToNonstockListDTO(e), } } + +func toNonstockSupplierDTOs(relations []entity.NonstockSupplier) []supplierDTO.SupplierRelationDTO { + if len(relations) == 0 { + return nil + } + + result := make([]supplierDTO.SupplierRelationDTO, 0, len(relations)) + for _, relation := range relations { + if relation.Supplier.Id == 0 { + continue + } + result = append(result, supplierDTO.ToSupplierRelationDTO(relation.Supplier)) + } + + if len(result) == 0 { + return nil + } + + return result +} diff --git a/internal/modules/production/chickins/module.go b/internal/modules/production/chickins/module.go index f6dd554b..f4e91056 100644 --- a/internal/modules/production/chickins/module.go +++ b/internal/modules/production/chickins/module.go @@ -41,8 +41,8 @@ func (ChickinModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate * approvalRepo := commonRepo.NewApprovalRepository(db) approvalService := commonSvc.NewApprovalService(approvalRepo) - if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowProjectFlockKandang, utils.ProjectFlockKandangApprovalSteps); err != nil { - panic(fmt.Sprintf("failed to register project flock kandang approval workflow: %v", err)) + if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowChickin, utils.ChickinApprovalSteps); err != nil { + panic(fmt.Sprintf("failed to register chickin approval workflow: %v", err)) } chickinService := sChickin.NewChickinService(chickinRepo, kandangRepo, warehouseRepo, productWarehouseRepo, projectFlockRepo, projectflockkandangrepo, projectflockpopulationrepo, chickinDetailRepo, validate) diff --git a/internal/modules/production/chickins/repositories/project_chickin.repository.go b/internal/modules/production/chickins/repositories/project_chickin.repository.go index a98dab67..bef062f5 100644 --- a/internal/modules/production/chickins/repositories/project_chickin.repository.go +++ b/internal/modules/production/chickins/repositories/project_chickin.repository.go @@ -11,6 +11,7 @@ import ( type ProjectChickinRepository interface { repository.BaseRepository[entity.ProjectChickin] GetFirstByProjectFlockID(ctx context.Context, projectFlockID uint) (*entity.ProjectChickin, error) + GetByProjectFlockID(ctx context.Context, projectFlockID uint) ([]entity.ProjectChickin, error) GetByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) ([]entity.ProjectChickin, error) GetPendingByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) ([]entity.ProjectChickin, error) GetTotalPendingUsageQtyByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error) @@ -40,6 +41,16 @@ func (r *ChickinRepositoryImpl) GetFirstByProjectFlockID(ctx context.Context, pr return &chickin, nil } +func (r *ChickinRepositoryImpl) GetByProjectFlockID(ctx context.Context, projectFlockID uint) ([]entity.ProjectChickin, error) { + var chickins []entity.ProjectChickin + err := r.db.WithContext(ctx). + Joins("JOIN project_flock_kandangs ON project_flock_kandangs.id = project_chickins.project_flock_kandang_id"). + Where("project_flock_kandangs.project_flock_id = ?", projectFlockID). + Order("project_chickins.created_at DESC"). + Find(&chickins).Error + return chickins, err +} + func (r *ChickinRepositoryImpl) GetByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) ([]entity.ProjectChickin, error) { var chickins []entity.ProjectChickin err := r.db.WithContext(ctx). diff --git a/internal/modules/production/chickins/services/chickin.service.go b/internal/modules/production/chickins/services/chickin.service.go index 660f1e7e..cb816431 100644 --- a/internal/modules/production/chickins/services/chickin.service.go +++ b/internal/modules/production/chickins/services/chickin.service.go @@ -190,14 +190,14 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti return fiber.NewError(fiber.StatusInternalServerError, "Failed to create chickins") } - latest, err := approvalSvcTx.LatestByTarget(c.Context(), utils.ApprovalWorkflowProjectFlockKandang, projectFlockKandang.Id, nil) + latest, err := approvalSvcTx.LatestByTarget(c.Context(), utils.ApprovalWorkflowChickin, projectFlockKandang.Id, nil) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "Failed to get latest approval") } if category == string(utils.ProjectFlockCategoryLaying) { for _, chickin := range newChikins { - updates := map[string]any{"quantity": gorm.Expr("quantity - ?", chickin.PendingUsageQty)} + updates := map[string]any{"qty": gorm.Expr("qty - ?", chickin.PendingUsageQty)} if err := productWarehouseTx.PatchOne(c.Context(), chickin.ProductWarehouseId, updates, nil); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -218,9 +218,9 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti if latest == nil { if _, err := approvalSvcTx.CreateApproval( c.Context(), - utils.ApprovalWorkflowProjectFlockKandang, + utils.ApprovalWorkflowChickin, projectFlockKandang.Id, - utils.ProjectFlockKandangStepPengajuan, + utils.ChickinStepPengajuan, &approvalAction, actorID, nil); err != nil { @@ -228,12 +228,12 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti return fiber.NewError(fiber.StatusInternalServerError, "Failed to create approval") } } - } else if latest.StepNumber != uint16(utils.ProjectFlockKandangStepPengajuan) { + } else if latest.StepNumber != uint16(utils.ChickinStepPengajuan) { if _, err := approvalSvcTx.CreateApproval( c.Context(), - utils.ApprovalWorkflowProjectFlockKandang, + utils.ApprovalWorkflowChickin, projectFlockKandang.Id, - utils.ProjectFlockKandangStepPengajuan, + utils.ChickinStepPengajuan, &approvalAction, actorID, nil); err != nil { @@ -388,7 +388,7 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit return nil, err } - latestApproval, err := approvalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowProjectFlockKandang, id, nil) + latestApproval, err := approvalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowChickin, id, nil) if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to check approval status") @@ -396,14 +396,14 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit if latestApproval == nil { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("No approval found for ProjectFlockKandang %d - chickins must be created first", id)) } - if latestApproval.StepNumber != uint16(utils.ProjectFlockKandangStepPengajuan) { + if latestApproval.StepNumber != uint16(utils.ChickinStepPengajuan) { return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("ProjectFlockKandang %d cannot be approved - current status is not in PENGAJUAN stage", id)) } } - step := utils.ProjectFlockKandangStepPengajuan + step := utils.ChickinStepPengajuan if action == entity.ApprovalActionApproved { - step = utils.ProjectFlockKandangStepDisetujui + step = utils.ChickinStepDisetujui } err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { @@ -415,7 +415,7 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit for _, approvableID := range approvableIDs { if _, err := approvalSvc.CreateApproval( c.Context(), - utils.ApprovalWorkflowProjectFlockKandang, + utils.ApprovalWorkflowChickin, approvableID, step, &action, @@ -498,7 +498,7 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit for _, chickin := range chickins { if categoryForRejection == string(utils.ProjectFlockCategoryGrowing) { - updates := map[string]any{"quantity": gorm.Expr("quantity + ?", chickin.PendingUsageQty)} + updates := map[string]any{"qty": gorm.Expr("qty + ?", chickin.PendingUsageQty)} if err := productWarehouseTx.PatchOne(c.Context(), chickin.ProductWarehouseId, updates, nil); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -600,7 +600,7 @@ func (s *chickinService) convertChickinsToTarget(ctx *fiber.Ctx, chickins []enti if chickin.ProductWarehouseId != targetPW.Id { if err := productWarehouseTx.PatchOne(ctx.Context(), chickin.ProductWarehouseId, map[string]any{ - "quantity": gorm.Expr("quantity - ?", quantityToConvert), + "qty": gorm.Expr("qty - ?", quantityToConvert), }, nil); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Source product warehouse %d not found", chickin.ProductWarehouseId)) @@ -610,7 +610,7 @@ func (s *chickinService) convertChickinsToTarget(ctx *fiber.Ctx, chickins []enti } if err := productWarehouseTx.PatchOne(ctx.Context(), targetPW.Id, map[string]any{ - "quantity": gorm.Expr("quantity + ?", quantityToConvert), + "qty": gorm.Expr("qty + ?", quantityToConvert), }, nil); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Target product warehouse %d not found", targetPW.Id)) 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 4b6e605a..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 @@ -84,3 +84,57 @@ func (u *ProjectFlockKandangController) GetOne(c *fiber.Ctx) error { Data: dto.ToProjectFlockKandangDetailDTOWithAvailableQty(*result, availableQtys, productWarehouses), }) } + +func (u *ProjectFlockKandangController) Closing(c *fiber.Ctx) error { + id, err := strconv.Atoi(c.Params("id")) + if err != nil || id <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + req := new(validation.Closing) + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := u.ProjectFlockKandangService.Closing(c, uint(id), req) + if err != nil { + 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: fiber.Map{ + "detail": detailDTO, + "approval": detailDTO.Approval, + }, + }) +} + +func (u *ProjectFlockKandangController) CheckClosing(c *fiber.Ctx) error { + id, err := strconv.Atoi(c.Params("id")) + if err != nil || id <= 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") + } + + result, err := u.ProjectFlockKandangService.CheckClosing(c, uint(id)) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK).JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Cek persyaratan closing kandang", + Data: result, + }) +} diff --git a/internal/modules/production/project-flock-kandangs/module.go b/internal/modules/production/project-flock-kandangs/module.go index 160cec5e..00ae03ff 100644 --- a/internal/modules/production/project-flock-kandangs/module.go +++ b/internal/modules/production/project-flock-kandangs/module.go @@ -9,9 +9,10 @@ import ( sProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project-flock-kandangs/services" rProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" - + rKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + rExpense "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" "gitlab.com/mbugroup/lti-api.git/internal/utils" @@ -28,15 +29,17 @@ func (ProjectFlockKandangModule) RegisterRoutes(router fiber.Router, db *gorm.DB userRepo := rUser.NewUserRepository(db) warehouseRepo := rWarehouse.NewWarehouseRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + kandangRepo := rKandang.NewKandangRepository(db) approvalRepo := commonRepo.NewApprovalRepository(db) approvalService := commonSvc.NewApprovalService(approvalRepo) - // register workflow steps for project flock kandang approvals + // register workflow steps for chickin approvals if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowProjectFlockKandang, utils.ProjectFlockKandangApprovalSteps); err != nil { - panic(fmt.Sprintf("failed to register project flock kandang approval workflow: %v", err)) + panic(fmt.Sprintf("failed to register chickin approval workflow: %v", err)) } - projectFlockKandangService := sProjectFlockKandang.NewProjectFlockKandangService(projectFlockKandangRepo, approvalService, warehouseRepo, productWarehouseRepo, projectFlockPopulationRepo, validate) + expenseRepo := rExpense.NewExpenseRepository(db) + projectFlockKandangService := sProjectFlockKandang.NewProjectFlockKandangService(projectFlockKandangRepo, approvalService, expenseRepo, warehouseRepo, productWarehouseRepo, projectFlockPopulationRepo,kandangRepo, validate) userService := sUser.NewUserService(userRepo, validate) ProjectFlockKandangRoutes(router, userService, projectFlockKandangService) diff --git a/internal/modules/production/project-flock-kandangs/route.go b/internal/modules/production/project-flock-kandangs/route.go index b382d1af..c5dba313 100644 --- a/internal/modules/production/project-flock-kandangs/route.go +++ b/internal/modules/production/project-flock-kandangs/route.go @@ -16,5 +16,8 @@ func ProjectFlockKandangRoutes(v1 fiber.Router, u user.UserService, s projectFlo route.Use(m.Auth(u)) route.Get("/",m.RequirePermissions(m.P_ProjectFlockKandangsGetAll), ctrl.GetAll) route.Get("/:id",m.RequirePermissions(m.P_ProjectFlockKandangsGetOne), ctrl.GetOne) - + // route.Post("/:id/closing", m.RequirePermissions(m.PermissionProjectFlockClosing), ctrl.Closing) + // route.Get("/:id/closing/check", m.RequirePermissions(m.PermissionProjectFlockClosing), ctrl.CheckClosing) + route.Post("/:id/closing", ctrl.Closing) + route.Get("/:id/closing/check", ctrl.CheckClosing) } 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 11e8b0d5..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 @@ -2,47 +2,84 @@ package service import ( "errors" - - commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" - entity "gitlab.com/mbugroup/lti-api.git/internal/entities" - rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" - rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" - validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project-flock-kandangs/validations" - repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" - "gitlab.com/mbugroup/lti-api.git/internal/utils" + "fmt" + "strings" + "time" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" "github.com/sirupsen/logrus" + commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + m "gitlab.com/mbugroup/lti-api.git/internal/middleware" + expenseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" + rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + kandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories" + rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project-flock-kandangs/validations" + repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" + "gitlab.com/mbugroup/lti-api.git/internal/utils" "gorm.io/gorm" ) type ProjectFlockKandangService interface { GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlockKandang, int64, error) GetOne(ctx *fiber.Ctx, id uint) (*entity.ProjectFlockKandang, map[uint]float64, []entity.ProductWarehouse, error) + CheckClosing(ctx *fiber.Ctx, id uint) (*ClosingCheckResult, error) + Closing(ctx *fiber.Ctx, id uint, req *validation.Closing) (*entity.ProjectFlockKandang, error) + GetProjectFlockKandangClosingDate(c *fiber.Ctx, id uint) (*time.Time, error) } -// Note: map[uint]float64 adalah mapping dari ProductWarehouse ID ke calculated available quantity - type projectFlockKandangService struct { Log *logrus.Logger Validate *validator.Validate Repository repository.ProjectFlockKandangRepository ApprovalSvc commonSvc.ApprovalService + ExpenseRepo expenseRepo.ExpenseRepository WarehouseRepo rWarehouse.WarehouseRepository ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository PopulationRepo repository.ProjectFlockPopulationRepository + KandangRepo kandangRepo.KandangRepository } -func NewProjectFlockKandangService(repo repository.ProjectFlockKandangRepository, approvalSvc commonSvc.ApprovalService, warehouseRepo rWarehouse.WarehouseRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, populationRepo repository.ProjectFlockPopulationRepository, validate *validator.Validate) ProjectFlockKandangService { +type ClosingCheckResult struct { + UnfinishedExpenses int64 `json:"unfinished_expenses"` + StockRemaining []StockRemainingDetail `json:"stock_remaining"` + Expenses []ExpenseSummary `json:"expenses"` +} + +type StockRemainingDetail struct { + FlagName string `json:"flag_name"` + ProductWarehouseId uint `json:"product_warehouse_id"` + ProductId uint `json:"product_id"` + ProductName string `json:"product_name"` + ProductCategory string `json:"product_category"` + Uom string `json:"uom"` + Quantity float64 `json:"quantity"` +} + +type ExpenseSummary struct { + Id uint64 `json:"id"` + PoNumber string `json:"po_number"` + Category string `json:"category"` + Total float64 `json:"total"` + Status string `json:"status"` + StepName string `json:"step_name"` + Step uint16 `json:"step"` + Reference string `json:"reference_number"` +} + +func NewProjectFlockKandangService(repo repository.ProjectFlockKandangRepository, approvalSvc commonSvc.ApprovalService, expenseRepo expenseRepo.ExpenseRepository, warehouseRepo rWarehouse.WarehouseRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, populationRepo repository.ProjectFlockPopulationRepository, kandangRepo kandangRepo.KandangRepository, validate *validator.Validate) ProjectFlockKandangService { return &projectFlockKandangService{ Log: utils.Log, Validate: validate, Repository: repo, ApprovalSvc: approvalSvc, + ExpenseRepo: expenseRepo, WarehouseRepo: warehouseRepo, ProductWarehouseRepo: productWarehouseRepo, PopulationRepo: populationRepo, + KandangRepo: kandangRepo, } } @@ -166,6 +203,351 @@ func (s projectFlockKandangService) getAvailableQuantities(c *fiber.Ctx, project return result, nil } +func (s projectFlockKandangService) CheckClosing(c *fiber.Ctx, id uint) (*ClosingCheckResult, error) { + pfk, err := s.Repository.GetByID(c.Context(), id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "ProjectFlockKandang not found") + } + return nil, err + } + + var unfinished int64 + if s.ExpenseRepo != nil && s.ApprovalSvc != nil { + count, err := s.ExpenseRepo.CountUnfinishedByProjectFlockKandang(c.Context(), pfk.Id, pfk.KandangId, func(appr *entity.Approval) bool { + return appr != nil && appr.StepNumber == uint16(utils.ExpenseStepSelesai) && appr.Action != nil && *appr.Action == entity.ApprovalActionApproved + }) + if err != nil { + return nil, err + } + unfinished = count + } + + stockRemain := make([]StockRemainingDetail, 0) + if s.WarehouseRepo != nil && s.ProductWarehouseRepo != nil { + warehouse, werr := s.WarehouseRepo.GetByKandangID(c.Context(), pfk.KandangId) + if werr != nil { + return nil, werr + } + + for _, flagName := range []utils.FlagType{utils.FlagPakan, utils.FlagOVK} { + productWarehouses, pwErr := s.ProductWarehouseRepo.GetByFlagAndWarehouseID(c.Context(), string(flagName), warehouse.Id) + if pwErr != nil { + return nil, pwErr + } + + for _, pw := range productWarehouses { + if pw.Quantity > 0 { + category := "" + if pw.Product.ProductCategory.Id != 0 { + category = pw.Product.ProductCategory.Name + } + uomName := "" + if pw.Product.Uom.Id != 0 { + uomName = pw.Product.Uom.Name + } + stockRemain = append(stockRemain, StockRemainingDetail{ + FlagName: string(flagName), + ProductWarehouseId: pw.Id, + ProductId: pw.ProductId, + ProductName: pw.Product.Name, + ProductCategory: category, + Uom: uomName, + Quantity: pw.Quantity, + }) + } + } + } + } + + expenseSummaries := make([]ExpenseSummary, 0) + if s.ExpenseRepo != nil { + var expenses []entity.Expense + if err := s.ExpenseRepo.DB().WithContext(c.Context()). + Scopes(s.ExpenseRepo.WithProjectFlockKandangFilter(pfk.Id, pfk.KandangId)). + Preload("Nonstocks"). + Find(&expenses).Error; err != nil { + return nil, err + } + + if len(expenses) > 0 && s.ApprovalSvc != nil { + ids := make([]uint, 0, len(expenses)) + for _, e := range expenses { + ids = append(ids, uint(e.Id)) + } + latestMap, err := s.ApprovalSvc.LatestByTargets(c.Context(), utils.ApprovalWorkflowExpense, ids, nil) + if err == nil { + for i := range expenses { + if latest, ok := latestMap[uint(expenses[i].Id)]; ok { + expenses[i].LatestApproval = latest + } + } + } + } + + for _, exp := range expenses { + total := 0.0 + for _, ns := range exp.Nonstocks { + total += ns.Qty * ns.Price + } + + status := "Pending" + stepName := "" + stepNum := uint16(0) + if exp.LatestApproval != nil { + stepName = exp.LatestApproval.StepName + stepNum = exp.LatestApproval.StepNumber + if exp.LatestApproval.Action != nil { + status = string(*exp.LatestApproval.Action) + } else if stepName != "" { + status = stepName + } + } + + expenseSummaries = append(expenseSummaries, ExpenseSummary{ + Id: exp.Id, + PoNumber: exp.PoNumber, + Category: exp.Category, + Total: total, + Status: status, + StepName: stepName, + Step: stepNum, + Reference: exp.ReferenceNumber, + }) + } + } + + return &ClosingCheckResult{ + UnfinishedExpenses: unfinished, + StockRemaining: stockRemain, + Expenses: expenseSummaries, + }, nil +} + +// getProjectFlockKandangClosingDate mengembalikan tanggal closing PFK jika sudah di-close. +func (s projectFlockKandangService) GetProjectFlockKandangClosingDate(c *fiber.Ctx, id uint) (*time.Time, error) { + if id == 0 { + return nil, nil + } + pfk, err := s.Repository.GetByID(c.Context(), id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + return pfk.ClosedAt, nil +} + +func (s projectFlockKandangService) Closing(c *fiber.Ctx, id uint, req *validation.Closing) (*entity.ProjectFlockKandang, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + + pfk, err := s.Repository.GetByID(c.Context(), id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "ProjectFlockKandang not found") + } + return nil, err + } + + if s.ApprovalSvc != nil { + latest, aerr := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowProjectFlock, pfk.ProjectFlockId, nil) + if aerr != nil { + return nil, aerr + } + if latest == nil || latest.Action == nil || *latest.Action != entity.ApprovalActionApproved { + return nil, fiber.NewError(fiber.StatusBadRequest, "Project flock belum berstatus aktif") + } + if latest.StepNumber != uint16(utils.ProjectFlockStepAktif) && latest.StepNumber != uint16(utils.ProjectFlockStepSelesai) { + return nil, fiber.NewError(fiber.StatusBadRequest, "Project flock belum berstatus aktif") + } + } + + action := strings.ToLower(strings.TrimSpace(req.Action)) + now := time.Now() + + switch action { + case "close": + if pfk.ClosedAt != nil { + return nil, fiber.NewError(fiber.StatusConflict, "Kandang sudah closed") + } + if s.ExpenseRepo != nil && s.ApprovalSvc != nil { + unfinished, err := s.ExpenseRepo.CountUnfinishedByProjectFlockKandang(c.Context(), pfk.Id, pfk.KandangId, func(appr *entity.Approval) bool { + return appr != nil && appr.StepNumber == uint16(utils.ExpenseStepSelesai) && appr.Action != nil && *appr.Action == entity.ApprovalActionApproved + }) + if err != nil { + return nil, err + } + if unfinished > 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Masih ada expense belum selesai untuk kandang ini") + } + } + + if s.WarehouseRepo != nil && s.ProductWarehouseRepo != nil { + warehouse, werr := s.WarehouseRepo.GetByKandangID(c.Context(), pfk.KandangId) + if werr != nil { + return nil, werr + } + + for _, flagName := range []utils.FlagType{utils.FlagPakan, utils.FlagOVK} { + productWarehouses, pwErr := s.ProductWarehouseRepo.GetByFlagAndWarehouseID(c.Context(), string(flagName), warehouse.Id) + if pwErr != nil { + return nil, pwErr + } + + for _, pw := range productWarehouses { + if pw.Quantity > 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok %s masih tersedia (product warehouse %d: %.2f)", flagName, pw.Id, pw.Quantity)) + } + } + } + } + + closeTime := now + if req.ClosedDate != nil { + parsed, perr := utils.ParseDateString(strings.TrimSpace(*req.ClosedDate)) + if perr != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, "closed_date tidak valid, gunakan format YYYY-MM-DD") + } + closeTime = parsed + } + if err := s.Repository.UpdateClosedAt(c.Context(), id, &closeTime); err != nil { + return nil, err + } + if s.KandangRepo != nil { + if err := s.KandangRepo.UpdateStatusByIDs( + c.Context(), + []uint{pfk.KandangId}, + utils.KandangStatusNonActive, + ); err != nil { + return nil, err + } + } + if s.ApprovalSvc != nil { + closeAction := entity.ApprovalActionApproved + // 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. + pfks, ferr := s.Repository.GetByProjectFlockID(c.Context(), pfk.ProjectFlockId) + if ferr != nil { + return nil, ferr + } + allClosed := true + for _, item := range pfks { + if item.ClosedAt == nil { + allClosed = false + break + } + } + if allClosed { + latestPF, lerr := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowProjectFlock, pfk.ProjectFlockId, nil) + if lerr != nil { + return nil, lerr + } + if latestPF == nil || latestPF.StepNumber != uint16(utils.ProjectFlockStepSelesai) { + if _, aerr := s.ApprovalSvc.CreateApproval( + c.Context(), + utils.ApprovalWorkflowProjectFlock, + pfk.ProjectFlockId, + utils.ProjectFlockStepSelesai, + &closeAction, + actorID, + nil, + ); aerr != nil && !errors.Is(aerr, gorm.ErrDuplicatedKey) { + return nil, aerr + } + } + } + } + case "unclose": + if pfk.ClosedAt == nil { + return nil, fiber.NewError(fiber.StatusConflict, "Kandang belum closed") + } + openNewer, err := s.Repository.HasOpenNewerPeriod(c.Context(), pfk.KandangId, pfk.Period, &pfk.Id) + if err != nil { + return nil, err + } + if openNewer { + return nil, fiber.NewError(fiber.StatusBadRequest, "Tidak dapat un-close: ada periode yang sedang berjalan") + } + if err := s.Repository.UpdateClosedAt(c.Context(), id, nil); err != nil { + return nil, err + } + if s.KandangRepo != nil { + if err := s.KandangRepo.UpdateStatusByIDs( + c.Context(), + []uint{pfk.KandangId}, + utils.KandangStatusActive, + ); err != nil { + return nil, err + } + } + if s.ApprovalSvc != nil { + 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: + return nil, fiber.NewError(fiber.StatusBadRequest, "action harus close atau unclose") + } + + return s.Repository.GetByID(c.Context(), id) +} + func (s projectFlockKandangService) calculateAvailableQuantityForProductWarehouse(c *fiber.Ctx, projectFlockKandang *entity.ProjectFlockKandang, productWarehouse *entity.ProductWarehouse) (float64, error) { availableQty := productWarehouse.Quantity diff --git a/internal/modules/production/project-flock-kandangs/validations/project_flock_kandang.validation.go b/internal/modules/production/project-flock-kandangs/validations/project_flock_kandang.validation.go index 93e0256a..729d8329 100644 --- a/internal/modules/production/project-flock-kandangs/validations/project_flock_kandang.validation.go +++ b/internal/modules/production/project-flock-kandangs/validations/project_flock_kandang.validation.go @@ -22,3 +22,8 @@ type Query struct { SortOrder string `query:"sort_order" validate:"omitempty,oneof=ASC DESC"` StepName string `query:"step_name" validate:"omitempty,max=50"` } + +type Closing struct { + Action string `json:"action" validate:"required,oneof=close unclose"` + ClosedDate *string `json:"closed_date,omitempty"` +} \ No newline at end of file diff --git a/internal/modules/production/project_flocks/controllers/projectflock.controller.go b/internal/modules/production/project_flocks/controllers/projectflock.controller.go index 52d53be5..c48e1e2a 100644 --- a/internal/modules/production/project_flocks/controllers/projectflock.controller.go +++ b/internal/modules/production/project_flocks/controllers/projectflock.controller.go @@ -165,33 +165,6 @@ func (u *ProjectflockController) CreateOne(c *fiber.Ctx) error { }) } -func (u *ProjectflockController) UpdateOne(c *fiber.Ctx) error { - req := new(validation.Update) - param := c.Params("id") - - id, err := strconv.Atoi(param) - if err != nil { - return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") - } - - if err := c.BodyParser(req); err != nil { - return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") - } - - result, err := u.ProjectflockService.UpdateOne(c, req, uint(id)) - if err != nil { - return err - } - - return c.Status(fiber.StatusOK). - JSON(response.Success{ - Code: fiber.StatusOK, - Status: "success", - Message: "Update projectflock successfully", - Data: dto.ToProjectFlockListDTO(*result), - }) -} - func (u *ProjectflockController) DeleteOne(c *fiber.Ctx) error { param := c.Params("id") @@ -308,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] @@ -319,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/production/project_flocks/repositories/project_budget.repository.go b/internal/modules/production/project_flocks/repositories/project_budget.repository.go index 943a22b3..720bfc40 100644 --- a/internal/modules/production/project_flocks/repositories/project_budget.repository.go +++ b/internal/modules/production/project_flocks/repositories/project_budget.repository.go @@ -1,6 +1,8 @@ package repository import ( + "context" + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" "gorm.io/gorm" @@ -8,6 +10,7 @@ import ( type ProjectBudgetRepository interface { repository.BaseRepository[entity.ProjectBudget] + GetByProjectFlockID(ctx context.Context, projectFlockID uint) ([]entity.ProjectBudget, error) } type ProjectBudgetRepositoryImpl struct { @@ -21,3 +24,13 @@ func NewProjectBudgetRepository(db *gorm.DB) ProjectBudgetRepository { db: db, } } + +func (r *ProjectBudgetRepositoryImpl) GetByProjectFlockID(ctx context.Context, projectFlockID uint) ([]entity.ProjectBudget, error) { + var budgets []entity.ProjectBudget + err := r.db.WithContext(ctx). + Where("project_flock_id = ?", projectFlockID). + Preload("Nonstock"). + Preload("Nonstock.Uom"). + Find(&budgets).Error + return budgets, err +} 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 76f23b39..911c8b0b 100644 --- a/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go +++ b/internal/modules/production/project_flocks/repositories/projectflock_kandang.repository.go @@ -3,6 +3,7 @@ package repository import ( "context" "strings" + "time" "gitlab.com/mbugroup/lti-api.git/internal/common/repository" entity "gitlab.com/mbugroup/lti-api.git/internal/entities" @@ -14,18 +15,21 @@ type ProjectFlockKandangRepository interface { GetByID(ctx context.Context, id uint) (*entity.ProjectFlockKandang, error) GetByProjectFlockAndKandang(ctx context.Context, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, error) GetActiveByKandangID(ctx context.Context, kandangID uint) (*entity.ProjectFlockKandang, error) + UpdateClosedAt(ctx context.Context, id uint, t *time.Time) error CreateMany(ctx context.Context, records []*entity.ProjectFlockKandang) error DeleteMany(ctx context.Context, projectFlockID uint, kandangIDs []uint) error GetAll(ctx context.Context, offset int, limit int, modifier func(*gorm.DB) *gorm.DB) ([]entity.ProjectFlockKandang, int64, error) GetAllWithFilters(ctx context.Context, offset int, limit int, params interface{}) ([]entity.ProjectFlockKandang, int64, error) + GetByProjectFlockID(ctx context.Context, projectFlockID uint) ([]entity.ProjectFlockKandang, error) ListExistingKandangIDs(ctx context.Context, projectFlockID uint, kandangIDs []uint) ([]uint, error) HasKandangsLinkedToOtherProject(ctx context.Context, kandangIDs []uint, exceptProjectID *uint) (bool, error) FindKandangsWithRecordings(ctx context.Context, projectFlockID uint, kandangIDs []uint) ([]entity.Kandang, error) MaxPeriodByBaseName(ctx context.Context, baseName string) (int, error) 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 - IdExists(ctx context.Context, id uint) (bool, error) DB() *gorm.DB + IdExists(ctx context.Context, id uint) (bool, error) } type projectFlockKandangRepositoryImpl struct { @@ -75,6 +79,16 @@ func (r *projectFlockKandangRepositoryImpl) GetAll(ctx context.Context, offset i return records, total, nil } +func (r *projectFlockKandangRepositoryImpl) GetByProjectFlockID(ctx context.Context, projectFlockID uint) ([]entity.ProjectFlockKandang, error) { + var records []entity.ProjectFlockKandang + if err := r.db.WithContext(ctx). + Where("project_flock_id = ?", projectFlockID). + Find(&records).Error; err != nil { + return nil, err + } + return records, nil +} + func (r *projectFlockKandangRepositoryImpl) GetAllWithFilters(ctx context.Context, offset int, limit int, params interface{}) ([]entity.ProjectFlockKandang, int64, error) { var records []entity.ProjectFlockKandang var total int64 @@ -104,10 +118,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 ) ) @@ -223,32 +237,57 @@ func (r *projectFlockKandangRepositoryImpl) GetByProjectFlockAndKandang(ctx cont } func (r *projectFlockKandangRepositoryImpl) GetActiveByKandangID(ctx context.Context, kandangID uint) (*entity.ProjectFlockKandang, error) { - record := new(entity.ProjectFlockKandang) + latestApprovalSubQuery := r.db. + Table("approvals"). + Select("DISTINCT ON (approvable_id) approvable_id, step_name, id"). + Where("approvable_type = ?", "PROJECT_FLOCKS"). + Order("approvable_id, id DESC") + + var pfkID uint if err := r.db.WithContext(ctx). + Model(&entity.ProjectFlockKandang{}). Joins("JOIN project_flocks ON project_flocks.id = project_flock_kandangs.project_flock_id"). - Joins(` - INNER JOIN ( - SELECT DISTINCT ON (approvable_id) approvable_id, step_name, action_at - FROM approvals - WHERE approvable_type = 'PROJECT_FLOCKS' - ORDER BY approvable_id, action_at DESC - ) latest_approval ON latest_approval.approvable_id = project_flocks.id - `). + Joins("JOIN (?) AS latest_approval ON latest_approval.approvable_id = project_flocks.id", latestApprovalSubQuery). Where("project_flock_kandangs.kandang_id = ?", kandangID). + Where("project_flock_kandangs.closed_at IS NULL"). Where("LOWER(latest_approval.step_name) = LOWER(?)", "Aktif"). Order("project_flock_kandangs.id DESC"). - Preload("ProjectFlock"). - Preload("ProjectFlock.Fcr"). - Preload("ProjectFlock.Area"). - Preload("ProjectFlock.Location"). - Preload("ProjectFlock.CreatedUser"). - Preload("ProjectFlock.Kandangs"). - Preload("ProjectFlock.KandangHistory"). - Preload("Kandang"). - First(record).Error; err != nil { + Limit(1). + Pluck("project_flock_kandangs.id", &pfkID).Error; err != nil { return nil, err } - return record, nil + + if pfkID == 0 { + return nil, gorm.ErrRecordNotFound + } + + return r.GetByID(ctx, pfkID) +} + +func (r *projectFlockKandangRepositoryImpl) UpdateClosedAt(ctx context.Context, id uint, t *time.Time) error { + return r.db.WithContext(ctx). + Model(&entity.ProjectFlockKandang{}). + Where("id = ?", id). + Update("closed_at", t).Error +} + +func (r *projectFlockKandangRepositoryImpl) HasOpenNewerPeriod(ctx context.Context, kandangID uint, currentPeriod int, excludeID *uint) (bool, error) { + if kandangID == 0 { + return false, nil + } + q := r.db.WithContext(ctx). + Table("project_flock_kandangs"). + Where("kandang_id = ?", kandangID). + Where("period > ?", currentPeriod). + Where("closed_at IS NULL") + if excludeID != nil { + q = q.Where("id <> ?", *excludeID) + } + var count int64 + if err := q.Count(&count).Error; err != nil { + return false, err + } + return count > 0, nil } func (r *projectFlockKandangRepositoryImpl) ListExistingKandangIDs(ctx context.Context, projectFlockID uint, kandangIDs []uint) ([]uint, error) { @@ -269,7 +308,8 @@ func (r *projectFlockKandangRepositoryImpl) HasKandangsLinkedToOtherProject(ctx } q := r.db.WithContext(ctx). Table("project_flock_kandangs"). - Where("kandang_id IN ?", kandangIDs) + Where("kandang_id IN ?", kandangIDs). + Where("closed_at IS NULL") if exceptProjectID != nil { q = q.Where("project_flock_id <> ?", *exceptProjectID) } diff --git a/internal/modules/production/project_flocks/route.go b/internal/modules/production/project_flocks/route.go index a962fd56..c0eb8657 100644 --- a/internal/modules/production/project_flocks/route.go +++ b/internal/modules/production/project_flocks/route.go @@ -18,7 +18,6 @@ func ProjectflockRoutes(v1 fiber.Router, u user.UserService, s projectflock.Proj route.Get("/",m.RequirePermissions(m.P_ProjectFlockGetAll),ctrl.GetAll) route.Post("/",m.RequirePermissions(m.P_ProjectFlockCreate), ctrl.CreateOne) route.Get("/:id",m.RequirePermissions(m.P_ProjectFlockGetOne), ctrl.GetOne) - route.Patch("/:id",m.RequirePermissions(m.P_ProjectFlockUpdate), ctrl.UpdateOne) route.Delete("/:id",m.RequirePermissions(m.P_ProjectFlockGetAll), ctrl.DeleteOne) route.Get("/kandangs/lookup",m.RequirePermissions(m.P_ProjectFlockLookup), ctrl.LookupProjectFlockKandang) route.Post("/approvals",m.RequirePermissions(m.P_ProjectFlockApprove), ctrl.Approval) diff --git a/internal/modules/production/project_flocks/services/projectflock.service.go b/internal/modules/production/project_flocks/services/projectflock.service.go index 1a7fc6f2..62e1d389 100644 --- a/internal/modules/production/project_flocks/services/projectflock.service.go +++ b/internal/modules/production/project_flocks/services/projectflock.service.go @@ -34,7 +34,6 @@ type ProjectflockService interface { GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlock, int64, map[uint]*flockDTO.FlockRelationDTO, error) GetOne(ctx *fiber.Ctx, id uint) (*entity.ProjectFlock, *flockDTO.FlockRelationDTO, error) CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.ProjectFlock, error) - UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.ProjectFlock, error) GetAvailableDocQuantity(ctx *fiber.Ctx, kandangID uint) (float64, error) DeleteOne(ctx *fiber.Ctx, id uint) error GetProjectFlockKandangByProjectAndKandang(ctx *fiber.Ctx, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, float64, error) @@ -255,6 +254,16 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (* return nil, err } + var location entity.Location + if err := s.Repository.DB().WithContext(c.Context()). + Where("id = ? AND area_id = ?", req.LocationId, req.AreaId). + First(&location).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusBadRequest, "Lokasi tidak berada pada area yang diminta") + } + return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi relasi area-lokasi") + } + canonicalBase := baseName if s.FlockRepo != nil { baseFlock, err := s.ensureFlockByName(c.Context(), actorID, baseName) @@ -348,365 +357,6 @@ func (s *projectflockService) CreateOne(c *fiber.Ctx, req *validation.Create) (* return s.getOneEntityOnly(c, createBody.Id) } -func (s projectflockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.ProjectFlock, error) { - if err := s.Validate.Struct(req); err != nil { - return nil, err - } - - actorID, err := m.ActorIDFromContext(c) - if err != nil { - return nil, err - } - - existing, err := s.Repository.GetByID(c.Context(), id, s.Repository.WithDefaultRelations()) - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Projectflock not found") - } - if err != nil { - s.Log.Errorf("Failed to fetch projectflock %d before update: %+v", id, err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock") - } - updateBody := make(map[string]any) - hasBodyChanges := false - var relationChecks []commonSvc.RelationCheck - existingBase := pfutils.DeriveBaseName(existing.FlockName) - targetBaseName := existingBase - needFlockNameRegenerate := false - - if req.FlockName != nil { - trimmed := strings.TrimSpace(*req.FlockName) - if trimmed == "" { - return nil, fiber.NewError(fiber.StatusBadRequest, "Flock name cannot be empty") - } - canonicalBase := trimmed - if s.FlockRepo != nil { - flockEntity, err := s.ensureFlockByName(c.Context(), actorID, trimmed) - if err != nil { - return nil, err - } - canonicalBase = flockEntity.Name - } - if !strings.EqualFold(canonicalBase, existingBase) { - needFlockNameRegenerate = true - targetBaseName = canonicalBase - hasBodyChanges = true - } - } - if req.AreaId != nil { - updateBody["area_id"] = *req.AreaId - hasBodyChanges = true - relationChecks = append(relationChecks, commonSvc.RelationCheck{ - Name: "Area", - ID: req.AreaId, - Exists: s.Repository.AreaExists, - }) - } - if req.Category != nil { - cat := strings.ToUpper(*req.Category) - if !utils.IsValidProjectFlockCategory(cat) { - return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid category") - } - - updateBody["category"] = cat - } - if req.FcrId != nil { - updateBody["fcr_id"] = *req.FcrId - hasBodyChanges = true - relationChecks = append(relationChecks, commonSvc.RelationCheck{ - Name: "FCR", - ID: req.FcrId, - Exists: s.Repository.FcrExists, - }) - } - if req.LocationId != nil { - updateBody["location_id"] = *req.LocationId - hasBodyChanges = true - relationChecks = append(relationChecks, commonSvc.RelationCheck{ - Name: "Location", - ID: req.LocationId, - Exists: s.Repository.LocationExists, - }) - } - - if len(relationChecks) > 0 { - if err := commonSvc.EnsureRelations(c.Context(), relationChecks...); err != nil { - return nil, err - } - } - - var newKandangIDs []uint - hasKandangChanges := false - if req.KandangIds != nil { - hasKandangChanges = true - if len(req.KandangIds) == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "kandang_ids cannot be empty") - } - newKandangIDs = uniqueUintSlice(req.KandangIds) - kandangs, err := s.KandangRepo.GetByIDs(c.Context(), newKandangIDs, nil) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Some kandangs not found") - } - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch kandangs") - } - if len(kandangs) != len(newKandangIDs) { - return nil, fiber.NewError(fiber.StatusNotFound, "Some kandangs not found") - } - targetLocationID := existing.LocationId - if req.LocationId != nil && *req.LocationId > 0 { - targetLocationID = *req.LocationId - } - for _, kandang := range kandangs { - if kandang.LocationId != targetLocationID { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Kandang %d tidak berada pada lokasi yang sama dengan project flock", kandang.Id)) - } - } - if linked, err := s.pivotRepo().HasKandangsLinkedToOtherProject(c.Context(), newKandangIDs, &id); err != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate kandangs linkage") - } else if linked { - return nil, fiber.NewError(fiber.StatusConflict, "Beberapa kandang sudah terikat dengan project flock lain") - } - - } - - hasChanges := hasBodyChanges || hasKandangChanges - if !hasChanges { - return s.getOneEntityOnly(c, id) - } - - err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { - projectRepo := repository.NewProjectflockRepository(dbTransaction) - - baseForGeneration := targetBaseName - if strings.TrimSpace(baseForGeneration) == "" { - baseForGeneration = existingBase - } - if strings.TrimSpace(baseForGeneration) == "" { - baseForGeneration = strings.TrimSpace(existing.FlockName) - } - - if needFlockNameRegenerate { - newName, _, err := s.generateSequentialFlockName(c.Context(), projectRepo, baseForGeneration, 1, &id) - if err != nil { - return err - } - updateBody["flock_name"] = newName - } - - if len(updateBody) > 0 { - if err := projectRepo.PatchOne(c.Context(), id, updateBody, nil); err != nil { - return err - } - } else { - if _, err := projectRepo.GetByID(c.Context(), id, nil); err != nil { - return err - } - } - - if req.KandangIds != nil { - existingIDs := make(map[uint]struct{}, len(existing.Kandangs)) - for _, k := range existing.Kandangs { - existingIDs[k.Id] = struct{}{} - } - newSet := make(map[uint]struct{}, len(newKandangIDs)) - for _, kid := range newKandangIDs { - newSet[kid] = struct{}{} - } - - var toDetach []uint - for kid := range existingIDs { - if _, ok := newSet[kid]; !ok { - toDetach = append(toDetach, kid) - } - } - - var toAttach []uint - for kid := range newSet { - if _, ok := existingIDs[kid]; !ok { - toAttach = append(toAttach, kid) - } - } - - if len(toDetach) > 0 { - if err := s.detachKandangs(c.Context(), dbTransaction, id, toDetach, true); err != nil { - return err - } - } - - if len(toAttach) > 0 { - currentPeriod, err := projectRepo.GetCurrentProjectPeriod(c.Context(), id) - if err != nil { - return err - } - - periods := make(map[uint]int, len(toAttach)) - if currentPeriod > 0 { - for _, kid := range toAttach { - periods[kid] = currentPeriod - } - } else { - periods, err = projectRepo.GetNextPeriodsForKandangs(c.Context(), toAttach) - if err != nil { - return err - } - } - - if err := s.attachKandangs(c.Context(), dbTransaction, id, toAttach, periods); err != nil { - return err - } - } - } - - if hasChanges { - approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) - if approvalSvc != nil { - latestBeforeReset, err := approvalSvc.LatestByTarget(c.Context(), s.approvalWorkflow, id, nil) - if err != nil { - return err - } - shouldRecordUpdate := latestBeforeReset == nil || - latestBeforeReset.StepNumber != uint16(utils.ProjectFlockStepPengajuan) || - latestBeforeReset.Action == nil || - (latestBeforeReset.Action != nil && *latestBeforeReset.Action != entity.ApprovalActionUpdated) - - if shouldRecordUpdate { - action := entity.ApprovalActionUpdated - if _, err := approvalSvc.CreateApproval( - c.Context(), - utils.ApprovalWorkflowProjectFlock, - id, - utils.ProjectFlockStepPengajuan, - &action, - actorID, - nil, - ); err != nil { - return err - } - } - } - } - - return nil - }) - - if err != nil { - if fiberErr, ok := err.(*fiber.Error); ok { - return nil, fiberErr - } - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Projectflock not found") - } - s.Log.Errorf("Failed to update projectflock %d: %+v", id, err) - if errors.Is(err, gorm.ErrDuplicatedKey) { - return nil, fiber.NewError(fiber.StatusConflict, "Project flock period already exists") - } - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to update project flock") - } - - return s.getOneEntityOnly(c, id) -} - -func (s projectflockService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entity.ProjectFlock, error) { - if err := s.Validate.Struct(req); err != nil { - return nil, err - } - - actorID, err := m.ActorIDFromContext(c) - if err != nil { - return nil, err - } - - var action entity.ApprovalAction - switch strings.ToUpper(strings.TrimSpace(req.Action)) { - case string(entity.ApprovalActionRejected): - action = entity.ApprovalActionRejected - case string(entity.ApprovalActionApproved): - action = entity.ApprovalActionApproved - default: - return nil, fiber.NewError(fiber.StatusBadRequest, "action must be APPROVED or REJECTED") - } - - approvableIDs := uniqueUintSlice(req.ApprovableIds) - if len(approvableIDs) == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "approvable_ids must contain at least one id") - } - - step := utils.ProjectFlockStepPengajuan - if action == entity.ApprovalActionApproved { - step = utils.ProjectFlockStepAktif - } - - err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { - approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) - kandangRepoTx := kandangRepository.NewKandangRepository(dbTransaction) - projectRepoTx := repository.NewProjectflockRepository(dbTransaction) - - for _, approvableID := range approvableIDs { - if _, err := projectRepoTx.GetByID(c.Context(), approvableID, nil); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Projectflock %d not found", approvableID)) - } - return err - } - - if _, err := approvalSvc.CreateApproval( - c.Context(), - utils.ApprovalWorkflowProjectFlock, - approvableID, - step, - &action, - actorID, - req.Notes, - ); err != nil { - return err - } - - switch action { - case entity.ApprovalActionApproved: - if err := kandangRepoTx.UpdateStatusByProjectFlockID( - c.Context(), - approvableID, - utils.KandangStatusActive, - ); err != nil { - return err - } - case entity.ApprovalActionRejected: - if err := kandangRepoTx.UpdateStatusByProjectFlockID( - c.Context(), - approvableID, - utils.KandangStatusNonActive, - ); err != nil { - return err - } - } - } - - return nil - }) - - if err != nil { - if fiberErr, ok := err.(*fiber.Error); ok { - return nil, fiberErr - } - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Projectflock not found") - } - s.Log.Errorf("Failed to record approval for projectflocks %+v: %+v", approvableIDs, err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to record approval") - } - - updated := make([]entity.ProjectFlock, 0, len(approvableIDs)) - for _, approvableID := range approvableIDs { - project, err := s.getOneEntityOnly(c, approvableID) - if err != nil { - return nil, err - } - updated = append(updated, *project) - } - - return updated, nil -} - func (s projectflockService) DeleteOne(c *fiber.Ctx, id uint) error { existing, err := s.Repository.GetByID(c.Context(), id, s.Repository.WithDefaultRelations()) if errors.Is(err, gorm.ErrRecordNotFound) { @@ -827,6 +477,27 @@ func (s projectflockService) GetAvailableDocQuantity(ctx *fiber.Ctx, kandangID u return total, nil } +// getProjectFlockClosingDate mengembalikan tanggal closing Project Flock jika sudah mencapai step SELESAI (Approved). +// func (s projectflockService) getProjectFlockClosingDate(ctx context.Context, projectFlockID uint) (*time.Time, error) { +// if projectFlockID == 0 || s.ApprovalSvc == nil { +// return nil, nil +// } + +// latest, err := s.ApprovalSvc.LatestByTarget(ctx, utils.ApprovalWorkflowProjectFlock, projectFlockID, nil) +// if err != nil { +// return nil, err +// } +// if latest == nil || latest.Action == nil || *latest.Action != entity.ApprovalActionApproved { +// return nil, nil +// } +// if latest.StepNumber != uint16(utils.ProjectFlockStepSelesai) { +// return nil, nil +// } + +// t := latest.ActionAt +// return &t, nil +// } + func (s projectflockService) GetProjectPeriods(c *fiber.Ctx, projectIDs []uint) (map[uint]int, error) { if len(projectIDs) == 0 { return map[uint]int{}, nil @@ -834,6 +505,133 @@ func (s projectflockService) GetProjectPeriods(c *fiber.Ctx, projectIDs []uint) return s.pivotRepo().ProjectPeriodsByProjectIDs(c.Context(), projectIDs) } +func (s projectflockService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entity.ProjectFlock, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + actorID, err := m.ActorIDFromContext(c) + if err != nil { + return nil, err + } + + var action entity.ApprovalAction + switch strings.ToUpper(strings.TrimSpace(req.Action)) { + case string(entity.ApprovalActionRejected): + action = entity.ApprovalActionRejected + case string(entity.ApprovalActionApproved): + action = entity.ApprovalActionApproved + default: + return nil, fiber.NewError(fiber.StatusBadRequest, "action must be APPROVED or REJECTED") + } + + approvableIDs := uniqueUintSlice(req.ApprovableIds) + if len(approvableIDs) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "approvable_ids must contain at least one id") + } + + step := utils.ProjectFlockStepPengajuan + if action == entity.ApprovalActionApproved { + step = utils.ProjectFlockStepAktif + } + + err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { + approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(dbTransaction)) + kandangRepoTx := kandangRepository.NewKandangRepository(dbTransaction) + projectRepoTx := repository.NewProjectflockRepository(dbTransaction) + projectFlockKandangRepoTx := repository.NewProjectFlockKandangRepository(dbTransaction) + + for _, approvableID := range approvableIDs { + if _, err := projectRepoTx.GetByID(c.Context(), approvableID, nil); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Projectflock %d not found", approvableID)) + } + return err + } + + if _, err := approvalSvc.CreateApproval( + c.Context(), + utils.ApprovalWorkflowProjectFlock, + approvableID, + step, + &action, + actorID, + req.Notes, + ); err != nil { + return err + } + + switch action { + case entity.ApprovalActionApproved: + if err := kandangRepoTx.UpdateStatusByProjectFlockID( + c.Context(), + approvableID, + utils.KandangStatusActive, + ); err != nil { + return err + } + + pfks, err := projectFlockKandangRepoTx.GetByProjectFlockID(c.Context(), approvableID) + if err != nil { + return err + } + for _, pfk := range pfks { + latest, lerr := approvalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowProjectFlockKandang, pfk.Id, nil) + if lerr != nil { + return lerr + } + if latest != nil && latest.StepNumber == uint16(utils.ProjectFlockKandangStepDisetujui) { + continue + } + if _, aerr := approvalSvc.CreateApproval( + c.Context(), + utils.ApprovalWorkflowProjectFlockKandang, + pfk.Id, + utils.ProjectFlockKandangStepDisetujui, + &action, + actorID, + req.Notes, + ); aerr != nil && !errors.Is(aerr, gorm.ErrDuplicatedKey) { + return aerr + } + } + case entity.ApprovalActionRejected: + if err := kandangRepoTx.UpdateStatusByProjectFlockID( + c.Context(), + approvableID, + utils.KandangStatusNonActive, + ); err != nil { + return err + } + } + } + + return nil + }) + + if err != nil { + if fiberErr, ok := err.(*fiber.Error); ok { + return nil, fiberErr + } + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Projectflock not found") + } + s.Log.Errorf("Failed to record approval for projectflocks %+v: %+v", approvableIDs, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to record approval") + } + + updated := make([]entity.ProjectFlock, 0, len(approvableIDs)) + for _, approvableID := range approvableIDs { + project, err := s.getOneEntityOnly(c, approvableID) + if err != nil { + return nil, err + } + updated = append(updated, *project) + } + + return updated, nil +} + func (s projectflockService) GetPeriodSummary(c *fiber.Ctx, locationID uint) ([]KandangPeriodSummary, error) { if locationID == 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "location_id is required") diff --git a/internal/modules/production/project_flocks/validations/projectflock.validation.go b/internal/modules/production/project_flocks/validations/projectflock.validation.go index 00b01456..66045dc1 100644 --- a/internal/modules/production/project_flocks/validations/projectflock.validation.go +++ b/internal/modules/production/project_flocks/validations/projectflock.validation.go @@ -10,15 +10,6 @@ type Create struct { ProjectBudgets []ProjectBudget `json:"project_budgets" validate:"required,min=1,dive"` } -type Update struct { - FlockName *string `json:"flock_name,omitempty" validate:"omitempty"` - AreaId *uint `json:"area_id,omitempty" validate:"omitempty,number,gt=0"` - Category *string `json:"category,omitempty" validate:"omitempty"` - FcrId *uint `json:"fcr_id,omitempty" validate:"omitempty,number,gt=0"` - LocationId *uint `json:"location_id,omitempty" validate:"omitempty,number,gt=0"` - KandangIds []uint `json:"kandang_ids,omitempty" validate:"omitempty,min=1,dive,gt=0"` -} - type Query struct { Page int `query:"page" validate:"omitempty,number,min=1"` Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` diff --git a/internal/modules/production/recordings/permissions.go b/internal/modules/production/recordings/permissions.go deleted file mode 100644 index 00f9bd48..00000000 --- a/internal/modules/production/recordings/permissions.go +++ /dev/null @@ -1,8 +0,0 @@ -package recordings - -const ( - PermissionRecordingRead = "recording.read" - PermissionRecordingCreate = "recording.write" - PermissionRecordingUpdate = "recording.update" - PermissionRecordingDelete = "recording.delete" -) diff --git a/internal/modules/purchases/dto/purchase.dto.go b/internal/modules/purchases/dto/purchase.dto.go index d6114952..1956729c 100644 --- a/internal/modules/purchases/dto/purchase.dto.go +++ b/internal/modules/purchases/dto/purchase.dto.go @@ -14,11 +14,12 @@ import ( ) type PurchaseRelationDTO struct { - Id uint `json:"id"` - PrNumber string `json:"pr_number"` - PoNumber *string `json:"po_number"` - PoDate *time.Time `json:"po_date"` - Notes *string `json:"notes"` + Id uint `json:"id"` + PrNumber string `json:"pr_number"` + PoNumber *string `json:"po_number"` + PoDate *time.Time `json:"po_date"` + CreditTerm int `json:"credit_term"` + Notes *string `json:"notes"` } type PurchaseListDTO struct { @@ -42,7 +43,6 @@ type PurchaseDetailDTO struct { LatestApproval *approvalDTO.ApprovalRelationDTO `json:"latest_approval"` } - type PurchaseItemDTO struct { Id uint `json:"id"` ProductID uint `json:"product_id"` @@ -59,16 +59,18 @@ type PurchaseItemDTO struct { TravelNumber *string `json:"travel_number"` TravelDocumentPath *string `json:"travel_document_path"` VehicleNumber *string `json:"vehicle_number"` + TransportPerItem *float64 `json:"transport_per_item,omitempty"` + ExpeditionVendor *supplierDTO.SupplierRelationDTO `json:"expedition_vendor,omitempty"` } - func ToPurchaseRelationDTO(p *entity.Purchase) PurchaseRelationDTO { return PurchaseRelationDTO{ - Id: p.Id, - PrNumber: p.PrNumber, - PoNumber: p.PoNumber, - PoDate: p.PoDate, - Notes: p.Notes, + Id: p.Id, + PrNumber: p.PrNumber, + PoNumber: p.PoNumber, + PoDate: p.PoDate, + CreditTerm: p.CreditTerm, + Notes: p.Notes, } } @@ -107,6 +109,20 @@ func ToPurchaseItemDTO(item entity.PurchaseItem) PurchaseItemDTO { dto.Warehouse = &summary } + if item.ExpenseNonstock != nil { + priceCopy := item.ExpenseNonstock.Price + dto.TransportPerItem = &priceCopy + + if item.ExpenseNonstock.Expense != nil { + exp := item.ExpenseNonstock.Expense + + if exp.Supplier != nil && exp.Supplier.Id != 0 { + supplierSummary := supplierDTO.ToSupplierRelationDTO(*exp.Supplier) + dto.ExpeditionVendor = &supplierSummary + } + } + } + return dto } 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/expense_bridge.go b/internal/modules/purchases/services/expense_bridge.go index 1f42872c..d8356e6a 100644 --- a/internal/modules/purchases/services/expense_bridge.go +++ b/internal/modules/purchases/services/expense_bridge.go @@ -155,109 +155,6 @@ func (b *expenseBridge) OnItemsDeleted(ctx context.Context, _ uint, items []enti }) } -func (b *expenseBridge) cleanupExistingNonstocks(ctx context.Context, updates []ExpenseReceivingPayload) error { - if len(updates) == 0 { - return nil - } - - itemIDs := make([]uint, 0, len(updates)) - for _, upd := range updates { - if upd.PurchaseItemID != 0 { - itemIDs = append(itemIDs, upd.PurchaseItemID) - } - } - if len(itemIDs) == 0 { - return nil - } - - return b.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - var links []struct { - ItemID uint - ExpenseNonstockID *uint64 - } - if err := tx.Model(&entity.PurchaseItem{}). - Select("id as item_id, expense_nonstock_id"). - Where("id IN ?", itemIDs). - Scan(&links).Error; err != nil { - return err - } - - expenseIDs := make(map[uint64]struct{}) - expenseNonstockIDs := make([]uint64, 0) - for _, link := range links { - if link.ExpenseNonstockID != nil && *link.ExpenseNonstockID != 0 { - expenseNonstockIDs = append(expenseNonstockIDs, *link.ExpenseNonstockID) - } - } - - if len(expenseNonstockIDs) == 0 { - return nil - } - - for _, nsID := range expenseNonstockIDs { - var expenseID uint64 - if err := tx.Model(&entity.ExpenseNonstock{}). - Select("expense_id"). - Where("id = ?", nsID). - Scan(&expenseID).Error; err != nil { - return err - } - if expenseID != 0 { - expenseIDs[expenseID] = struct{}{} - } - } - - if err := tx.Delete(&entity.ExpenseNonstock{}, expenseNonstockIDs).Error; err != nil { - return err - } - - approvalRepoTx := commonRepo.NewApprovalRepository(tx) - for expenseID := range expenseIDs { - var count int64 - if err := tx.Model(&entity.ExpenseNonstock{}). - Where("expense_id = ?", expenseID). - Count(&count).Error; err != nil { - return err - } - if count == 0 { - if err := approvalRepoTx.DeleteByTarget(ctx, utils.ApprovalWorkflowExpense.String(), uint(expenseID)); err != nil { - return err - } - if err := tx.Delete(&entity.Expense{}, expenseID).Error; err != nil { - return err - } - } - } - return nil - }) -} - -func (b *expenseBridge) cleanupEmptyExpenses(ctx context.Context, expenseIDs []uint64) error { - if len(expenseIDs) == 0 { - return nil - } - return b.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - approvalRepoTx := commonRepo.NewApprovalRepository(tx) - for _, id := range expenseIDs { - var count int64 - if err := tx.Model(&entity.ExpenseNonstock{}). - Where("expense_id = ?", id). - Count(&count).Error; err != nil { - return err - } - if count == 0 { - if err := approvalRepoTx.DeleteByTarget(ctx, utils.ApprovalWorkflowExpense.String(), uint(id)); err != nil { - return err - } - if err := tx.Delete(&entity.Expense{}, id).Error; err != nil { - return err - } - } - } - return nil - }) -} - func (b *expenseBridge) markExpensesUpdated(ctx context.Context, expenseIDs map[uint64]struct{}, actorID uint) error { if len(expenseIDs) == 0 { return nil diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index bbaa1b40..64a91e9d 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -99,6 +99,7 @@ func (s *purchaseService) withRelations(db *gorm.DB) *gorm.DB { return db. Preload("Supplier"). + Preload("CreatedUser"). Preload("Items", func(db *gorm.DB) *gorm.DB { return db.Order("id ASC") }). @@ -109,7 +110,10 @@ func (s *purchaseService) withRelations(db *gorm.DB) *gorm.DB { Preload("Items.Product.Flags"). Preload("Items.Warehouse.Area"). Preload("Items.Warehouse.Location"). - Preload("Items.ProductWarehouse") + Preload("Items.ProductWarehouse"). + Preload("Items.ExpenseNonstock"). + Preload("Items.ExpenseNonstock.Expense"). + Preload("Items.ExpenseNonstock.Expense.Supplier") } func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Purchase, int64, error) { @@ -121,7 +125,7 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti createdFrom, createdTo, err := utils.ParseDateRangeForQuery(params.CreatedFrom, params.CreatedTo) if err != nil { - return nil, 0, fiber.NewError(fiber.StatusBadRequest, err.Error()) + return nil, 0, utils.BadRequest(err.Error()) } purchases, total, err := s.PurchaseRepo.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { @@ -180,7 +184,7 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti if err != nil { s.Log.Errorf("Failed to get purchases: %+v", err) - return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchases") + return nil, 0, utils.Internal("Failed to get purchases") } for i := range purchases { @@ -193,19 +197,7 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti } func (s *purchaseService) GetOne(c *fiber.Ctx, id uint) (*entity.Purchase, error) { - purchase, err := s.PurchaseRepo.GetByID(c.Context(), id, s.withRelations) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Purchase not found") - } - s.Log.Errorf("Failed to get purchase: %+v", err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase") - } - - if err := s.attachLatestApproval(c.Context(), purchase); err != nil { - s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", id, err) - } - return purchase, nil + return s.loadPurchase(c.Context(), id) } func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchaseRequest) (*entity.Purchase, error) { @@ -220,10 +212,10 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase if _, err := s.SupplierRepo.GetByID(c.Context(), req.SupplierID, nil); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Supplier not found") + return nil, utils.NotFound("Supplier not found") } s.Log.Errorf("Failed to get supplier: %+v", err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get supplier") + return nil, utils.Internal("Failed to get supplier") } type aggregatedItem struct { @@ -234,7 +226,7 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase } if len(req.Items) == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Items must not be empty") + return nil, utils.BadRequest("Items must not be empty") } warehouseCache := make(map[uint]*entity.Warehouse) @@ -249,24 +241,27 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Warehouse %d not found", id)) + return nil, nil, utils.NotFound(fmt.Sprintf("Warehouse %d not found", id)) } s.Log.Errorf("Failed to get warehouse %d: %+v", id, err) - return nil, nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get warehouse") + return nil, nil, utils.Internal("Failed to get warehouse") } if warehouse.KandangId == nil || *warehouse.KandangId == 0 { - return nil, nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Warehouse %d is not linked to a kandang", id)) + return nil, nil, utils.BadRequest(fmt.Sprintf("Warehouse %d is not linked to a kandang", id)) } var pfkID *uint if s.ProjectFlockKandangRepo != nil { if pfk, err := s.ProjectFlockKandangRepo.GetActiveByKandangID(c.Context(), uint(*warehouse.KandangId)); err == nil && pfk != nil { + if pfk.ClosedAt != nil { + return nil, nil, utils.BadRequest("Project sudah closing") + } idCopy := uint(pfk.Id) pfkID = &idCopy } else if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Warehouse %d has no active project flock", id)) + return nil, nil, utils.BadRequest(fmt.Sprintf("Warehouse %d has no active project flock", id)) } else if err != nil { s.Log.Errorf("Failed to validate project flock for warehouse %d: %+v", id, err) - return nil, nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate project flock") + return nil, nil, utils.Internal("Failed to validate project flock") } } @@ -287,10 +282,10 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase linked, err := s.ProductRepo.IsLinkedToSupplier(c.Context(), item.ProductID, req.SupplierID) if err != nil { s.Log.Errorf("Failed to validate product %d for supplier %d: %+v", item.ProductID, req.SupplierID, err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product for supplier") + return nil, utils.Internal("Failed to validate product for supplier") } if !linked { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Product %d is not linked to supplier %d", item.ProductID, req.SupplierID)) + return nil, utils.BadRequest(fmt.Sprintf("Product %d is not linked to supplier %d", item.ProductID, req.SupplierID)) } productSupplierCache[item.ProductID] = true } @@ -315,17 +310,13 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase } var dueDate *time.Time - if req.DueDate != nil && strings.TrimSpace(*req.DueDate) != "" { - parsed, err := utils.ParseDateString(strings.TrimSpace(*req.DueDate)) - if err != nil { - return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid due_date, expected YYYY-MM-DD") - } - parsed = parsed.UTC() - dueDate = &parsed - } + now := time.Now().UTC() + d := now.AddDate(0, 0, req.CreditTerm) + dueDate = &d purchase := &entity.Purchase{ SupplierId: uint(req.SupplierID), + CreditTerm: req.CreditTerm, DueDate: dueDate, Notes: req.Notes, CreatedBy: uint(actorID), @@ -373,13 +364,13 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase }) if transactionErr != nil { s.Log.Errorf("Failed to create purchase: %+v", transactionErr) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to create purchase") + return nil, utils.Internal("Failed to create purchase") } created, err := s.PurchaseRepo.GetByID(c.Context(), purchase.Id, s.withRelations) if err != nil { s.Log.Errorf("Failed to load created purchase: %+v", err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase") + return nil, utils.Internal("Failed to load purchase") } if err := s.attachLatestApproval(c.Context(), created); err != nil { @@ -405,17 +396,15 @@ func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint, req *valid if err != nil { return nil, err } - - purchase, err := s.PurchaseRepo.GetByID(ctx, id, s.withRelations) + purchase, err := s.loadPurchase(ctx, id) if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Purchase not found") - } - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase") + return nil, err } - if err := s.attachLatestApproval(ctx, purchase); err != nil { - s.Log.Warnf("Unable to load latest approval for purchase %d: %+v", id, err) + if action == entity.ApprovalActionApproved { + if err := s.ensureProjectFlockNotClosedForPurchase(ctx, purchase); err != nil { + return nil, err + } } var latestStep uint16 @@ -429,7 +418,7 @@ func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint, req *valid isInitialApproval := latestStep < uint16(utils.PurchaseStepStaffPurchase) if isInitialApproval && latestStep != uint16(utils.PurchaseStepPengajuan) { - return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase is not ready for staff approval") + return nil, utils.BadRequest("Purchase is not ready for staff approval") } hasReceivingData := false @@ -442,8 +431,8 @@ func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint, req *valid syncReceiving := !isInitialApproval && hasReceivingData - if len(req.Items) == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Items must not be empty for staff approval") + if action == entity.ApprovalActionApproved && len(req.Items) == 0 { + return nil, utils.BadRequest("Items must not be empty for staff approval") } payload, err := s.buildStaffAdjustmentPayload(c.Context(), purchase, req, syncReceiving) @@ -491,18 +480,18 @@ func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint, req *valid }) if transactionErr != nil { if errors.Is(transactionErr, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Purchase item not found") + return nil, utils.NotFound("Purchase item not found") } if isInitialApproval { s.Log.Errorf("Failed to approve purchase %d: %+v", purchase.Id, transactionErr) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to approve purchase") + return nil, utils.Internal("Failed to approve purchase") } - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to update purchase pricing") + return nil, utils.Internal("Failed to update purchase pricing") } updated, err := s.PurchaseRepo.GetByID(c.Context(), purchase.Id, s.withRelations) if err != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase") + return nil, utils.Internal("Failed to load purchase") } if err := s.attachLatestApproval(c.Context(), updated); err != nil { s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err) @@ -526,22 +515,19 @@ func (s *purchaseService) ApproveManagerPurchase(c *fiber.Ctx, id uint, req *val return nil, err } - purchase, err := s.PurchaseRepo.GetByID(c.Context(), id, s.withRelations) + purchase, err := s.loadPurchase(c.Context(), id) if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Purchase not found") + return nil, err + } + + if action == entity.ApprovalActionApproved { + if err := s.ensureProjectFlockNotClosedForPurchase(c.Context(), purchase); err != nil { + return nil, err } - s.Log.Errorf("Failed to get purchase: %+v", err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase") } - - if err := s.attachLatestApproval(c.Context(), purchase); err != nil { - s.Log.Warnf("Unable to load latest approval for purchase %d: %+v", id, err) - } - if purchase.LatestApproval == nil || purchase.LatestApproval.StepNumber < uint16(utils.PurchaseStepStaffPurchase) { - return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase must reach staff purchase step before manager approval") + return nil, utils.BadRequest("Purchase must reach staff purchase step before manager approval") } if action == entity.ApprovalActionRejected { @@ -601,7 +587,7 @@ func (s *purchaseService) ApproveManagerPurchase(c *fiber.Ctx, id uint, req *val }) if transactionErr != nil { s.Log.Errorf("Failed to approve manager purchase %d: %+v", purchase.Id, transactionErr) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to generate purchase order") + return nil, utils.Internal("Failed to generate purchase order") } if generatedNumber != "" { @@ -612,7 +598,7 @@ func (s *purchaseService) ApproveManagerPurchase(c *fiber.Ctx, id uint, req *val updated, err := s.PurchaseRepo.GetByID(c.Context(), purchase.Id, s.withRelations) if err != nil { s.Log.Errorf("Failed to load purchase after manager approval: %+v", err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase") + return nil, utils.Internal("Failed to load purchase") } if err := s.attachLatestApproval(c.Context(), updated); err != nil { @@ -639,29 +625,26 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation return nil, err } - purchase, err := s.PurchaseRepo.GetByID(ctx, id, s.withRelations) + purchase, err := s.loadPurchase(ctx, id) if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Purchase not found") - } - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase ") + return nil, err } if purchase.PoNumber == nil || strings.TrimSpace(*purchase.PoNumber) == "" { - return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase order has not been generated") - } - - if err := s.attachLatestApproval(c.Context(), purchase); err != nil { - s.Log.Warnf("Unable to load latest approval for purchase %d: %+v", id, err) + return nil, utils.BadRequest("Purchase order has not been generated") } if purchase.LatestApproval == nil || purchase.LatestApproval.StepNumber < uint16(utils.PurchaseStepManager) { - return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase must be approved by manager before receiving products") + return nil, utils.BadRequest("Purchase must be approved by manager before receiving products") + } + if action == entity.ApprovalActionApproved { + if err := s.ensureProjectFlockNotClosedForPurchase(ctx, purchase); err != nil { + return nil, err + } } - if action == entity.ApprovalActionApproved && len(req.Items) == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Receiving data must not be empty") + return nil, utils.BadRequest("Receiving data must not be empty") } if action == entity.ApprovalActionRejected { @@ -670,7 +653,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation } updated, err := s.PurchaseRepo.GetByID(ctx, purchase.Id, s.withRelations) if err != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase") + return nil, utils.Internal("Failed to load purchase") } if err := s.attachLatestApproval(ctx, updated); err != nil { s.Log.Warnf("Unable to load latest approval for purchase %d: %+v", id, err) @@ -696,17 +679,22 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation visitedItems := make(map[uint]struct{}, len(req.Items)) prepared := make([]preparedReceiving, 0, len(req.Items)) + var earliestReceived *time.Time for _, payload := range req.Items { item, exists := itemMap[payload.PurchaseItemID] if !exists { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Purchase item %d not found", payload.PurchaseItemID)) + return nil, utils.BadRequest(fmt.Sprintf("Purchase item %d not found", payload.PurchaseItemID)) } receivedDate, err := utils.ParseDateString(payload.ReceivedDate) if err != nil { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Invalid received_date for item %d", payload.PurchaseItemID)) + return nil, utils.BadRequest(fmt.Sprintf("Invalid received_date for item %d", payload.PurchaseItemID)) } receivedDate = receivedDate.UTC() + if earliestReceived == nil || receivedDate.Before(*earliestReceived) { + copy := receivedDate + earliestReceived = © + } warehouseID := uint(item.WarehouseId) overrideWarehouse := false @@ -715,10 +703,10 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation overrideWarehouse = true } if warehouseID == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Warehouse must be specified for item %d", payload.PurchaseItemID)) + return nil, utils.BadRequest(fmt.Sprintf("Warehouse must be specified for item %d", payload.PurchaseItemID)) } if payload.WarehouseID != nil && uint(item.WarehouseId) != warehouseID { - return nil, fiber.NewError(fiber.StatusBadRequest, "Receiving does not allow changing warehouse") + return nil, utils.BadRequest("Receiving does not allow changing warehouse") } var receivedQty float64 @@ -728,14 +716,14 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation receivedQty = item.SubQty } if receivedQty < 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Received quantity for item %d cannot be negative", payload.PurchaseItemID)) + return nil, utils.BadRequest(fmt.Sprintf("Received quantity for item %d cannot be negative", payload.PurchaseItemID)) } if receivedQty > item.SubQty { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Received quantity for item %d cannot exceed ordered quantity (%.3f)", payload.PurchaseItemID, item.SubQty)) + return nil, utils.BadRequest(fmt.Sprintf("Received quantity for item %d cannot exceed ordered quantity (%.3f)", payload.PurchaseItemID, item.SubQty)) } if _, dup := visitedItems[payload.PurchaseItemID]; dup { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Duplicate receiving data for item %d", payload.PurchaseItemID)) + return nil, utils.BadRequest(fmt.Sprintf("Duplicate receiving data for item %d", payload.PurchaseItemID)) } visitedItems[payload.PurchaseItemID] = struct{}{} @@ -747,7 +735,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation var transportPerItem *float64 if payload.TransportPerItem != nil { if *payload.TransportPerItem < 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("transport_per_item for item %d cannot be negative", payload.PurchaseItemID)) + return nil, utils.BadRequest(fmt.Sprintf("transport_per_item for item %d cannot be negative", payload.PurchaseItemID)) } val := *payload.TransportPerItem transportPerItem = &val @@ -768,7 +756,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation // Require receiving payload to cover all purchase items so that // receiving cannot be submitted partially item-by-item. if len(visitedItems) != len(itemMap) { - return nil, fiber.NewError(fiber.StatusBadRequest, "Receiving data must be provided for all purchase items") + return nil, utils.BadRequest("Receiving data must be provided for all purchase items") } receivingAction := action @@ -792,7 +780,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation ) if err != nil { s.Log.Errorf("Failed to inspect receiving approval for purchase %d: %+v", purchase.Id, err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to record purchase receiving") + return nil, utils.Internal("Failed to record purchase receiving") } if latestReceiving != nil { @@ -826,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 } @@ -882,6 +876,16 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation return err } + // Update due_date based on earliest received date when receiving approved. + if earliestReceived != nil { + due := earliestReceived.AddDate(0, 0, purchase.CreditTerm) + if err := tx.Model(&entity.Purchase{}). + Where("id = ?", purchase.Id). + Update("due_date", due).Error; err != nil { + return err + } + } + if s.FifoSvc != nil { for _, adj := range fifoAdds { if adj.pwID == 0 || adj.qty <= 0 { @@ -903,15 +907,15 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation }) if transactionErr != nil { if errors.Is(transactionErr, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Purchase item not found for receiving") + return nil, utils.NotFound("Purchase item not found for receiving") } s.Log.Errorf("Failed to save purchase receiving %d: %+v", purchase.Id, transactionErr) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to record purchase receiving") + return nil, utils.Internal("Failed to record purchase receiving") } updated, err := s.PurchaseRepo.GetByID(c.Context(), purchase.Id, s.withRelations) if err != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase ") + return nil, utils.Internal("Failed to load purchase ") } if err := s.attachLatestApproval(c.Context(), updated); err != nil { s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err) @@ -936,7 +940,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation if fe, ok := err.(*fiber.Error); ok { return nil, fe } - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to sync expense") + return nil, utils.Internal("Failed to sync expense") } // Create approvals only after expense sync succeeds @@ -959,20 +963,23 @@ func (s *purchaseService) DeleteItems(c *fiber.Ctx, id uint, req *validation.Del purchase, err := s.PurchaseRepo.GetByID(ctx, id, s.withRelations) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Purchase not found") + return nil, utils.NotFound("Purchase not found") } - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase") + return nil, utils.Internal("Failed to get purchase") } if err := s.attachLatestApproval(ctx, purchase); err != nil { s.Log.Warnf("Unable to load latest approval for purchase %d: %+v", id, err) } + if err := s.ensureProjectFlockNotClosedForPurchase(ctx, purchase); err != nil { + return nil, err + } if purchase.LatestApproval == nil || purchase.LatestApproval.StepNumber == uint16(utils.PurchaseStepPengajuan) { - return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase cannot delete items before staff purchase approval") + return nil, utils.BadRequest("Purchase cannot delete items before staff purchase approval") } if len(purchase.Items) == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase has no items to delete") + return nil, utils.BadRequest("Purchase has no items to delete") } requested := make(map[uint]struct{}, len(req.ItemIDs)) @@ -992,7 +999,7 @@ func (s *purchaseService) DeleteItems(c *fiber.Ctx, id uint, req *validation.Del } if len(toDelete) == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Requested items were not found in this purchase") + return nil, utils.BadRequest("Requested items were not found in this purchase") } toDeleteSet := make(map[uint]struct{}, len(toDelete)) @@ -1007,7 +1014,7 @@ func (s *purchaseService) DeleteItems(c *fiber.Ctx, id uint, req *validation.Del } if len(purchase.Items)-len(toDelete) <= 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase must keep at least one item") + return nil, utils.BadRequest("Purchase must keep at least one item") } transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { @@ -1021,9 +1028,9 @@ func (s *purchaseService) DeleteItems(c *fiber.Ctx, id uint, req *validation.Del }) if transactionErr != nil { if errors.Is(transactionErr, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "Purchase item not found") + return nil, utils.NotFound("Purchase item not found") } - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to delete purchase items") + return nil, utils.Internal("Failed to delete purchase items") } if len(itemsToDelete) > 0 { @@ -1032,13 +1039,13 @@ func (s *purchaseService) DeleteItems(c *fiber.Ctx, id uint, req *validation.Del if fe, ok := err.(*fiber.Error); ok { return nil, fe } - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to sync expense") + return nil, utils.Internal("Failed to sync expense") } } updated, err := s.PurchaseRepo.GetByID(ctx, purchase.Id, s.withRelations) if err != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase") + return nil, utils.Internal("Failed to load purchase") } if err := s.attachLatestApproval(ctx, updated); err != nil { s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err) @@ -1049,16 +1056,16 @@ func (s *purchaseService) DeleteItems(c *fiber.Ctx, id uint, req *validation.Del func (s *purchaseService) DeletePurchase(c *fiber.Ctx, id uint) error { if id == 0 { - return fiber.NewError(fiber.StatusBadRequest, "Invalid purchase id") + return utils.BadRequest("Invalid purchase id") } ctx := c.Context() - purchase, err := s.PurchaseRepo.GetByID(ctx, id, s.withRelations) + purchase, err := s.loadPurchase(ctx, id) if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, "Purchase not found") - } - return fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase") + return err + } + if err := s.ensureProjectFlockNotClosedForPurchase(ctx, purchase); err != nil { + return err } itemsToDelete := make([]entity.PurchaseItem, len(purchase.Items)) @@ -1080,9 +1087,9 @@ func (s *purchaseService) DeletePurchase(c *fiber.Ctx, id uint) error { }) if transactionErr != nil { if errors.Is(transactionErr, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusNotFound, "Purchase not found") + return utils.NotFound("Purchase not found") } - return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete purchase") + return utils.Internal("Failed to delete purchase") } if len(itemsToDelete) > 0 { @@ -1091,7 +1098,7 @@ func (s *purchaseService) DeletePurchase(c *fiber.Ctx, id uint) error { if fe, ok := err.(*fiber.Error); ok { return fe } - return fiber.NewError(fiber.StatusInternalServerError, "Failed to sync expense") + return utils.Internal("Failed to sync expense") } } @@ -1109,7 +1116,7 @@ func (s *purchaseService) createPurchaseApproval( allowDuplicate bool, ) error { if purchaseID == 0 { - return fiber.NewError(fiber.StatusBadRequest, "Purchase is invalid for approval") + return utils.BadRequest("Purchase is invalid for approval") } if actorID == 0 { actorID = 1 @@ -1117,7 +1124,7 @@ func (s *purchaseService) createPurchaseApproval( svc := s.approvalServiceForDB(db) if svc == nil { - return fiber.NewError(fiber.StatusInternalServerError, "Approval service not available") + return utils.Internal("Approval service not available") } modifier := func(db *gorm.DB) *gorm.DB { @@ -1175,7 +1182,7 @@ func (s *purchaseService) buildStaffAdjustmentPayload( syncReceiving bool, ) (*staffAdjustmentPayload, error) { if len(req.Items) == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Items must not be empty") + return nil, utils.BadRequest("Items must not be empty") } requestItems := make(map[uint]validation.StaffPurchaseApprovalItem, len(req.Items)) @@ -1187,7 +1194,7 @@ func (s *purchaseService) buildStaffAdjustmentPayload( continue } if _, exists := requestItems[item.PurchaseItemID]; exists { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Duplicate pricing data for item %d", item.PurchaseItemID)) + return nil, utils.BadRequest(fmt.Sprintf("Duplicate pricing data for item %d", item.PurchaseItemID)) } requestItems[item.PurchaseItemID] = item } @@ -1205,34 +1212,31 @@ func (s *purchaseService) buildStaffAdjustmentPayload( allowedWarehouses[item.WarehouseId] = struct{}{} } if len(allowedWarehouses) == 0 && len(newPayloads) > 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "No available warehouses for this purchase") + return nil, utils.BadRequest("No available warehouses for this purchase") } for _, item := range purchase.Items { data, ok := requestItems[item.Id] if !ok { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Missing pricing data for item %d", item.Id)) + return nil, utils.BadRequest(fmt.Sprintf("Missing pricing data for item %d", item.Id)) } if data.ProductID != 0 && data.ProductID != item.ProductId { - return nil, fiber.NewError( - fiber.StatusBadRequest, - fmt.Sprintf("Cannot change product for item %d. Delete the item and create a new one instead", item.Id), - ) + return nil, utils.BadRequest(fmt.Sprintf("Cannot change product for item %d. Delete the item and create a new one instead", item.Id)) } if data.WarehouseID != 0 && data.WarehouseID != item.WarehouseId { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Warehouse mismatch for item %d", item.Id)) + return nil, utils.BadRequest(fmt.Sprintf("Warehouse mismatch for item %d", item.Id)) } effectiveQty := item.SubQty if data.Qty != nil { if *data.Qty <= 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Quantity for item %d must be greater than 0", item.Id)) + return nil, utils.BadRequest(fmt.Sprintf("Quantity for item %d must be greater than 0", item.Id)) } if item.TotalUsed > 0 && *data.Qty < item.TotalUsed { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Quantity for item %d cannot be lower than used amount (%.3f)", item.Id, item.TotalUsed)) + return nil, utils.BadRequest(fmt.Sprintf("Quantity for item %d cannot be lower than used amount (%.3f)", item.Id, item.TotalUsed)) } if (item.TotalQty > 0 || item.TotalUsed > 0) && !syncReceiving { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Cannot change quantity for item %d because it already has receiving data", item.Id)) + return nil, utils.BadRequest(fmt.Sprintf("Cannot change quantity for item %d because it already has receiving data", item.Id)) } effectiveQty = *data.Qty } @@ -1261,7 +1265,7 @@ func (s *purchaseService) buildStaffAdjustmentPayload( delete(requestItems, item.Id) } if len(requestItems) > 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Found pricing data for items that do not belong to this purchase") + return nil, utils.BadRequest("Found pricing data for items that do not belong to this purchase") } productSupplierCache := make(map[uint]bool) @@ -1270,37 +1274,28 @@ func (s *purchaseService) buildStaffAdjustmentPayload( for _, payload := range newPayloads { if payload.ProductID == 0 || payload.WarehouseID == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Product and warehouse must be provided for new items") + return nil, utils.BadRequest("Product and warehouse must be provided for new items") } if payload.Qty == nil || *payload.Qty <= 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Quantity must be greater than 0 for product %d", payload.ProductID)) + return nil, utils.BadRequest(fmt.Sprintf("Quantity must be greater than 0 for product %d", payload.ProductID)) } if _, ok := allowedWarehouses[payload.WarehouseID]; !ok { - return nil, fiber.NewError( - fiber.StatusBadRequest, - fmt.Sprintf("Warehouse %d is not available for this purchase", payload.WarehouseID), - ) + return nil, utils.BadRequest(fmt.Sprintf("Warehouse %d is not available for this purchase", payload.WarehouseID)) } key := fmt.Sprintf("%d:%d", payload.ProductID, payload.WarehouseID) if _, exists := existingCombos[key]; exists { - return nil, fiber.NewError( - fiber.StatusBadRequest, - fmt.Sprintf("Product %d in warehouse %d already exists in this purchase", payload.ProductID, payload.WarehouseID), - ) + return nil, utils.BadRequest(fmt.Sprintf("Product %d in warehouse %d already exists in this purchase", payload.ProductID, payload.WarehouseID)) } if _, checked := productSupplierCache[payload.ProductID]; !checked { linked, err := s.ProductRepo.IsLinkedToSupplier(ctx, uint(payload.ProductID), uint(purchase.SupplierId)) if err != nil { s.Log.Errorf("Failed to validate product %d for supplier %d: %+v", payload.ProductID, purchase.SupplierId, err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product for supplier") + return nil, utils.Internal("Failed to validate product for supplier") } if !linked { - return nil, fiber.NewError( - fiber.StatusBadRequest, - fmt.Sprintf("Product %d is not linked to supplier %d", payload.ProductID, purchase.SupplierId), - ) + return nil, utils.BadRequest(fmt.Sprintf("Product %d is not linked to supplier %d", payload.ProductID, purchase.SupplierId)) } productSupplierCache[payload.ProductID] = true } @@ -1328,7 +1323,7 @@ func (s *purchaseService) buildStaffAdjustmentPayload( } if len(updates) == 0 && len(newItems) == 0 { - return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase has no items to process") + return nil, utils.BadRequest("Purchase has no items to process") } return &staffAdjustmentPayload{ @@ -1340,10 +1335,10 @@ func (s *purchaseService) buildStaffAdjustmentPayload( // ? helper func calculateTotalPrice(quantity float64, price float64, provided *float64, ref string) (float64, error) { if quantity <= 0 { - return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Quantity for %s must be greater than 0", ref)) + return 0, utils.BadRequest(fmt.Sprintf("Quantity for %s must be greater than 0", ref)) } if price <= 0 { - return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Price for %s must be greater than 0", ref)) + return 0, utils.BadRequest(fmt.Sprintf("Price for %s must be greater than 0", ref)) } expectedTotal := price * quantity @@ -1352,10 +1347,10 @@ func calculateTotalPrice(quantity float64, price float64, provided *float64, ref return expectedTotal, nil } if *provided <= 0 { - return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Total price for %s must be greater than 0", ref)) + return 0, utils.BadRequest(fmt.Sprintf("Total price for %s must be greater than 0", ref)) } if math.Abs(*provided-expectedTotal) > priceTolerance { - return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Total price for %s must equal quantity x price", ref)) + return 0, utils.BadRequest(fmt.Sprintf("Total price for %s must equal quantity x price", ref)) } return *provided, nil } @@ -1384,7 +1379,7 @@ func parseApprovalActionInput(raw string) (entity.ApprovalAction, error) { case string(entity.ApprovalActionRejected): return entity.ApprovalActionRejected, nil default: - return "", fiber.NewError(fiber.StatusBadRequest, "action must be APPROVED or REJECTED") + return "", utils.BadRequest("action must be APPROVED or REJECTED") } } @@ -1399,13 +1394,58 @@ func (s *purchaseService) rejectAndReload( if err := s.createPurchaseApproval(c.Context(), nil, purchaseID, step, entity.ApprovalActionRejected, actorID, notes, false); err != nil { return nil, err } - - updated, err := s.PurchaseRepo.GetByID(c.Context(), purchaseID, s.withRelations) - if err != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase") - } - if err := s.attachLatestApproval(c.Context(), updated); err != nil { - s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err) - } - return updated, nil + return s.loadPurchase(c.Context(), purchaseID) +} +func (s *purchaseService) loadPurchase( + ctx context.Context, + id uint, +) (*entity.Purchase, error) { + purchase, err := s.PurchaseRepo.GetByID(ctx, id, s.withRelations) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, utils.NotFound("Purchase not found") + } + s.Log.Errorf("Failed to get purchase %d: %+v", id, err) + return nil, utils.Internal("Failed to get purchase") + } + + if err := s.attachLatestApproval(ctx, purchase); err != nil { + s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", id, err) + } + + return purchase, nil +} + +func collectPFKIDsFromPurchase(p *entity.Purchase) []uint { + seen := make(map[uint]struct{}) + ids := make([]uint, 0) + + for _, item := range p.Items { + if item.ProjectFlockKandangId == nil || *item.ProjectFlockKandangId == 0 { + continue + } + id := uint(*item.ProjectFlockKandangId) + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + ids = append(ids, id) + } + return ids +} +func (s *purchaseService) ensureProjectFlockNotClosedForPurchase( + ctx context.Context, + purchase *entity.Purchase, +) error { + pfkIDs := collectPFKIDsFromPurchase(purchase) + if len(pfkIDs) == 0 { + return nil + } + + db := s.PurchaseRepo.DB() + if db == nil { + return utils.Internal("DB not available for project flock validation") + } + + return commonSvc.EnsureProjectFlockNotClosedForProductWarehouses(ctx, db, pfkIDs) } diff --git a/internal/modules/purchases/validations/purchase.validation.go b/internal/modules/purchases/validations/purchase.validation.go index 6bbe9ddc..1637ccaf 100644 --- a/internal/modules/purchases/validations/purchase.validation.go +++ b/internal/modules/purchases/validations/purchase.validation.go @@ -8,6 +8,7 @@ type PurchaseItemPayload struct { type CreatePurchaseRequest struct { SupplierID uint `json:"supplier_id" validate:"required,gt=0"` + CreditTerm int `json:"credit_term" validate:"required,number,gte=0"` DueDate *string `json:"due_date,omitempty" validate:"omitempty,datetime=2006-01-02"` Notes *string `json:"notes" validate:"omitempty,max=500"` Items []PurchaseItemPayload `json:"items" validate:"required,min=1,dive"` diff --git a/internal/modules/repports/controllers/repport.controller.go b/internal/modules/repports/controllers/repport.controller.go new file mode 100644 index 00000000..21d3c49a --- /dev/null +++ b/internal/modules/repports/controllers/repport.controller.go @@ -0,0 +1,99 @@ +package controller + +import ( + "math" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type RepportController struct { + RepportService service.RepportService +} + +func NewRepportController(repportService service.RepportService) *RepportController { + return &RepportController{ + RepportService: repportService, + } +} + +func (c *RepportController) GetExpense(ctx *fiber.Ctx) error { + query := &validation.ExpenseQuery{ + 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.GetExpense(ctx, query) + if err != nil { + return err + } + + return ctx.Status(fiber.StatusOK). + JSON(response.SuccessWithPaginate[dto.RepportExpenseListDTO]{ + Code: fiber.StatusOK, + Status: "success", + Message: "Get expense report successfully", + Meta: response.Meta{ + Page: query.Page, + Limit: query.Limit, + TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))), + TotalResults: totalResults, + }, + 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/repportExpense.dto.go b/internal/modules/repports/dto/repportExpense.dto.go new file mode 100644 index 00000000..3e71df2c --- /dev/null +++ b/internal/modules/repports/dto/repportExpense.dto.go @@ -0,0 +1,179 @@ +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"` + 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 + Kandang *kandangDTO.KandangRelationDTO `json:"kandang,omitempty"` + 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 + } + + return RepportExpensePengajuanDTO{ + Id: ns.Id, + ExpenseId: ns.ExpenseId, + ProjectFlockKandangId: ns.ProjectFlockKandangId, + Qty: ns.Qty, + Price: ns.Price, + Notes: ns.Notes, + Nonstock: nonstock, + 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 + } + + // 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, + 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 + } + + // 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) + } + + return 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 new file mode 100644 index 00000000..4479b733 --- /dev/null +++ b/internal/modules/repports/module.go @@ -0,0 +1,28 @@ +package repports + +import ( + "github.com/go-playground/validator/v10" + "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" + marketingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" +) + +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, marketingDeliveryProductRepository, approvalSvc) + + RepportRoutes(router, repportService) +} diff --git a/internal/modules/repports/route.go b/internal/modules/repports/route.go new file mode 100644 index 00000000..4aea831c --- /dev/null +++ b/internal/modules/repports/route.go @@ -0,0 +1,17 @@ +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" + + "github.com/gofiber/fiber/v2" +) + +func RepportRoutes(v1 fiber.Router, s repport.RepportService) { + ctrl := controller.NewRepportController(s) + + route := v1.Group("/reports") + + route.Get("/expense", ctrl.GetExpense) + route.Get("/marketing", ctrl.GetMarketing) +} diff --git a/internal/modules/repports/services/repport.service.go b/internal/modules/repports/services/repport.service.go new file mode 100644 index 00000000..3adc5c0a --- /dev/null +++ b/internal/modules/repports/services/repport.service.go @@ -0,0 +1,115 @@ +package service + +import ( + "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" + marketingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type RepportService interface { + 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, 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.ExpenseQuery) ([]dto.RepportExpenseListDTO, int64, error) { + if err := s.Validate.Struct(params); err != nil { + return nil, 0, err + } + + offset := (params.Page - 1) * params.Limit + + 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 + } + + result := dto.ToRepportExpenseListDTOs(realizations) + + expenseIDs := make([]uint, 0, len(result)) + for i := range result { + expenseIDs = append(expenseIDs, uint(result[i].Id)) + } + + 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 +} + +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 new file mode 100644 index 00000000..7efc51f9 --- /dev/null +++ b/internal/modules/repports/validations/repport.validation.go @@ -0,0 +1,29 @@ +package validation + +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"` + 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"` +} + +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"` +} diff --git a/internal/route/route.go b/internal/route/route.go index 4d1c1bae..294fc900 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -19,6 +19,7 @@ import ( purchases "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases" ssoModule "gitlab.com/mbugroup/lti-api.git/internal/modules/sso" users "gitlab.com/mbugroup/lti-api.git/internal/modules/users" + repports "gitlab.com/mbugroup/lti-api.git/internal/modules/repports" // MODULE IMPORTS ) @@ -42,6 +43,7 @@ func Routes(app *fiber.App, db *gorm.DB) { expenses.ExpenseModule{}, ssoModule.Module{}, closings.ClosingModule{}, + repports.RepportModule{}, // MODULE REGISTRY } diff --git a/internal/utils/constant.go b/internal/utils/constant.go index 6594ac6b..b09bc187 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -165,17 +165,33 @@ var ProjectFlockApprovalSteps = map[approvalutils.ApprovalStep]string{ } // ------------------------------------------------------------------- -// Project Flock Kandang Approval +// Chickin Approval +// ------------------------------------------------------------------- +const ( + ApprovalWorkflowChickin approvalutils.ApprovalWorkflowKey = approvalutils.ApprovalWorkflowKey("CHICKINS") + ChickinStepPengajuan approvalutils.ApprovalStep = 1 + ChickinStepDisetujui approvalutils.ApprovalStep = 2 +) + +var ChickinApprovalSteps = map[approvalutils.ApprovalStep]string{ + ChickinStepPengajuan: "Pengajuan", + ChickinStepDisetujui: "Disetujui", +} + +// ------------------------------------------------------------------- +// Project-Flock kandang Approval // ------------------------------------------------------------------- const ( ApprovalWorkflowProjectFlockKandang approvalutils.ApprovalWorkflowKey = approvalutils.ApprovalWorkflowKey("PROJECT_FLOCK_KANDANGS") ProjectFlockKandangStepPengajuan approvalutils.ApprovalStep = 1 ProjectFlockKandangStepDisetujui approvalutils.ApprovalStep = 2 + ProjectFlockKandangStepClosed approvalutils.ApprovalStep = 3 ) var ProjectFlockKandangApprovalSteps = map[approvalutils.ApprovalStep]string{ ProjectFlockKandangStepPengajuan: "Pengajuan", ProjectFlockKandangStepDisetujui: "Disetujui", + ProjectFlockKandangStepClosed: "Selesai", } // ------------------------------------------------------------------- diff --git a/internal/utils/error.go b/internal/utils/error.go index e409e50c..ead06aeb 100644 --- a/internal/utils/error.go +++ b/internal/utils/error.go @@ -25,3 +25,16 @@ func ErrorHandler(c *fiber.Ctx, err error) error { func NotFoundHandler(c *fiber.Ctx) error { return response.Error(c, fiber.StatusNotFound, "Endpoint Not Found", nil) } + + +func BadRequest(msg string) error { + return fiber.NewError(fiber.StatusBadRequest, msg) +} + +func NotFound(msg string) error { + return fiber.NewError(fiber.StatusNotFound, msg) +} + +func Internal(msg string) error { + return fiber.NewError(fiber.StatusInternalServerError, msg) +} diff --git a/test/integration/production/recordings/recording_fifo_integration_test.go b/test/integration/production/recordings/recording_fifo_integration_test.go index 755e9e95..dd5f7d53 100644 --- a/test/integration/production/recordings/recording_fifo_integration_test.go +++ b/test/integration/production/recordings/recording_fifo_integration_test.go @@ -263,6 +263,7 @@ func createProductWarehouseRow(t *testing.T, db *gorm.DB, qty float64) entity.Pr ProductId: 1, WarehouseId: 1, Quantity: qty, + // CreatedBy: 1, } if err := db.Create(&pw).Error; err != nil { t.Fatalf("create product warehouse: %v", err)