diff --git a/internal/database/migrations/20251104084540_purchase-items.down.sql b/internal/database/migrations/20251104084540_purchase-items.down.sql new file mode 100644 index 00000000..46d2b5eb --- /dev/null +++ b/internal/database/migrations/20251104084540_purchase-items.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS purchase_items; diff --git a/internal/database/migrations/20251104084540_purchase-items.up.sql b/internal/database/migrations/20251104084540_purchase-items.up.sql new file mode 100644 index 00000000..a09b1d15 --- /dev/null +++ b/internal/database/migrations/20251104084540_purchase-items.up.sql @@ -0,0 +1,59 @@ +CREATE TABLE IF NOT EXISTS purchase_items ( + id BIGSERIAL PRIMARY KEY, + purchase_id BIGINT NOT NULL, + product_id BIGINT NOT NULL, + warehouse_id BIGINT NOT NULL, + product_warehouse_id BIGINT, + received_date TIMESTAMPTZ, + travel_number VARCHAR, + travel_number_docs VARCHAR, + vehicle_number VARCHAR, + sub_qty NUMERIC(15, 3) NOT NULL, + total_qty NUMERIC(15, 3) DEFAULT 0, + total_used NUMERIC(15, 3) DEFAULT 0, + price NUMERIC(15, 3) DEFAULT 0, + total_price NUMERIC(15, 3) DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + deleted_at TIMESTAMPTZ +); + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'products') THEN + EXECUTE + 'ALTER TABLE purchase_items + ADD CONSTRAINT fk_purchase_items_product + FOREIGN KEY (product_id) + REFERENCES products(id) + ON DELETE RESTRICT ON UPDATE CASCADE'; + END IF; + + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'warehouses') THEN + EXECUTE + 'ALTER TABLE purchase_items + ADD CONSTRAINT fk_purchase_items_warehouse + FOREIGN KEY (warehouse_id) + REFERENCES warehouses(id) + ON DELETE RESTRICT ON UPDATE CASCADE'; + END IF; + + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'product_warehouses') THEN + EXECUTE + 'ALTER TABLE purchase_items + ADD CONSTRAINT fk_purchase_items_product_warehouse + FOREIGN KEY (product_warehouse_id) + REFERENCES product_warehouses(id) + ON DELETE SET NULL ON UPDATE CASCADE'; + END IF; +END $$; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_purchase_items_unique_allocation + ON purchase_items (purchase_id, product_id, warehouse_id) + WHERE deleted_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_purchase_items_product_id ON purchase_items (product_id); +CREATE INDEX IF NOT EXISTS idx_purchase_items_warehouse_id ON purchase_items (warehouse_id); +CREATE INDEX IF NOT EXISTS idx_purchase_items_product_warehouse_id ON purchase_items (product_warehouse_id); +CREATE INDEX IF NOT EXISTS idx_purchase_items_purchase_id ON purchase_items (purchase_id); +CREATE INDEX IF NOT EXISTS idx_purchase_items_deleted_at ON purchase_items (deleted_at); diff --git a/internal/database/migrations/20251104084555_purchases.down.sql b/internal/database/migrations/20251104084555_purchases.down.sql new file mode 100644 index 00000000..f3900bbf --- /dev/null +++ b/internal/database/migrations/20251104084555_purchases.down.sql @@ -0,0 +1,14 @@ +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'fk_purchase_items_purchase' + AND conrelid = 'purchase_items'::regclass + ) THEN + ALTER TABLE purchase_items + DROP CONSTRAINT fk_purchase_items_purchase; + END IF; +END $$; + +DROP TABLE IF EXISTS purchases; diff --git a/internal/database/migrations/20251104084555_purchases.up.sql b/internal/database/migrations/20251104084555_purchases.up.sql new file mode 100644 index 00000000..e42f1606 --- /dev/null +++ b/internal/database/migrations/20251104084555_purchases.up.sql @@ -0,0 +1,64 @@ +CREATE TABLE IF NOT EXISTS purchases ( + id BIGSERIAL PRIMARY KEY, + pr_number VARCHAR NOT NULL, + po_number VARCHAR, + po_date TIMESTAMPTZ, + supplier_id BIGINT NOT NULL, + credit_term INT, + due_date TIMESTAMPTZ, + grand_total NUMERIC(15, 3) DEFAULT 0, + notes TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + created_by BIGINT NOT NULL +); + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'suppliers') THEN + EXECUTE + 'ALTER TABLE purchases + ADD CONSTRAINT fk_purchases_supplier + FOREIGN KEY (supplier_id) + REFERENCES suppliers(id) + ON DELETE RESTRICT ON UPDATE CASCADE'; + END IF; + + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'users') THEN + EXECUTE + 'ALTER TABLE purchases + ADD CONSTRAINT fk_purchases_created_by + FOREIGN KEY (created_by) + REFERENCES users(id) + ON DELETE RESTRICT ON UPDATE CASCADE'; + END IF; + + IF EXISTS ( + SELECT 1 FROM pg_tables WHERE tablename = 'purchase_items' + ) AND NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'fk_purchase_items_purchase' + ) THEN + EXECUTE + 'ALTER TABLE purchase_items + ADD CONSTRAINT fk_purchase_items_purchase + FOREIGN KEY (purchase_id) + REFERENCES purchases(id) + ON DELETE CASCADE ON UPDATE CASCADE'; + END IF; +END $$; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_purchases_pr_number_unique + ON purchases (pr_number) + WHERE deleted_at IS NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_purchases_po_number_unique + ON purchases (po_number) + WHERE deleted_at IS NULL AND po_number IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_purchases_supplier_id ON purchases (supplier_id); +CREATE INDEX IF NOT EXISTS idx_purchases_created_by ON purchases (created_by); +CREATE INDEX IF NOT EXISTS idx_purchases_po_date ON purchases (po_date); +CREATE INDEX IF NOT EXISTS idx_purchases_deleted_at ON purchases (deleted_at); diff --git a/internal/entities/purchase.go b/internal/entities/purchase.go new file mode 100644 index 00000000..1a57090a --- /dev/null +++ b/internal/entities/purchase.go @@ -0,0 +1,26 @@ +package entities + +import ( + "time" +) + +type Purchase struct { + Id uint64 `gorm:"primaryKey;autoIncrement"` + PrNumber string `gorm:"not null"` + PoNumber *string + PoDate *time.Time + SupplierId uint64 `gorm:"not null"` + CreditTerm *int + DueDate *time.Time + GrandTotal float64 `gorm:"type:numeric(15,3);default:0"` + Notes *string + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt *time.Time `gorm:"index"` + CreatedBy uint64 `gorm:"not null"` + + // Relations + Supplier Supplier `gorm:"foreignKey:SupplierId;references:Id"` + Items []PurchaseItem `gorm:"foreignKey:PurchaseId"` + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` +} diff --git a/internal/entities/purchase_item.go b/internal/entities/purchase_item.go new file mode 100644 index 00000000..b092b647 --- /dev/null +++ b/internal/entities/purchase_item.go @@ -0,0 +1,31 @@ +package entities + +import ( + "time" +) + +type PurchaseItem struct { + Id uint64 `gorm:"primaryKey;autoIncrement"` + PurchaseId uint64 `gorm:"not null"` + ProductId uint64 `gorm:"not null"` + WarehouseId uint64 `gorm:"not null"` + ProductWarehouseId *uint64 + ReceivedDate *time.Time + TravelNumber *string + TravelNumberDocs *string + VehicleNumber *string + SubQty float64 `gorm:"type:numeric(15,3);not null"` + TotalQty float64 `gorm:"type:numeric(15,3);default:0"` + TotalUsed float64 `gorm:"type:numeric(15,3);default:0"` + Price float64 `gorm:"type:numeric(15,3);default:0"` + TotalPrice float64 `gorm:"type:numeric(15,3);default:0"` + CreatedAt time.Time `gorm:"autoCreateTime"` + UpdatedAt time.Time `gorm:"autoUpdateTime"` + DeletedAt *time.Time `gorm:"index"` + + // Relations + Purchase *Purchase `gorm:"foreignKey:PurchaseId;references:Id"` + Product *Product `gorm:"foreignKey:ProductId;references:Id"` + Warehouse *Warehouse `gorm:"foreignKey:WarehouseId;references:Id"` + ProductWarehouse *ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` +} diff --git a/internal/modules/approvals/dto/approval.dto.go b/internal/modules/approvals/dto/approval.dto.go index 085c367c..52b99fc6 100644 --- a/internal/modules/approvals/dto/approval.dto.go +++ b/internal/modules/approvals/dto/approval.dto.go @@ -11,6 +11,7 @@ import ( ) type ApprovalBaseDTO struct { + Id uint `json:"id"` StepNumber uint16 `json:"step_number"` StepName string `json:"step_name"` Action *string `json:"action"` @@ -27,6 +28,7 @@ type ApprovalGroupDTO struct { func ToApprovalDTO(e entity.Approval) ApprovalBaseDTO { dto := ApprovalBaseDTO{ + Id: e.Id, Notes: e.Notes, } 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 fbda4718..151a3697 100644 --- a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go +++ b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go @@ -23,6 +23,7 @@ type ProductWarehouseRepository interface { GetFirstProductByFlag(ctx context.Context, flagName string) (*entity.Product, error) ApplyFlagsFilter(db *gorm.DB, flags []string) *gorm.DB AdjustQuantities(ctx context.Context, deltas map[uint]float64, modifier func(*gorm.DB) *gorm.DB) error + GetDetailByID(ctx context.Context, id uint) (*entity.ProductWarehouse, error) } type ProductWarehouseRepositoryImpl struct { @@ -149,6 +150,20 @@ func (r *ProductWarehouseRepositoryImpl) AdjustQuantities(ctx context.Context, d return nil } +func (r *ProductWarehouseRepositoryImpl) GetDetailByID(ctx context.Context, id uint) (*entity.ProductWarehouse, error) { + var productWarehouse entity.ProductWarehouse + err := r.DB().WithContext(ctx). + Preload("Product"). + Preload("Warehouse"). + Preload("Warehouse.Area"). + Preload("Warehouse.Location"). + First(&productWarehouse, id).Error + if err != nil { + return nil, err + } + return &productWarehouse, nil +} + func (r *ProductWarehouseRepositoryImpl) GetFirstProductByCategoryCode(ctx context.Context, categoryCode string) (*entity.Product, error) { var product entity.Product err := r.DB().WithContext(ctx). diff --git a/internal/modules/master/warehouses/repositories/warehouse.repository.go b/internal/modules/master/warehouses/repositories/warehouse.repository.go index e879e01a..ff05b3a1 100644 --- a/internal/modules/master/warehouses/repositories/warehouse.repository.go +++ b/internal/modules/master/warehouses/repositories/warehouse.repository.go @@ -17,6 +17,7 @@ type WarehouseRepository interface { IdExists(ctx context.Context, id uint) (bool, error) GetByKandangID(ctx context.Context, kandangId uint) (*entity.Warehouse, error) GetLatestByKandangID(ctx context.Context, kandangId uint) (*entity.Warehouse, error) + GetDetailByID(ctx context.Context, id uint) (*entity.Warehouse, error) } type WarehouseRepositoryImpl struct { @@ -62,6 +63,18 @@ func (r *WarehouseRepositoryImpl) GetByKandangID(ctx context.Context, kandangId return &warehouse, nil } +func (r *WarehouseRepositoryImpl) GetDetailByID(ctx context.Context, id uint) (*entity.Warehouse, error) { + var warehouse entity.Warehouse + err := r.db.WithContext(ctx). + Preload("Area"). + Preload("Location"). + First(&warehouse, id).Error + if err != nil { + return nil, err + } + return &warehouse, nil +} + func (r *WarehouseRepositoryImpl) GetLatestByKandangID(ctx context.Context, kandangId uint) (*entity.Warehouse, error) { var warehouse entity.Warehouse err := r.db.WithContext(ctx). diff --git a/internal/modules/production/project_flocks/route.go b/internal/modules/production/project_flocks/route.go index 7642b90c..70a22bad 100644 --- a/internal/modules/production/project_flocks/route.go +++ b/internal/modules/production/project_flocks/route.go @@ -12,7 +12,7 @@ import ( func ProjectflockRoutes(v1 fiber.Router, u user.UserService, s projectflock.ProjectflockService) { ctrl := controller.NewProjectflockController(s) - route := v1.Group("/project_flocks") + route := v1.Group("/project-flocks") // route.Get("/", m.Auth(u), ctrl.GetAll) // route.Post("/", m.Auth(u), ctrl.CreateOne) @@ -27,6 +27,6 @@ func ProjectflockRoutes(v1 fiber.Router, u user.UserService, s projectflock.Proj route.Delete("/:id", ctrl.DeleteOne) route.Get("/kandangs/lookup", ctrl.LookupProjectFlockKandang) route.Post("/approvals", ctrl.Approval) - route.Get("/kandangs/:project_flock_kandang_id/periods", ctrl.GetFlockPeriodSummary) + route.Get("/kandangs/:project-flock_kandang-id/periods", ctrl.GetFlockPeriodSummary) } diff --git a/internal/modules/production/recordings/repositories/recording.repository.go b/internal/modules/production/recordings/repositories/recording.repository.go index aa525951..d9512edd 100644 --- a/internal/modules/production/recordings/repositories/recording.repository.go +++ b/internal/modules/production/recordings/repositories/recording.repository.go @@ -254,18 +254,22 @@ func (r *RecordingRepositoryImpl) FindPreviousRecording(tx *gorm.DB, projectFloc } func (r *RecordingRepositoryImpl) GetTotalChick(tx *gorm.DB, projectFlockKandangId uint) (int64, error) { - var population entity.ProjectFlockPopulation + var total float64 err := tx. - Where("project_flock_kandang_id = ?", projectFlockKandangId). - Order("created_at DESC"). - First(&population).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - return 0, nil - } + Table("project_flock_populations"). + Select("COALESCE(SUM(project_flock_populations.total_qty - project_flock_populations.total_used_qty), 0) AS total_qty"). + Joins("JOIN project_chickins ON project_chickins.id = project_flock_populations.project_chickin_id"). + Where("project_chickins.project_flock_kandang_id = ?", projectFlockKandangId). + Scan(&total).Error if err != nil { return 0, err } - return int64(math.Round(population.TotalQty)), nil + + if total < 0 { + total = 0 + } + + return int64(math.Round(total)), nil } func (r *RecordingRepositoryImpl) GetAverageBodyWeight(tx *gorm.DB, recordingID uint) (float64, error) { diff --git a/internal/modules/production/recordings/services/recording.service.go b/internal/modules/production/recordings/services/recording.service.go index e8836590..b31a90c0 100644 --- a/internal/modules/production/recordings/services/recording.service.go +++ b/internal/modules/production/recordings/services/recording.service.go @@ -261,6 +261,10 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin return nil, err } + if req.BodyWeights == nil && req.Stocks == nil && req.Depletions == nil && req.Eggs == nil { + return s.GetOne(c, id) + } + ctx := c.Context() var recordingEntity *entity.Recording @@ -277,12 +281,21 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin } recordingEntity = recording + hasBodyChanges := req.BodyWeights != nil + hasStockChanges := req.Stocks != nil + hasDepletionChanges := req.Depletions != nil + hasEggChanges := req.Eggs != nil + + if !hasBodyChanges && !hasStockChanges && !hasDepletionChanges && !hasEggChanges { + return nil + } + var category string if recordingEntity.ProjectFlockKandang != nil && recordingEntity.ProjectFlockKandang.ProjectFlock.Id != 0 { category = strings.ToUpper(recordingEntity.ProjectFlockKandang.ProjectFlock.Category) } isLaying := category == strings.ToUpper(string(utils.ProjectFlockCategoryLaying)) - if req.Eggs != nil { + if hasEggChanges { if !isLaying && len(req.Eggs) > 0 { return fiber.NewError(fiber.StatusBadRequest, "Egg details permitted only for laying project flocks") } @@ -291,7 +304,29 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin } } - if req.BodyWeights != nil { + if hasStockChanges { + if err := s.ensureProductWarehousesExist(c, req.Stocks, nil, nil); err != nil { + return err + } + } + + if hasDepletionChanges || hasEggChanges { + if err := s.ensureProductWarehousesExist(c, nil, req.Depletions, req.Eggs); err != nil { + return err + } + } + + hasExistingGradings := false + for _, egg := range recordingEntity.Eggs { + if len(egg.GradingEggs) > 0 { + hasExistingGradings = true + break + } + } + + hasEggsAfterUpdate := len(recordingEntity.Eggs) > 0 + + if hasBodyChanges { if err := s.Repository.DeleteBodyWeights(tx, recordingEntity.Id); err != nil { s.Log.Errorf("Failed to clear body weights: %+v", err) return err @@ -302,11 +337,7 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin } } - if req.Stocks != nil { - if err := s.ensureProductWarehousesExist(c, req.Stocks, nil, nil); err != nil { - return err - } - + if hasStockChanges { existingStocks, err := s.Repository.ListStocks(tx, recordingEntity.Id) if err != nil { s.Log.Errorf("Failed to list existing stocks: %+v", err) @@ -330,17 +361,7 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin } } - if req.Eggs != nil && req.Depletions == nil { - if err := s.ensureProductWarehousesExist(c, nil, nil, req.Eggs); err != nil { - return err - } - } - - if req.Depletions != nil { - if err := s.ensureProductWarehousesExist(c, nil, req.Depletions, req.Eggs); err != nil { - return err - } - + if hasDepletionChanges { existingDepletions, err := s.Repository.ListDepletions(tx, recordingEntity.Id) if err != nil { s.Log.Errorf("Failed to list existing depletions: %+v", err) @@ -364,7 +385,7 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin } } - if req.Eggs != nil { + if hasEggChanges { existingEggs, err := s.Repository.ListEggs(tx, recordingEntity.Id) if err != nil { s.Log.Errorf("Failed to list existing eggs: %+v", err) @@ -386,17 +407,71 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin s.Log.Errorf("Failed to adjust product warehouses for eggs: %+v", err) return err } + + hasExistingGradings = false + hasEggsAfterUpdate = len(req.Eggs) > 0 } - if err := s.computeAndUpdateMetrics(ctx, tx, recordingEntity); err != nil { - s.Log.Errorf("Failed to recompute recording metrics: %+v", err) - return err + if hasBodyChanges || hasStockChanges || hasDepletionChanges { + if err := s.computeAndUpdateMetrics(ctx, tx, recordingEntity); err != nil { + s.Log.Errorf("Failed to recompute recording metrics: %+v", err) + return err + } } action := entity.ApprovalActionUpdated - if err := s.createRecordingApproval(ctx, tx, recordingEntity.Id, utils.RecordingStepPengajuan, action, recordingEntity.CreatedBy, nil); err != nil { - s.Log.Errorf("Failed to create approval after recording update %d: %+v", recordingEntity.Id, err) - return err + actorID := recordingEntity.CreatedBy + if actorID == 0 { + actorID = 1 + } + + var step approvalutils.ApprovalStep + if isLaying { + if !hasEggsAfterUpdate { + step = utils.RecordingStepGradingTelur + } else if hasEggChanges { + step = utils.RecordingStepGradingTelur + } else if hasExistingGradings { + step = utils.RecordingStepPengajuan + } else { + step = utils.RecordingStepGradingTelur + } + } else { + step = utils.RecordingStepPengajuan + } + + latestApproval := recordingEntity.LatestApproval + if latestApproval == nil { + if s.ApprovalSvc != nil { + if fetched, fetchErr := s.ApprovalSvc.LatestByTarget(ctx, utils.ApprovalWorkflowRecording, recordingEntity.Id, nil); fetchErr != nil { + s.Log.Errorf("Failed to load latest approval for recording %d: %+v", recordingEntity.Id, fetchErr) + return fetchErr + } else { + latestApproval = fetched + } + } else if s.ApprovalRepo != nil { + if fetched, fetchErr := s.ApprovalRepo.LatestByTarget(ctx, utils.ApprovalWorkflowRecording.String(), recordingEntity.Id, nil); fetchErr != nil { + s.Log.Errorf("Failed to load latest approval for recording %d: %+v", recordingEntity.Id, fetchErr) + return fetchErr + } else { + latestApproval = fetched + } + } + } + + shouldCreateApproval := true + if latestApproval != nil && + latestApproval.StepNumber == uint16(step) && + latestApproval.Action != nil && + *latestApproval.Action == action { + shouldCreateApproval = false + } + + if shouldCreateApproval { + if err := s.createRecordingApproval(ctx, tx, recordingEntity.Id, step, action, actorID, nil); err != nil { + s.Log.Errorf("Failed to create approval after recording update %d: %+v", recordingEntity.Id, err) + return err + } } return nil @@ -1015,13 +1090,21 @@ func (s *recordingService) ensureChickInExists(ctx context.Context, projectFlock return fiber.NewError(fiber.StatusBadRequest, "Project flock kandang tidak valid") } - _, err := s.ProjectFlockPopulationRepo.GetByProjectFlockKandangID(ctx, projectFlockKandangID) - if err == nil { - return nil + populations, err := s.ProjectFlockPopulationRepo.GetByProjectFlockKandangID(ctx, projectFlockKandangID) + if err != nil { + s.Log.Errorf("Failed to check project flock population for project_flock_kandang_id=%d: %+v", projectFlockKandangID, err) + return fiber.NewError(fiber.StatusInternalServerError, "Gagal memeriksa data chick in") } - if errors.Is(err, gorm.ErrRecordNotFound) { - return fiber.NewError(fiber.StatusBadRequest, "Project flock belum melakukan chick in sehingga belum dapat membuat recording") + + if len(populations) == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Project flock belum memiliki chick in yang disetujui sehingga belum dapat membuat recording") } - s.Log.Errorf("Failed to check project flock population for project_flock_kandang_id=%d: %+v", projectFlockKandangID, err) - return fiber.NewError(fiber.StatusInternalServerError, "Gagal memeriksa data chick in") + + for _, population := range populations { + if population.TotalQty > 0 { + return nil + } + } + + return fiber.NewError(fiber.StatusBadRequest, "Chick in project flock belum disetujui sehingga belum dapat membuat recording") } diff --git a/internal/modules/production/recordings/validations/recording.validation.go b/internal/modules/production/recordings/validations/recording.validation.go index 62e5f8df..28ea8a9f 100644 --- a/internal/modules/production/recordings/validations/recording.validation.go +++ b/internal/modules/production/recordings/validations/recording.validation.go @@ -2,9 +2,9 @@ package validation type ( BodyWeight struct { - AvgWeight float64 `json:"avg_weight" validate:"required"` - Qty float64 `json:"qty" validate:"required,gt=0"` - TotalWeight float64 `json:"total_weight" validate:"required,gte=0"` + AvgWeight float64 `json:"avg_weight" validate:"required"` + Qty float64 `json:"qty" validate:"required,gt=0"` + TotalWeight *float64 `json:"total_weight,omitempty" validate:"omitempty,gte=0"` } Stock struct { diff --git a/internal/modules/purchases/controllers/purchase.controller.go b/internal/modules/purchases/controllers/purchase.controller.go new file mode 100644 index 00000000..ffef2f5d --- /dev/null +++ b/internal/modules/purchases/controllers/purchase.controller.go @@ -0,0 +1,67 @@ +package controller + +import ( + "strconv" + + "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/dto" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/services" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/validations" + "gitlab.com/mbugroup/lti-api.git/internal/response" + + "github.com/gofiber/fiber/v2" +) + +type PurchaseController struct { + service service.PurchaseService +} + +func NewPurchaseController(s service.PurchaseService) *PurchaseController { + return &PurchaseController{service: s} +} + +func (ctrl *PurchaseController) CreateOne(c *fiber.Ctx) error { + req := new(validation.CreatePurchaseRequest) + + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := ctrl.service.CreateOne(c, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusCreated). + JSON(response.Success{ + Code: fiber.StatusCreated, + Status: "success", + Message: "Purchase requisition created successfully", + Data: dto.ToPurchaseDetailDTO(*result), + }) +} + +func (ctrl *PurchaseController) ApproveStaffPurchase(c *fiber.Ctx) error { + param := c.Params("id") + id, err := strconv.ParseUint(param, 10, 64) + if err != nil || id == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Invalid purchase requisition id") + } + + req := new(validation.ApproveStaffPurchaseRequest) + if err := c.BodyParser(req); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + } + + result, err := ctrl.service.ApproveStaffPurchase(c, id, req) + if err != nil { + return err + } + + return c.Status(fiber.StatusOK). + JSON(response.Success{ + Code: fiber.StatusOK, + Status: "success", + Message: "Staff purchase approval recorded successfully", + Data: dto.ToPurchaseDetailDTO(*result), + }) +} diff --git a/internal/modules/purchases/dto/purchase.dto.go b/internal/modules/purchases/dto/purchase.dto.go new file mode 100644 index 00000000..381115a6 --- /dev/null +++ b/internal/modules/purchases/dto/purchase.dto.go @@ -0,0 +1,155 @@ +package dto + +import ( + "time" + + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" +) + +type SupplierBaseDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + Alias string `json:"alias"` + Type string `json:"type"` + Category string `json:"category"` +} + +type AreaBaseDTO struct { + Id uint `json:"id"` + Name string `json:"name"` +} + +type LocationBaseDTO struct { + Id uint `json:"id"` + Name string `json:"name"` +} + +type WarehouseBaseDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + Area *AreaBaseDTO `json:"area,omitempty"` + Location *LocationBaseDTO `json:"location,omitempty"` +} + +type ProductBaseDTO struct { + Id uint `json:"id"` + Name string `json:"name"` + SKU *string `json:"sku,omitempty"` +} + +type PurchaseItemDTO struct { + Id uint64 `json:"id"` + Product *ProductBaseDTO `json:"product,omitempty"` + Warehouse *WarehouseBaseDTO `json:"warehouse,omitempty"` + ProductWarehouseID *uint64 `json:"product_warehouse_id,omitempty"` + SubQty float64 `json:"sub_qty"` + TotalQty float64 `json:"total_qty"` + TotalUsed float64 `json:"total_used"` + Price float64 `json:"price"` + TotalPrice float64 `json:"total_price"` +} + +type PurchaseDetailDTO struct { + Id uint64 `json:"id"` + PrNumber string `json:"pr_number"` + Supplier *SupplierBaseDTO `json:"supplier,omitempty"` + CreditTerm *int `json:"credit_term,omitempty"` + DueDate *time.Time `json:"due_date,omitempty"` + GrandTotal float64 `json:"grand_total"` + Notes *string `json:"notes,omitempty"` + Items []PurchaseItemDTO `json:"items"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func toSupplierBaseDTO(s entity.Supplier) *SupplierBaseDTO { + if s.Id == 0 { + return nil + } + return &SupplierBaseDTO{ + Id: s.Id, + Name: s.Name, + Alias: s.Alias, + Type: s.Type, + Category: s.Category, + } +} + +func toWarehouseBaseDTO(w *entity.Warehouse) *WarehouseBaseDTO { + if w == nil || w.Id == 0 { + return nil + } + dto := &WarehouseBaseDTO{ + Id: w.Id, + Name: w.Name, + } + if w.Area.Id != 0 { + dto.Area = &AreaBaseDTO{ + Id: w.Area.Id, + Name: w.Area.Name, + } + } + if w.Location != nil && w.Location.Id != 0 { + dto.Location = &LocationBaseDTO{ + Id: w.Location.Id, + Name: w.Location.Name, + } + } + return dto +} + +func toProductBaseDTO(p *entity.Product) *ProductBaseDTO { + if p == nil || p.Id == 0 { + return nil + } + dto := &ProductBaseDTO{ + Id: p.Id, + Name: p.Name, + } + if p.Sku != nil { + dto.SKU = p.Sku + } + return dto +} + +func ToPurchaseItemDTO(item entity.PurchaseItem) PurchaseItemDTO { + dto := PurchaseItemDTO{ + Id: item.Id, + ProductWarehouseID: item.ProductWarehouseId, + SubQty: item.SubQty, + TotalQty: item.TotalQty, + TotalUsed: item.TotalUsed, + Price: item.Price, + TotalPrice: item.TotalPrice, + } + if item.Product != nil { + dto.Product = toProductBaseDTO(item.Product) + } + if item.Warehouse != nil { + dto.Warehouse = toWarehouseBaseDTO(item.Warehouse) + } + return dto +} + +func ToPurchaseItemDTOs(items []entity.PurchaseItem) []PurchaseItemDTO { + result := make([]PurchaseItemDTO, len(items)) + for i, item := range items { + result[i] = ToPurchaseItemDTO(item) + } + return result +} + +func ToPurchaseDetailDTO(p entity.Purchase) PurchaseDetailDTO { + return PurchaseDetailDTO{ + Id: p.Id, + PrNumber: p.PrNumber, + Supplier: toSupplierBaseDTO(p.Supplier), + CreditTerm: p.CreditTerm, + DueDate: p.DueDate, + GrandTotal: p.GrandTotal, + Notes: p.Notes, + Items: ToPurchaseItemDTOs(p.Items), + CreatedAt: p.CreatedAt, + UpdatedAt: p.UpdatedAt, + } +} diff --git a/internal/modules/purchases/module.go b/internal/modules/purchases/module.go new file mode 100644 index 00000000..1397f27e --- /dev/null +++ b/internal/modules/purchases/module.go @@ -0,0 +1,13 @@ +package purchases + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" +) + +type PurchaseModule struct{} + +func (PurchaseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + RegisterRoutes(router, db, validate) +} diff --git a/internal/modules/purchases/repositories/purchase.repository.go b/internal/modules/purchases/repositories/purchase.repository.go new file mode 100644 index 00000000..398fcea1 --- /dev/null +++ b/internal/modules/purchases/repositories/purchase.repository.go @@ -0,0 +1,113 @@ +package repositories + +import ( + "context" + "errors" + + "gitlab.com/mbugroup/lti-api.git/internal/common/repository" + entity "gitlab.com/mbugroup/lti-api.git/internal/entities" + "gorm.io/gorm" +) + +type PurchaseRepository interface { + repository.BaseRepository[entity.Purchase] + CreateWithItems(ctx context.Context, purchase *entity.Purchase, items []*entity.PurchaseItem) error + GetByIDWithRelations(ctx context.Context, id uint64) (*entity.Purchase, error) + UpdatePricing(ctx context.Context, purchaseID uint64, updates []PurchasePricingUpdate, grandTotal float64) error +} + +type PurchaseRepositoryImpl struct { + *repository.BaseRepositoryImpl[entity.Purchase] +} + +func NewPurchaseRepository(db *gorm.DB) PurchaseRepository { + return &PurchaseRepositoryImpl{ + BaseRepositoryImpl: repository.NewBaseRepository[entity.Purchase](db), + } +} + +func (r *PurchaseRepositoryImpl) CreateWithItems(ctx context.Context, purchase *entity.Purchase, items []*entity.PurchaseItem) error { + db := r.DB().WithContext(ctx) + + if err := db.Create(purchase).Error; err != nil { + return err + } + + if len(items) == 0 { + return nil + } + + for _, item := range items { + item.PurchaseId = purchase.Id + } + + if err := db.Create(&items).Error; err != nil { + return err + } + return nil +} + +func (r *PurchaseRepositoryImpl) GetByIDWithRelations(ctx context.Context, id uint64) (*entity.Purchase, error) { + var purchase entity.Purchase + err := r.DB().WithContext(ctx). + Preload("Supplier"). + Preload("Items", func(db *gorm.DB) *gorm.DB { + return db.Order("id ASC") + }). + Preload("Items.Product"). + Preload("Items.Warehouse"). + Preload("Items.Warehouse.Area"). + Preload("Items.Warehouse.Location"). + Preload("Items.ProductWarehouse"). + First(&purchase, id).Error + if err != nil { + return nil, err + } + return &purchase, nil +} + +type PurchasePricingUpdate struct { + ItemID uint64 + Price float64 + TotalPrice float64 +} + +func (r *PurchaseRepositoryImpl) UpdatePricing( + ctx context.Context, + purchaseID uint64, + updates []PurchasePricingUpdate, + grandTotal float64, +) error { + if len(updates) == 0 { + return errors.New("pricing updates cannot be empty") + } + + db := r.DB().WithContext(ctx) + + for _, upd := range updates { + result := db.Model(&entity.PurchaseItem{}). + Where("purchase_id = ? AND id = ?", purchaseID, upd.ItemID). + Updates(map[string]interface{}{ + "price": upd.Price, + "total_price": upd.TotalPrice, + "updated_at": gorm.Expr("NOW()"), + }) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + } + + if err := db.Model(&entity.Purchase{}). + Where("id = ?", purchaseID). + Updates(map[string]interface{}{ + "grand_total": grandTotal, + "updated_at": gorm.Expr("NOW()"), + }).Error; err != nil { + return err + } + + return nil +} diff --git a/internal/modules/purchases/route.go b/internal/modules/purchases/route.go new file mode 100644 index 00000000..df3ea1a1 --- /dev/null +++ b/internal/modules/purchases/route.go @@ -0,0 +1,59 @@ +package purchases + +import ( + "fmt" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + 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" + rSupplier "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/repositories" + rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" + controller "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/controllers" + rPurchase "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories" + service "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/services" + rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" + sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" + utils "gitlab.com/mbugroup/lti-api.git/internal/utils" + "gorm.io/gorm" +) + +func RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) { + group := router.Group("/purchases") + + purchaseRepo := rPurchase.NewPurchaseRepository(db) + productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) + warehouseRepo := rWarehouse.NewWarehouseRepository(db) + supplierRepo := rSupplier.NewSupplierRepository(db) + userRepo := rUser.NewUserRepository(db) + + approvalRepo := commonRepo.NewApprovalRepository(db) + approvalService := commonSvc.NewApprovalService(approvalRepo) + if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowPurchase, utils.PurchaseApprovalSteps); err != nil { + panic(fmt.Sprintf("failed to register purchase approval workflow: %v", err)) + } + + purchaseService := service.NewPurchaseService( + validate, + purchaseRepo, + productWarehouseRepo, + warehouseRepo, + supplierRepo, + approvalRepo, + ) + userService := sUser.NewUserService(userRepo, validate) + + PurchaseRoutes(group, userService, purchaseService) +} + +func PurchaseRoutes(v1 fiber.Router, u sUser.UserService, s service.PurchaseService) { + ctrl := controller.NewPurchaseController(s) + + route := v1.Group("/requisitions") + + // route.Post("/", m.Auth(u), ctrl.CreateOne) + + route.Post("/", ctrl.CreateOne) + route.Post("/:id/approvals/staff", ctrl.ApproveStaffPurchase) +} diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go new file mode 100644 index 00000000..a7419fc5 --- /dev/null +++ b/internal/modules/purchases/services/purchase.service.go @@ -0,0 +1,337 @@ +package service + +import ( + "errors" + "fmt" + "time" + + 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" + rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" + rSupplier "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/repositories" + rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" + rPurchase "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories" + validation "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/validations" + "gitlab.com/mbugroup/lti-api.git/internal/utils" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type PurchaseService interface { + CreateOne(ctx *fiber.Ctx, req *validation.CreatePurchaseRequest) (*entity.Purchase, error) + ApproveStaffPurchase(ctx *fiber.Ctx, id uint64, req *validation.ApproveStaffPurchaseRequest) (*entity.Purchase, error) +} + +type purchaseService struct { + Log *logrus.Logger + Validate *validator.Validate + PurchaseRepo rPurchase.PurchaseRepository + ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository + WarehouseRepo rWarehouse.WarehouseRepository + SupplierRepo rSupplier.SupplierRepository + ApprovalRepo commonRepo.ApprovalRepository +} + +func NewPurchaseService( + validate *validator.Validate, + purchaseRepo rPurchase.PurchaseRepository, + productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, + warehouseRepo rWarehouse.WarehouseRepository, + supplierRepo rSupplier.SupplierRepository, + approvalRepo commonRepo.ApprovalRepository, +) PurchaseService { + return &purchaseService{ + Log: utils.Log, + Validate: validate, + PurchaseRepo: purchaseRepo, + ProductWarehouseRepo: productWarehouseRepo, + WarehouseRepo: warehouseRepo, + SupplierRepo: supplierRepo, + ApprovalRepo: approvalRepo, + } +} + +func uint64Ptr(v uint64) *uint64 { + return &v +} + +func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchaseRequest) (*entity.Purchase, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + supplier, err := s.SupplierRepo.GetByID(c.Context(), req.SupplierID, nil) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Supplier not found") + } + s.Log.Errorf("Failed to get supplier: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get supplier") + } + + warehouse, err := s.WarehouseRepo.GetDetailByID(c.Context(), req.WarehouseID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Warehouse not found") + } + s.Log.Errorf("Failed to get warehouse: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get warehouse") + } + + if warehouse.AreaId != req.AreaID { + return nil, fiber.NewError(fiber.StatusBadRequest, "Warehouse does not belong to the provided area") + } + if warehouse.LocationId == nil || *warehouse.LocationId != req.LocationID { + return nil, fiber.NewError(fiber.StatusBadRequest, "Warehouse does not belong to the provided location") + } + + type aggregatedItem struct { + productId uint64 + warehouseId uint64 + productWarehouseId *uint64 + subQty float64 + } + + if len(req.Items) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Items must not be empty") + } + + aggregated := make([]*aggregatedItem, 0, len(req.Items)) + indexMap := make(map[string]int) + + for _, item := range req.Items { + var ( + productId = uint64(item.ProductID) + warehouseId = uint64(req.WarehouseID) + productWarehouseId *uint64 + ) + + if item.ProductWarehouseID != nil { + productWarehouse, err := s.ProductWarehouseRepo.GetDetailByID(c.Context(), *item.ProductWarehouseID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Product warehouse %d not found", *item.ProductWarehouseID)) + } + s.Log.Errorf("Failed to get product warehouse %d: %+v", *item.ProductWarehouseID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get product warehouse") + } + + if productWarehouse.WarehouseId != req.WarehouseID { + return nil, fiber.NewError(fiber.StatusBadRequest, "Product warehouse does not match selected warehouse") + } + + productId = uint64(productWarehouse.ProductId) + warehouseId = uint64(productWarehouse.WarehouseId) + idCopy := uint64(productWarehouse.Id) + productWarehouseId = &idCopy + } else { + productWarehouse, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID(c.Context(), item.ProductID, req.WarehouseID) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + s.Log.Errorf("Failed to get product warehouse for product %d and warehouse %d: %+v", item.ProductID, req.WarehouseID, err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get product warehouse") + } + if err == nil { + idCopy := uint64(productWarehouse.Id) + productWarehouseId = &idCopy + } + } + + key := fmt.Sprintf("%d:%d", productId, warehouseId) + if idx, ok := indexMap[key]; ok { + aggregated[idx].subQty += item.Quantity + continue + } + + entry := &aggregatedItem{ + productId: productId, + warehouseId: warehouseId, + productWarehouseId: productWarehouseId, + subQty: item.Quantity, + } + aggregated = append(aggregated, entry) + indexMap[key] = len(aggregated) - 1 + } + + prNumber := fmt.Sprintf("PR-%s-%s", time.Now().Format("20060102"), uuid.NewString()[:8]) + + var creditTerm *int + var dueDate *time.Time + + if supplier.DueDate > 0 { + ct := supplier.DueDate + creditTerm = &ct + d := time.Now().UTC().AddDate(0, 0, ct) + dueDate = &d + } + + purchase := &entity.Purchase{ + PrNumber: prNumber, + SupplierId: uint64(req.SupplierID), + CreditTerm: creditTerm, + DueDate: dueDate, + GrandTotal: 0, + Notes: req.Notes, + CreatedBy: 1, // TODO: replace with authenticated user id once available + } + + items := make([]*entity.PurchaseItem, 0, len(aggregated)) + for _, item := range aggregated { + items = append(items, &entity.PurchaseItem{ + ProductId: item.productId, + WarehouseId: item.warehouseId, + ProductWarehouseId: item.productWarehouseId, + SubQty: item.subQty, + TotalQty: item.subQty, + TotalUsed: 0, + Price: 0, + TotalPrice: 0, + }) + } + + ctx := c.Context() + transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + purchaseRepoTx := rPurchase.NewPurchaseRepository(tx) + if err := purchaseRepoTx.CreateWithItems(ctx, purchase, items); err != nil { + return err + } + + actorID := uint(purchase.CreatedBy) + if actorID == 0 { + actorID = 1 + } + action := entity.ApprovalActionCreated + + approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx)) + if _, err := approvalSvc.CreateApproval( + ctx, + utils.ApprovalWorkflowPurchase, + uint(purchase.Id), + utils.PurchaseStepPengajuan, + &action, + actorID, + nil, + ); err != nil { + return err + } + + return nil + }) + if transactionErr != nil { + s.Log.Errorf("Failed to create purchase requisition: %+v", transactionErr) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to create purchase requisition") + } + + created, err := s.PurchaseRepo.GetByIDWithRelations(ctx, purchase.Id) + if err != nil { + s.Log.Errorf("Failed to load created purchase requisition: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase requisition") + } + + return created, nil +} + +func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint64, req *validation.ApproveStaffPurchaseRequest) (*entity.Purchase, error) { + if err := s.Validate.Struct(req); err != nil { + return nil, err + } + + ctx := c.Context() + + purchase, err := s.PurchaseRepo.GetByIDWithRelations(ctx, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Purchase requisition not found") + } + s.Log.Errorf("Failed to get purchase requisition: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get purchase requisition") + } + + if len(purchase.Items) == 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase requisition has no items to approve") + } + + requestItems := make(map[uint64]validation.StaffPurchaseApprovalItem, len(req.Items)) + for _, item := range req.Items { + requestItems[item.PurchaseItemID] = item + } + + updates := make([]rPurchase.PurchasePricingUpdate, 0, len(purchase.Items)) + var grandTotal float64 + + 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)) + } + delete(requestItems, item.Id) + + if data.Price <= 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Price for item %d must be greater than 0", item.Id)) + } + + totalPrice := data.TotalPrice + if totalPrice == nil { + calculated := data.Price * item.TotalQty + totalPrice = &calculated + } + if *totalPrice <= 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Total price for item %d must be greater than 0", item.Id)) + } + + updates = append(updates, rPurchase.PurchasePricingUpdate{ + ItemID: item.Id, + Price: data.Price, + TotalPrice: *totalPrice, + }) + grandTotal += *totalPrice + } + + if len(requestItems) > 0 { + return nil, fiber.NewError(fiber.StatusBadRequest, "Found pricing data for items that do not belong to this purchase requisition") + } + + action := entity.ApprovalActionApproved + actorID := uint(1) // TODO: replace with authenticated user id once available + + transactionErr := s.PurchaseRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + purchaseRepoTx := rPurchase.NewPurchaseRepository(tx) + if err := purchaseRepoTx.UpdatePricing(ctx, purchase.Id, updates, grandTotal); err != nil { + return err + } + + approvalSvc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(tx)) + if _, err := approvalSvc.CreateApproval( + ctx, + utils.ApprovalWorkflowPurchase, + uint(purchase.Id), + utils.PurchaseStepStaffPurchase, + &action, + actorID, + req.Notes, + ); err != nil { + return err + } + + return nil + }) + if transactionErr != nil { + if errors.Is(transactionErr, gorm.ErrRecordNotFound) { + return nil, fiber.NewError(fiber.StatusNotFound, "Purchase item not found") + } + s.Log.Errorf("Failed to approve purchase requisition %d: %+v", purchase.Id, transactionErr) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to approve purchase requisition") + } + + updated, err := s.PurchaseRepo.GetByIDWithRelations(ctx, purchase.Id) + if err != nil { + s.Log.Errorf("Failed to load purchase requisition after approval: %+v", err) + return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load purchase requisition") + } + + return updated, nil +} diff --git a/internal/modules/purchases/validations/purchase.validation.go b/internal/modules/purchases/validations/purchase.validation.go new file mode 100644 index 00000000..8791fc1c --- /dev/null +++ b/internal/modules/purchases/validations/purchase.validation.go @@ -0,0 +1,27 @@ +package validation + +type PurchaseItemPayload struct { + ProductID uint `json:"product_id" validate:"required"` + ProductWarehouseID *uint `json:"product_warehouse_id,omitempty" validate:"omitempty,gt=0"` + Quantity float64 `json:"quantity" validate:"required,gt=0"` +} + +type CreatePurchaseRequest struct { + SupplierID uint `json:"supplier_id" validate:"required"` + AreaID uint `json:"area_id" validate:"required"` + LocationID uint `json:"location_id" validate:"required"` + WarehouseID uint `json:"warehouse_id" validate:"required"` + Notes *string `json:"notes" validate:"omitempty,max=500"` + Items []PurchaseItemPayload `json:"items" validate:"required,min=1,dive"` +} + +type StaffPurchaseApprovalItem struct { + PurchaseItemID uint64 `json:"purchase_item_id" validate:"required,gt=0"` + Price float64 `json:"price" validate:"required,gt=0"` + TotalPrice *float64 `json:"total_price,omitempty" validate:"omitempty,gt=0"` +} + +type ApproveStaffPurchaseRequest struct { + Items []StaffPurchaseApprovalItem `json:"items" validate:"required,min=1,dive"` + Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` +} diff --git a/internal/route/route.go b/internal/route/route.go index 60f0fe26..d6277549 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -8,12 +8,13 @@ import ( "github.com/gofiber/fiber/v2" "gorm.io/gorm" + approvals "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals" constants "gitlab.com/mbugroup/lti-api.git/internal/modules/constants" inventory "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory" master "gitlab.com/mbugroup/lti-api.git/internal/modules/master" + production "gitlab.com/mbugroup/lti-api.git/internal/modules/production" + purchases "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases" users "gitlab.com/mbugroup/lti-api.git/internal/modules/users" - production "gitlab.com/mbugroup/lti-api.git/internal/modules/production" - approvals "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals" // MODULE IMPORTS ) @@ -28,8 +29,9 @@ func Routes(app *fiber.App, db *gorm.DB) { master.MasterModule{}, constants.ConstantModule{}, inventory.InventoryModule{}, - production.ProductionModule{}, - approvals.ApprovalModule{}, + production.ProductionModule{}, + approvals.ApprovalModule{}, + purchases.PurchaseModule{}, // MODULE REGISTRY } diff --git a/internal/utils/constant.go b/internal/utils/constant.go index ec98bce4..099b6510 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -205,8 +205,23 @@ const ( var RecordingApprovalSteps = map[approvalutils.ApprovalStep]string{ RecordingStepGradingTelur: "Grading-Telur", - RecordingStepPengajuan: "Pengajuan", - RecordingStepDisetujui: "Disetujui", + RecordingStepPengajuan: "Pengajuan", + RecordingStepDisetujui: "Disetujui", +} + +// ------------------------------------------------------------------- +// Purchase Approval +// ------------------------------------------------------------------- + +const ( + ApprovalWorkflowPurchase approvalutils.ApprovalWorkflowKey = approvalutils.ApprovalWorkflowKey("PURCHASES") + PurchaseStepPengajuan approvalutils.ApprovalStep = 1 + PurchaseStepStaffPurchase approvalutils.ApprovalStep = 2 +) + +var PurchaseApprovalSteps = map[approvalutils.ApprovalStep]string{ + PurchaseStepPengajuan: "Pengajuan", + PurchaseStepStaffPurchase: "Staff Purchase", } // ------------------------------------------------------------------- diff --git a/internal/utils/recording/util.recording.go b/internal/utils/recording/util.recording.go index e9ae371c..8f0fe81f 100644 --- a/internal/utils/recording/util.recording.go +++ b/internal/utils/recording/util.recording.go @@ -12,7 +12,10 @@ func MapBodyWeights(recordingID uint, items []validation.BodyWeight) []entity.Re result := make([]entity.RecordingBW, 0, len(items)) for _, item := range items { - totalWeight := item.TotalWeight + var totalWeight float64 + if item.TotalWeight != nil { + totalWeight = *item.TotalWeight + } if totalWeight <= 0 { totalWeight = item.AvgWeight * item.Qty }