From fd5f83ca58cc1c140a6e6977bee98f6f73b5031c Mon Sep 17 00:00:00 2001 From: ragilap Date: Wed, 31 Dec 2025 03:50:58 +0700 Subject: [PATCH] feat(BE-278): unrestrict feat warehouse purchase,adding purchase upload document --- internal/middleware/permissions.go | 1 + .../expenses/services/expense.service.go | 24 +++-- .../product_warehouse.repository.go | 25 +++++ .../controllers/purchase.controller.go | 36 +++++-- internal/modules/purchases/module.go | 1 + .../purchases/services/expense_bridge.go | 4 + .../purchases/services/purchase.service.go | 98 ++++++++++++++----- .../validations/purchase.validation.go | 29 +++--- internal/modules/repports/route.go | 2 +- internal/utils/constant.go | 2 + 10 files changed, 159 insertions(+), 63 deletions(-) diff --git a/internal/middleware/permissions.go b/internal/middleware/permissions.go index f0056149..1d308787 100644 --- a/internal/middleware/permissions.go +++ b/internal/middleware/permissions.go @@ -44,6 +44,7 @@ const ( P_ReportExpenseGetAll = "lti.repport.expense.list" P_ReportDeliveryGetAll = "lti.repport.delivery.list" P_ReportPurchaseSupplierGetAll = "lti.repport.purchasesupplier.list" + P_ReportHppPerKandangGetAll = "lti.repport.gethppperkandang.list" ) const ( diff --git a/internal/modules/expenses/services/expense.service.go b/internal/modules/expenses/services/expense.service.go index b4753451..37d4cec0 100644 --- a/internal/modules/expenses/services/expense.service.go +++ b/internal/modules/expenses/services/expense.service.go @@ -214,21 +214,19 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen return fiber.NewError(fiber.StatusInternalServerError, "Failed to get active project flocks for location") } - if len(activeProjectFlocks) == 0 { - return fiber.NewError(fiber.StatusBadRequest, "No active project flocks found for this location") - } + if len(activeProjectFlocks) > 0 { + projectFlockIDs := make([]uint64, len(activeProjectFlocks)) + for i, pf := range activeProjectFlocks { + projectFlockIDs[i] = uint64(pf.Id) + } - projectFlockIDs := make([]uint64, len(activeProjectFlocks)) - for i, pf := range activeProjectFlocks { - projectFlockIDs[i] = uint64(pf.Id) + projectFlockIdsJSON, err := json.Marshal(projectFlockIDs) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, "Failed to marshal project_flock_ids") + } + jsonStr := string(projectFlockIdsJSON) + projectFlockIdJSON = &jsonStr } - - projectFlockIdsJSON, err := json.Marshal(projectFlockIDs) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "Failed to marshal project_flock_ids") - } - jsonStr := string(projectFlockIdsJSON) - projectFlockIdJSON = &jsonStr } expense = &entity.Expense{ 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 e759138e..3cb22851 100644 --- a/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go +++ b/internal/modules/inventory/product-warehouses/repositories/product_warehouse.repository.go @@ -199,6 +199,31 @@ func (r *ProductWarehouseRepositoryImpl) CleanupEmpty(ctx context.Context, affec return nil } + var inUseIDs []uint + if err := r.DB().WithContext(ctx). + Model(&entity.PurchaseItem{}). + Where("product_warehouse_id IN ?", emptyIDs). + Distinct(). + Pluck("product_warehouse_id", &inUseIDs).Error; err != nil { + return err + } + if len(inUseIDs) > 0 { + inUse := make(map[uint]struct{}, len(inUseIDs)) + for _, id := range inUseIDs { + inUse[id] = struct{}{} + } + filtered := make([]uint, 0, len(emptyIDs)) + for _, id := range emptyIDs { + if _, exists := inUse[id]; !exists { + filtered = append(filtered, id) + } + } + emptyIDs = filtered + } + if len(emptyIDs) == 0 { + return nil + } + if err := r.DB().WithContext(ctx). Model(&entity.PurchaseItem{}). Where("product_warehouse_id IN ?", emptyIDs). diff --git a/internal/modules/purchases/controllers/purchase.controller.go b/internal/modules/purchases/controllers/purchase.controller.go index b4cf5660..d9b32cd1 100644 --- a/internal/modules/purchases/controllers/purchase.controller.go +++ b/internal/modules/purchases/controllers/purchase.controller.go @@ -1,6 +1,7 @@ package controller import ( + "encoding/json" "fmt" "math" "strconv" @@ -24,13 +25,13 @@ func NewPurchaseController(s service.PurchaseService) *PurchaseController { func (ctrl *PurchaseController) GetAll(c *fiber.Ctx) error { query := &validation.Query{ - Page: c.QueryInt("page", 1), - Limit: c.QueryInt("limit", 10), - CreatedFrom: strings.TrimSpace(c.Query("created_from")), - CreatedTo: strings.TrimSpace(c.Query("created_to")), - SupplierID: uint(c.QueryInt("supplier_id", 0)), - AreaID: uint(c.QueryInt("area_id", 0)), - LocationID: uint(c.QueryInt("location_id", 0)), + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + CreatedFrom: strings.TrimSpace(c.Query("created_from")), + CreatedTo: strings.TrimSpace(c.Query("created_to")), + SupplierID: uint(c.QueryInt("supplier_id", 0)), + AreaID: uint(c.QueryInt("area_id", 0)), + LocationID: uint(c.QueryInt("location_id", 0)), ProductCategoryID: uint(c.QueryInt("product_category_id", 0)), } @@ -86,7 +87,6 @@ func (ctrl *PurchaseController) CreateOne(c *fiber.Ctx) error { 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 @@ -161,10 +161,26 @@ func (ctrl *PurchaseController) ReceiveProducts(c *fiber.Ctx) error { } req := new(validation.ReceivePurchaseRequest) - if err := c.BodyParser(req); err != nil { - return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") + form, err := c.MultipartForm() + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid multipart form") + } + req.Action = c.FormValue("action") + if notes := strings.TrimSpace(c.FormValue("notes")); notes != "" { + req.Notes = ¬es } + itemsJSON := c.FormValue("items") + if strings.TrimSpace(itemsJSON) != "" { + if err := json.Unmarshal([]byte(itemsJSON), &req.Items); err != nil { + var singleItem validation.ReceivePurchaseItemRequest + if err := json.Unmarshal([]byte(itemsJSON), &singleItem); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "Invalid items JSON") + } + req.Items = []validation.ReceivePurchaseItemRequest{singleItem} + } + } + req.TravelDocuments = form.File["documents"] result, err := ctrl.service.ReceiveProducts(c, uint(id), req) if err != nil { return err diff --git a/internal/modules/purchases/module.go b/internal/modules/purchases/module.go index fa10559d..7e80de38 100644 --- a/internal/modules/purchases/module.go +++ b/internal/modules/purchases/module.go @@ -98,6 +98,7 @@ func (PurchaseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate approvalService, expenseBridge, fifoService, + documentSvc, ) userRepo := rUser.NewUserRepository(db) diff --git a/internal/modules/purchases/services/expense_bridge.go b/internal/modules/purchases/services/expense_bridge.go index 70a06c92..6c74a1fc 100644 --- a/internal/modules/purchases/services/expense_bridge.go +++ b/internal/modules/purchases/services/expense_bridge.go @@ -394,9 +394,13 @@ func (b *expenseBridge) OnItemsReceived(c *fiber.Ctx, purchaseID uint, updates [ } if kandangID != nil { updateBody["kandang_id"] = uint64(*kandangID) + } else { + updateBody["kandang_id"] = nil } if projectFK != nil { updateBody["project_flock_kandang_id"] = uint64(*projectFK) + } else { + updateBody["project_flock_kandang_id"] = nil } if err := b.db.WithContext(ctx). diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index 43c2bdc7..813fbd6f 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "math" + "mime/multipart" "strings" "time" @@ -57,6 +58,7 @@ type purchaseService struct { ApprovalSvc commonSvc.ApprovalService ExpenseBridge PurchaseExpenseBridge FifoSvc commonSvc.FifoService + DocumentSvc commonSvc.DocumentService approvalWorkflow approvalutils.ApprovalWorkflowKey } @@ -76,6 +78,7 @@ func NewPurchaseService( approvalSvc commonSvc.ApprovalService, expenseBridge PurchaseExpenseBridge, fifoSvc commonSvc.FifoService, + documentSvc commonSvc.DocumentService, ) PurchaseService { return &purchaseService{ Log: utils.Log, @@ -89,6 +92,7 @@ func NewPurchaseService( ApprovalSvc: approvalSvc, ExpenseBridge: expenseBridge, FifoSvc: fifoSvc, + DocumentSvc: documentSvc, approvalWorkflow: utils.ApprovalWorkflowPurchase, } } @@ -615,9 +619,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation if err := s.Validate.Struct(req); err != nil { return nil, err } - ctx := c.Context() - action, err := parseApprovalActionInput(req.Action) if err != nil { return nil, err @@ -664,6 +666,30 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation return updated, nil } + if action == entity.ApprovalActionApproved && len(req.TravelDocuments) > 0 { + if len(req.TravelDocuments) > len(req.Items) { + return nil, utils.BadRequest("Travel documents exceed total receiving items") + } + for idx, file := range req.TravelDocuments { + if file == nil { + continue + } + if idx >= len(req.Items) { + break + } + itemID := req.Items[idx].PurchaseItemID + if itemID == 0 { + return nil, utils.BadRequest("Purchase item id is required for travel document upload") + } + uploadedURL, err := s.uploadTravelDocument(ctx, actorID, itemID, file) + if err != nil { + s.Log.Errorf("Failed to upload travel document for item %d: %+v", itemID, err) + return nil, utils.Internal("Failed to upload travel document") + } + req.Items[idx].TravelDocumentPath = &uploadedURL + } + } + itemMap := make(map[uint]*entity.PurchaseItem, len(purchase.Items)) for i := range purchase.Items { itemMap[purchase.Items[i].Id] = &purchase.Items[i] @@ -807,32 +833,20 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation for _, prep := range prepared { item := prep.item - var oldPWID *uint - if item.ProductWarehouseId != nil { - idCopy := uint(*item.ProductWarehouseId) - oldPWID = &idCopy - } - var newPWID *uint - clearPW := false - // 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, - item.ProjectFlockKandangId, - purchase.CreatedBy, - ) - if err != nil { - return err - } - newPWID = &pwID - } else if oldPWID != nil { - newPWID = oldPWID - clearPW = true + // Always ensure PW after receiving so linkage stays stable. + pwID, err := pwRepoTx.EnsureProductWarehouse( + c.Context(), + uint(item.ProductId), + prep.warehouseID, + item.ProjectFlockKandangId, + purchase.CreatedBy, + ) + if err != nil { + return err } + newPWID = &pwID deltaQty := prep.receivedQty - item.TotalQty switch { @@ -857,7 +871,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation VehicleNumber: prep.payload.VehicleNumber, ReceivedQty: &qtyCopy, ProductWarehouseID: newPWID, - ClearProductWarehouse: clearPW, + ClearProductWarehouse: false, } if prep.overrideWarehouse || uint(item.WarehouseId) != prep.warehouseID { @@ -972,6 +986,38 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation return updated, nil } +func (s *purchaseService) uploadTravelDocument( + ctx context.Context, + actorID uint, + itemID uint, + file *multipart.FileHeader, +) (string, error) { + if file == nil { + return "", errors.New("travel document file is required") + } + if s.DocumentSvc == nil { + return "", errors.New("document service not available") + } + + documentFiles := []commonSvc.DocumentFile{{ + File: file, + Type: string(utils.DocumentTypePurchaseTravel), + }} + results, err := s.DocumentSvc.UploadDocuments(ctx, commonSvc.DocumentUploadRequest{ + DocumentableType: string(utils.DocumentableTypePurchaseItem), + DocumentableID: uint64(itemID), + CreatedBy: &actorID, + Files: documentFiles, + }) + if err != nil { + return "", err + } + if len(results) == 0 { + return "", errors.New("upload result is empty") + } + return results[0].URL, nil +} + func (s *purchaseService) DeleteItems(c *fiber.Ctx, id uint, req *validation.DeletePurchaseItemsRequest) (*entity.Purchase, error) { if err := s.Validate.Struct(req); err != nil { return nil, err diff --git a/internal/modules/purchases/validations/purchase.validation.go b/internal/modules/purchases/validations/purchase.validation.go index 1637ccaf..564cc96f 100644 --- a/internal/modules/purchases/validations/purchase.validation.go +++ b/internal/modules/purchases/validations/purchase.validation.go @@ -1,5 +1,7 @@ package validation +import "mime/multipart" + type PurchaseItemPayload struct { WarehouseID uint `json:"warehouse_id" validate:"required,gt=0"` ProductID uint `json:"product_id" validate:"required,gt=0"` @@ -26,7 +28,7 @@ type StaffPurchaseApprovalItem struct { type ApproveStaffPurchaseRequest struct { Action string `json:"action" validate:"required,oneof=APPROVED REJECTED"` - Items []StaffPurchaseApprovalItem `json:"items,omitempty" validate:"omitempty,min=1,dive"` + Items []StaffPurchaseApprovalItem `json:"items" validate:"omitempty,min=1,dive"` Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` } @@ -36,21 +38,22 @@ type ApproveManagerPurchaseRequest struct { } type ReceivePurchaseItemRequest struct { - PurchaseItemID uint `json:"purchase_item_id" validate:"required,gt=0"` - WarehouseID *uint `json:"warehouse_id" validate:"omitempty,gt=0"` - ReceivedDate string `json:"received_date" validate:"required,datetime=2006-01-02"` - ExpeditionVendorID *uint `json:"expedition_vendor_id,omitempty" validate:"omitempty,gt=0"` - TransportPerItem *float64 `json:"transport_per_item,omitempty" validate:"omitempty,gte=0"` - TravelNumber *string `json:"travel_number" validate:"omitempty,max=100"` - TravelDocumentPath *string `json:"travel_document_path" validate:"omitempty,max=255"` - VehicleNumber *string `json:"vehicle_number" validate:"omitempty,max=100"` - ReceivedQty *float64 `json:"received_qty" validate:"omitempty,gte=0"` + PurchaseItemID uint `form:"purchase_item_id" json:"purchase_item_id" validate:"required,gt=0"` + WarehouseID *uint `form:"warehouse_id" json:"warehouse_id" validate:"omitempty,gt=0"` + ReceivedDate string `form:"received_date" json:"received_date" validate:"required,datetime=2006-01-02"` + ExpeditionVendorID *uint `form:"expedition_vendor_id" json:"expedition_vendor_id,omitempty" validate:"omitempty,gt=0"` + TransportPerItem *float64 `form:"transport_per_item" json:"transport_per_item,omitempty" validate:"omitempty,gte=0"` + TravelNumber *string `form:"travel_number" json:"travel_number" validate:"omitempty,max=100"` + TravelDocumentPath *string `form:"travel_document_path" json:"travel_document_path" validate:"omitempty,max=1024"` + VehicleNumber *string `form:"vehicle_number" json:"vehicle_number" validate:"omitempty,max=100"` + ReceivedQty *float64 `form:"received_qty" json:"received_qty" validate:"omitempty,gte=0"` } type ReceivePurchaseRequest struct { - Action string `json:"action" validate:"required,oneof=APPROVED REJECTED"` - Items []ReceivePurchaseItemRequest `json:"items,omitempty" validate:"omitempty,min=1,dive"` - Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"` + Action string `form:"action" json:"action" validate:"required,oneof=APPROVED REJECTED"` + Items []ReceivePurchaseItemRequest `form:"items" json:"items" validate:"min=1,dive"` + TravelDocuments []*multipart.FileHeader `form:"travel_documents" json:"-" validate:"omitempty,dive"` + Notes *string `form:"notes" json:"notes,omitempty" validate:"omitempty,max=500"` } type DeletePurchaseItemsRequest struct { diff --git a/internal/modules/repports/route.go b/internal/modules/repports/route.go index 707ef878..83f133af 100644 --- a/internal/modules/repports/route.go +++ b/internal/modules/repports/route.go @@ -18,6 +18,6 @@ func RepportRoutes(v1 fiber.Router, u user.UserService, s repport.RepportService route.Get("/expense", m.RequirePermissions(m.P_ReportExpenseGetAll), ctrl.GetExpense) route.Get("/marketing", m.RequirePermissions(m.P_ReportDeliveryGetAll), ctrl.GetMarketing) route.Get("/purchase-supplier", m.RequirePermissions(m.P_ReportPurchaseSupplierGetAll), ctrl.GetPurchaseSupplier) - route.Get("/hpp-per-kandang", ctrl.GetHppPerKandang) + route.Get("/hpp-per-kandang", m.RequirePermissions(m.P_ReportHppPerKandangGetAll),ctrl.GetHppPerKandang) } diff --git a/internal/utils/constant.go b/internal/utils/constant.go index 85b0cc91..b7875605 100644 --- a/internal/utils/constant.go +++ b/internal/utils/constant.go @@ -411,10 +411,12 @@ const ( DocumentTypeTransfer DocumentType = "STOCK_TRANSFER_DOCUMENT" DocumentTypeExpense DocumentType = "EXPENSE_DOCUMENT" DocumentTypeExpenseRealization DocumentType = "EXPENSE_REALIZATION_DOCUMENT" + DocumentTypePurchaseTravel DocumentType = "PURCHASE_TRAVEL_DOCUMENT" DocumentableTypeTransfer DocumentableType = "STOCK_TRANSFER" DocumentableTypeExpense DocumentableType = "EXPENSE" DocumentableTypeExpenseRealization DocumentableType = "EXPENSE_REALIZATION" + DocumentableTypePurchaseItem DocumentableType = "PURCHASE_ITEM" ) // -------------------------------------------------------------------