diff --git a/internal/modules/purchases/services/expense_bridge.go b/internal/modules/purchases/services/expense_bridge.go index a4d6b3ac..3a72a9b4 100644 --- a/internal/modules/purchases/services/expense_bridge.go +++ b/internal/modules/purchases/services/expense_bridge.go @@ -47,6 +47,10 @@ type groupedItem struct { totalPrice float64 } +func groupingKey(supplierID uint, date time.Time, warehouseID uint) string { + return fmt.Sprintf("%d:%s:%d", supplierID, utils.FormatDate(date), warehouseID) +} + // expenseBridge is the real implementation that syncs purchases to expenses on receiving/deletion. type expenseBridge struct { db *gorm.DB @@ -232,6 +236,33 @@ func (b *expenseBridge) cleanupExistingNonstocks(ctx context.Context, updates [] }) } +// cleanupEmptyExpenses deletes expense headers (and approvals) that have no nonstocks. +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) OnItemsReceived(c *fiber.Ctx, purchaseID uint, updates []ExpenseReceivingPayload) error { if purchaseID == 0 || len(updates) == 0 { return nil @@ -260,6 +291,7 @@ func (b *expenseBridge) OnItemsReceived(c *fiber.Ctx, purchaseID uint, updates [ } itemLinks := make(map[uint]itemLink) + existingExpenseByKey := make(map[string]uint64) if len(updates) > 0 { ids := make([]uint, 0, len(updates)) for _, upd := range updates { @@ -286,6 +318,7 @@ func (b *expenseBridge) OnItemsReceived(c *fiber.Ctx, purchaseID uint, updates [ Scan(&rows).Error; err != nil { return err } + // Build quick lookup per item and per group key for existing expenses. for _, row := range rows { itemLinks[row.ItemID] = itemLink{ ExpenseNonstockID: row.ExpenseNonstockID, @@ -295,6 +328,16 @@ func (b *expenseBridge) OnItemsReceived(c *fiber.Ctx, purchaseID uint, updates [ Qty: row.Qty, Price: row.Price, } + if row.ExpenseID != 0 && row.SupplierID != 0 && !row.TransactionDate.IsZero() { + // Use warehouse from purchase item; if not found, skip key. + for i := range purchase.Items { + if purchase.Items[i].Id == row.ItemID { + key := groupingKey(row.SupplierID, row.TransactionDate.UTC().Truncate(24*time.Hour), purchase.Items[i].WarehouseId) + existingExpenseByKey[key] = row.ExpenseID + break + } + } + } } } } @@ -307,6 +350,8 @@ func (b *expenseBridge) OnItemsReceived(c *fiber.Ctx, purchaseID uint, updates [ groups := make(map[string][]groupedItem) toRecreate := make([]ExpenseReceivingPayload, 0) + movedFrom := make([]uint64, 0) + for _, payload := range updates { if payload.ReceivedDate == nil { return fiber.NewError(fiber.StatusBadRequest, "received_date is required") @@ -338,40 +383,31 @@ func (b *expenseBridge) OnItemsReceived(c *fiber.Ctx, purchaseID uint, updates [ pricePerItem = *payload.TransportPerItem } - // If any change other than received_date / supplier occurs (e.g., price or qty), delete-then-create. - if (link.Price != pricePerItem) || (link.Qty != payload.ReceivedQty) { - requiresDelete = true - } else if oldSupplier != supplierID || !oldDate.Equal(newDate) { - // Supplier/date change: keep (update) if this expense only has one nonstock; otherwise recreate to avoid affecting others. - var count int64 - if err := b.db.WithContext(ctx). - Model(&entity.ExpenseNonstock{}). - Where("expense_id = ?", link.ExpenseID). - Count(&count).Error; err != nil { - return err - } - if count <= 1 { - // Update expense header supplier/date in-place. - if err := b.db.WithContext(ctx). - Model(&entity.Expense{}). - Where("id = ?", link.ExpenseID). - Updates(map[string]interface{}{ - "supplier_id": supplierID, - "transaction_date": newDate, - }).Error; err != nil { - return err - } - // Update note just in case. + // Supplier/date change: prefer re-link to existing expense in target group; otherwise recreate. + if oldSupplier != supplierID || !oldDate.Equal(newDate) { + newKey := groupingKey(supplierID, newDate, payload.WarehouseID) + if targetExpenseID, ok := existingExpenseByKey[newKey]; ok && targetExpenseID != 0 { + // Move nonstock to existing expense header in the target group. note := fmt.Sprintf("purchase_item:%d", payload.PurchaseItemID) + pricePerItem := item.Price + if payload.TransportPerItem != nil { + pricePerItem = *payload.TransportPerItem + } if err := b.db.WithContext(ctx). Model(&entity.ExpenseNonstock{}). Where("id = ?", link.ExpenseNonstockID). Updates(map[string]interface{}{ - "notes": note, + "expense_id": targetExpenseID, + "qty": payload.ReceivedQty, + "price": pricePerItem, + "notes": note, }).Error; err != nil { return err } - // Continue to grouping with updated header. + // Track cleanup for old header if it becomes empty. + movedFrom = append(movedFrom, link.ExpenseID) + existingExpenseByKey[newKey] = targetExpenseID + handledUpdate = true } else { requiresDelete = true } @@ -379,10 +415,6 @@ func (b *expenseBridge) OnItemsReceived(c *fiber.Ctx, purchaseID uint, updates [ // If we reach here and no delete is required, update the existing nonstock fields and skip creation. if !requiresDelete { - pricePerItem := item.Price - if payload.TransportPerItem != nil { - pricePerItem = *payload.TransportPerItem - } note := fmt.Sprintf("purchase_item:%d", payload.PurchaseItemID) if err := b.db.WithContext(ctx). Model(&entity.ExpenseNonstock{}). @@ -511,6 +543,13 @@ func (b *expenseBridge) OnItemsReceived(c *fiber.Ctx, purchaseID uint, updates [ } } + // Cleanup old expense headers that became empty after re-link. + if len(movedFrom) > 0 { + if err := b.cleanupEmptyExpenses(ctx, movedFrom); err != nil { + return err + } + } + return nil } diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index 564226b4..55e45a80 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -110,9 +110,9 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti offset := (params.Page - 1) * params.Limit - createdFrom, createdTo, err := parseQueryDates(params.CreatedFrom, params.CreatedTo) + createdFrom, createdTo, err := utils.ParseDateRangeForQuery(params.CreatedFrom, params.CreatedTo) if err != nil { - return nil, 0, err + return nil, 0, fiber.NewError(fiber.StatusBadRequest, err.Error()) } purchases, total, err := s.PurchaseRepo.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { @@ -233,9 +233,9 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase if warehouse, ok := warehouseCache[id]; ok { return warehouse, nil } - warehouse, err := s.WarehouseRepo.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { - return db.Preload("Area").Preload("Location") - }) + warehouse, err := s.WarehouseRepo.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { + return db.Preload("Area").Preload("Location") + }) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { @@ -299,22 +299,22 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase purchase := &entity.Purchase{ SupplierId: uint(req.SupplierID), - DueDate: dueDate, - Notes: req.Notes, - CreatedBy: uint(actorID), + DueDate: dueDate, + Notes: req.Notes, + CreatedBy: uint(actorID), } items := make([]*entity.PurchaseItem, 0, len(aggregated)) emptyVehicle := "" for _, item := range aggregated { items = append(items, &entity.PurchaseItem{ - ProductId: item.productId, - WarehouseId: item.warehouseId, - SubQty: item.subQty, - TotalQty: 0, - TotalUsed: 0, - Price: 0, - TotalPrice: 0, + ProductId: item.productId, + WarehouseId: item.warehouseId, + SubQty: item.subQty, + TotalQty: 0, + TotalUsed: 0, + Price: 0, + TotalPrice: 0, VehicleNumber: &emptyVehicle, }) } @@ -856,13 +856,13 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation for _, prep := range prepared { date := prep.receivedDate payload := ExpenseReceivingPayload{ - PurchaseItemID: prep.item.Id, - ProductID: prep.item.ProductId, - WarehouseID: uint(prep.warehouseID), - SupplierID: prep.supplierID, + PurchaseItemID: prep.item.Id, + ProductID: prep.item.ProductId, + WarehouseID: uint(prep.warehouseID), + SupplierID: prep.supplierID, TransportPerItem: prep.transportPerItem, - ReceivedQty: prep.receivedQty, - ReceivedDate: &date, + ReceivedQty: prep.receivedQty, + ReceivedDate: &date, } receivingPayloads = append(receivingPayloads, payload) } @@ -1090,49 +1090,6 @@ func (s *purchaseService) approvalServiceForDB(db *gorm.DB) commonSvc.ApprovalSe return nil } -func (s *purchaseService) attachLatestApprovals(ctx context.Context, items []entity.Purchase) error { - if len(items) == 0 || s.ApprovalSvc == nil { - return nil - } - - ids := make([]uint, 0, len(items)) - visited := make(map[uint]struct{}, len(items)) - for _, item := range items { - if item.Id == 0 { - continue - } - if _, ok := visited[item.Id]; ok { - continue - } - visited[item.Id] = struct{}{} - ids = append(ids, uint(item.Id)) - } - - if len(ids) == 0 { - return nil - } - - latestMap, err := s.ApprovalSvc.LatestByTargets(ctx, utils.ApprovalWorkflowPurchase, ids, func(db *gorm.DB) *gorm.DB { - return db.Preload("ActionUser") - }) - if err != nil { - return err - } - - for i := range items { - if items[i].Id == 0 { - continue - } - if approval, ok := latestMap[uint(items[i].Id)]; ok { - items[i].LatestApproval = approval - } else { - items[i].LatestApproval = nil - } - } - - return nil -} - func (s *purchaseService) notifyExpenseItemsReceived(c *fiber.Ctx, purchaseID uint, payloads []ExpenseReceivingPayload) error { if s.ExpenseBridge == nil || purchaseID == 0 || len(payloads) == 0 { return nil @@ -1237,9 +1194,9 @@ func (s *purchaseService) buildStaffAdjustmentPayload( update.TotalQty = &qtyCopy } - updates = append(updates, update) - delete(requestItems, item.Id) - } + updates = append(updates, update) + 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") } @@ -1293,19 +1250,19 @@ func (s *purchaseService) buildStaffAdjustmentPayload( } newItem := &entity.PurchaseItem{ - PurchaseId: purchase.Id, - ProductId: payload.ProductID, - WarehouseId: payload.WarehouseID, - SubQty: qty, - TotalQty: 0, - TotalUsed: 0, - Price: payload.Price, - TotalPrice: totalPrice, + PurchaseId: purchase.Id, + ProductId: payload.ProductID, + WarehouseId: payload.WarehouseID, + SubQty: qty, + TotalQty: 0, + TotalUsed: 0, + Price: payload.Price, + TotalPrice: totalPrice, VehicleNumber: &emptyVehicle, } - newItems = append(newItems, newItem) - existingCombos[key] = struct{}{} - } + newItems = append(newItems, newItem) + existingCombos[key] = struct{}{} + } if len(updates) == 0 && len(newItems) == 0 { return nil, fiber.NewError(fiber.StatusBadRequest, "Purchase has no items to process") @@ -1356,14 +1313,6 @@ func (s *purchaseService) attachLatestApproval(ctx context.Context, item *entity return nil } -func parseQueryDates(fromStr, toStr string) (*time.Time, *time.Time, error) { - fromPtr, toPtr, err := utils.ParseDateRangeForQuery(fromStr, toStr) - if err != nil { - return nil, nil, fiber.NewError(fiber.StatusBadRequest, err.Error()) - } - return fromPtr, toPtr, nil -} - func parseApprovalActionInput(raw string) (entity.ApprovalAction, error) { value := strings.ToUpper(strings.TrimSpace(raw)) switch value {