diff --git a/internal/database/migrations/20251204193903_adjustment_purchase_expedition.down.sql b/internal/database/migrations/20251204193903_adjustment_purchase_expedition.down.sql index 27e33330..022e3a36 100644 --- a/internal/database/migrations/20251204193903_adjustment_purchase_expedition.down.sql +++ b/internal/database/migrations/20251204193903_adjustment_purchase_expedition.down.sql @@ -6,12 +6,20 @@ BEGIN ALTER TABLE purchase_items DROP CONSTRAINT fk_purchase_items_expense_nonstock; END IF; + IF EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'fk_purchase_items_project_flock_kandang' + ) THEN + ALTER TABLE purchase_items + DROP CONSTRAINT fk_purchase_items_project_flock_kandang; + END IF; END $$; DROP INDEX IF EXISTS idx_purchase_items_expense_nonstock_id; +DROP INDEX IF EXISTS idx_purchase_items_project_flock_kandang_id; ALTER TABLE purchase_items DROP COLUMN IF EXISTS expense_nonstock_id, + DROP COLUMN IF EXISTS project_flock_kandang_id, ALTER COLUMN vehicle_number DROP NOT NULL, ALTER COLUMN vehicle_number TYPE VARCHAR USING vehicle_number; @@ -27,4 +35,4 @@ ALTER TABLE purchases ALTER TABLE purchases ALTER COLUMN credit_term DROP DEFAULT, - ALTER COLUMN grand_total DROP DEFAULT; \ No newline at end of file + ALTER COLUMN grand_total DROP DEFAULT; diff --git a/internal/database/migrations/20251204193903_adjustment_purchase_expedition.up.sql b/internal/database/migrations/20251204193903_adjustment_purchase_expedition.up.sql index a5dca888..c8d5748f 100644 --- a/internal/database/migrations/20251204193903_adjustment_purchase_expedition.up.sql +++ b/internal/database/migrations/20251204193903_adjustment_purchase_expedition.up.sql @@ -11,7 +11,8 @@ ALTER TABLE purchases -- Bring purchase_items in line with new requirements ALTER TABLE purchase_items - ADD COLUMN IF NOT EXISTS expense_nonstock_id BIGINT; + ADD COLUMN IF NOT EXISTS expense_nonstock_id BIGINT, + ADD COLUMN IF NOT EXISTS project_flock_kandang_id BIGINT; UPDATE purchase_items SET vehicle_number = '' @@ -35,7 +36,22 @@ BEGIN ON DELETE SET NULL ON UPDATE CASCADE'; END IF; END IF; + + IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'project_flock_kandangs') THEN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'fk_purchase_items_project_flock_kandang' + ) THEN + EXECUTE + 'ALTER TABLE purchase_items + ADD CONSTRAINT fk_purchase_items_project_flock_kandang + FOREIGN KEY (project_flock_kandang_id) + REFERENCES project_flock_kandangs(id) + ON DELETE SET NULL ON UPDATE CASCADE'; + END IF; + END IF; END $$; CREATE INDEX IF NOT EXISTS idx_purchase_items_expense_nonstock_id - ON purchase_items (expense_nonstock_id); \ No newline at end of file + ON purchase_items (expense_nonstock_id); +CREATE INDEX IF NOT EXISTS idx_purchase_items_project_flock_kandang_id + ON purchase_items (project_flock_kandang_id); diff --git a/internal/entities/purchase_item.go b/internal/entities/purchase_item.go index f7cd0cdc..22cb62ed 100644 --- a/internal/entities/purchase_item.go +++ b/internal/entities/purchase_item.go @@ -5,21 +5,22 @@ import ( ) type PurchaseItem struct { - Id uint `gorm:"primaryKey;autoIncrement"` - PurchaseId uint `gorm:"not null"` - ProductId uint `gorm:"not null"` - WarehouseId uint `gorm:"not null"` - ProductWarehouseId *uint - 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"` - ExpenseNonstockId *uint64 + Id uint `gorm:"primaryKey;autoIncrement"` + PurchaseId uint `gorm:"not null"` + ProductId uint `gorm:"not null"` + WarehouseId uint `gorm:"not null"` + ProductWarehouseId *uint + ProjectFlockKandangId *uint + 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"` + ExpenseNonstockId *uint64 // Relations Purchase *Purchase `gorm:"foreignKey:PurchaseId;references:Id"` diff --git a/internal/modules/master/warehouses/controllers/warehouse.controller.go b/internal/modules/master/warehouses/controllers/warehouse.controller.go index afa90660..a7cfac94 100644 --- a/internal/modules/master/warehouses/controllers/warehouse.controller.go +++ b/internal/modules/master/warehouses/controllers/warehouse.controller.go @@ -24,10 +24,11 @@ func NewWarehouseController(warehouseService service.WarehouseService) *Warehous func (u *WarehouseController) GetAll(c *fiber.Ctx) error { query := &validation.Query{ - Page: c.QueryInt("page", 1), - Limit: c.QueryInt("limit", 10), - Search: c.Query("search", ""), - AreaId: c.QueryInt("area_id", 0), + Page: c.QueryInt("page", 1), + Limit: c.QueryInt("limit", 10), + Search: c.Query("search", ""), + AreaId: c.QueryInt("area_id", 0), + ActiveProjectFlockOnly: c.QueryBool("active_project_flock", false), } if query.Page < 1 || query.Limit < 1 { diff --git a/internal/modules/master/warehouses/services/warehouse.service.go b/internal/modules/master/warehouses/services/warehouse.service.go index 4c15b94c..79c41284 100644 --- a/internal/modules/master/warehouses/services/warehouse.service.go +++ b/internal/modules/master/warehouses/services/warehouse.service.go @@ -53,11 +53,28 @@ func (s warehouseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti warehouses, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { db = s.withRelations(db) if params.Search != "" { - return db.Where("name LIKE ?", "%"+params.Search+"%") + db = db.Where("warehouses.name LIKE ?", "%"+params.Search+"%") } if params.AreaId != 0 { db = db.Where("area_id = ?", params.AreaId) } + if params.ActiveProjectFlockOnly { + db = db.Where(` + EXISTS ( + SELECT 1 + FROM kandangs k + JOIN project_flock_kandangs pfk ON pfk.kandang_id = k.id + 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 = pfk.project_flock_id + WHERE k.id = warehouses.kandang_id + AND LOWER(latest_approval.step_name) = LOWER(?) + ) + `, "Aktif") + } return db.Order("created_at DESC").Order("updated_at DESC") }) diff --git a/internal/modules/master/warehouses/validations/warehouse.validation.go b/internal/modules/master/warehouses/validations/warehouse.validation.go index 6046defe..1e305520 100644 --- a/internal/modules/master/warehouses/validations/warehouse.validation.go +++ b/internal/modules/master/warehouses/validations/warehouse.validation.go @@ -17,8 +17,9 @@ type Update struct { } type Query struct { - Page int `query:"page" validate:"omitempty,number,min=1"` - Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` - Search string `query:"search" validate:"omitempty,max=50"` - AreaId int `query:"area_id" validate:"omitempty,number,gt=0"` + Page int `query:"page" validate:"omitempty,number,min=1"` + Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` + Search string `query:"search" validate:"omitempty,max=50"` + AreaId int `query:"area_id" validate:"omitempty,number,gt=0"` + ActiveProjectFlockOnly bool `query:"active_project_flock"` } diff --git a/internal/modules/purchases/module.go b/internal/modules/purchases/module.go index bcdc20aa..60f68edc 100644 --- a/internal/modules/purchases/module.go +++ b/internal/modules/purchases/module.go @@ -68,6 +68,7 @@ func (PurchaseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate warehouseRepo, supplierRepo, productWarehouseRepo, + projectFlockKandangRepository, approvalService, expenseBridge, ) diff --git a/internal/modules/purchases/repositories/purchase.repository.go b/internal/modules/purchases/repositories/purchase.repository.go index f83a4fe8..bcb35e85 100644 --- a/internal/modules/purchases/repositories/purchase.repository.go +++ b/internal/modules/purchases/repositories/purchase.repository.go @@ -24,6 +24,7 @@ type PurchaseRepository interface { DeleteItems(ctx context.Context, purchaseID uint, itemIDs []uint) error NextPrNumber(ctx context.Context, tx *gorm.DB) (string, error) NextPoNumber(ctx context.Context, tx *gorm.DB) (string, error) + BackfillProjectFlockKandang(ctx context.Context, purchaseID uint) error } type PurchaseRepositoryImpl struct { @@ -58,6 +59,34 @@ func (r *PurchaseRepositoryImpl) CreateWithItems(ctx context.Context, purchase * return nil } +func (r *PurchaseRepositoryImpl) BackfillProjectFlockKandang(ctx context.Context, purchaseID uint) error { + if purchaseID == 0 { + return nil + } + + query := ` +WITH latest_pfk AS ( + SELECT pfk.id, pfk.kandang_id + FROM project_flock_kandangs pfk + 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 = pfk.project_flock_id + WHERE LOWER(latest_approval.step_name) = LOWER('Aktif') +) +UPDATE purchase_items pi +SET project_flock_kandang_id = lp.id +FROM warehouses w +JOIN latest_pfk lp ON lp.kandang_id = w.kandang_id +WHERE pi.purchase_id = ? + AND pi.project_flock_kandang_id IS NULL + AND pi.warehouse_id = w.id; +` + return r.DB().WithContext(ctx).Exec(query, purchaseID).Error +} + func (r *PurchaseRepositoryImpl) CreateItems(ctx context.Context, purchaseID uint, items []*entity.PurchaseItem) error { if len(items) == 0 { return nil diff --git a/internal/modules/purchases/services/expense_bridge.go b/internal/modules/purchases/services/expense_bridge.go index f7bf8433..1f42872c 100644 --- a/internal/modules/purchases/services/expense_bridge.go +++ b/internal/modules/purchases/services/expense_bridge.go @@ -303,7 +303,6 @@ func (b *expenseBridge) OnItemsReceived(c *fiber.Ctx, purchaseID uint, updates [ } itemLinks := make(map[uint]itemLink) - existingExpenseByKey := make(map[string]uint64) updatedExpenses := make(map[uint64]struct{}) if len(updates) > 0 { ids := make([]uint, 0, len(updates)) @@ -341,16 +340,6 @@ 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 - } - } - } } } } @@ -361,9 +350,6 @@ 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 { @@ -383,10 +369,8 @@ func (b *expenseBridge) OnItemsReceived(c *fiber.Ctx, purchaseID uint, updates [ supplierID = purchase.SupplierId } - // Decide whether to update existing expense_nonstock or recreate. + // Decide whether to update existing expense_nonstock or create new. link, hasLink := itemLinks[payload.PurchaseItemID] - requiresDelete := false - handledUpdate := false if hasLink && link.ExpenseNonstockID != 0 && link.ExpenseID != 0 { oldDate := link.TransactionDate.UTC().Truncate(24 * time.Hour) newDate := receivedDate @@ -396,39 +380,8 @@ func (b *expenseBridge) OnItemsReceived(c *fiber.Ctx, purchaseID uint, updates [ pricePerItem = *payload.TransportPerItem } - // 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{}{ - "expense_id": targetExpenseID, - "qty": payload.ReceivedQty, - "price": pricePerItem, - "notes": note, - }).Error; err != nil { - return err - } - // Track cleanup for old header if it becomes empty. - movedFrom = append(movedFrom, link.ExpenseID) - existingExpenseByKey[newKey] = targetExpenseID - updatedExpenses[targetExpenseID] = struct{}{} - handledUpdate = true - } else { - requiresDelete = true - } - } - - // If we reach here and no delete is required, update the existing nonstock fields and skip creation. - if !requiresDelete { + // If supplier/date unchanged, update nonstock in place. + if oldSupplier == supplierID && oldDate.Equal(newDate) { note := fmt.Sprintf("purchase_item:%d", payload.PurchaseItemID) if err := b.db.WithContext(ctx). Model(&entity.ExpenseNonstock{}). @@ -443,19 +396,139 @@ func (b *expenseBridge) OnItemsReceived(c *fiber.Ctx, purchaseID uint, updates [ if link.ExpenseID != 0 { updatedExpenses[link.ExpenseID] = struct{}{} } - handledUpdate = true + continue } + + // Supplier/date changed: if the linked expense has only this nonstock, update it in place. + if link.ExpenseID != 0 { + var cnt int64 + if err := b.db.WithContext(ctx). + Model(&entity.ExpenseNonstock{}). + Where("expense_id = ?", link.ExpenseID). + Count(&cnt).Error; err != nil { + return err + } + if cnt == 1 { + if item.Warehouse == nil || item.Warehouse.KandangId == nil || *item.Warehouse.KandangId == 0 { + return fiber.NewError(fiber.StatusBadRequest, "Warehouse not connect to kandangs") + } + newNonstockID, err := b.findExpeditionNonstockID(ctx, supplierID) + if err != nil { + return err + } + note := fmt.Sprintf("purchase_item:%d", payload.PurchaseItemID) + if err := b.db.WithContext(ctx). + Model(&entity.Expense{}). + Where("id = ?", link.ExpenseID). + Updates(map[string]interface{}{ + "transaction_date": newDate, + "supplier_id": supplierID, + }).Error; err != nil { + return err + } + updateBody := map[string]interface{}{ + "qty": payload.ReceivedQty, + "price": pricePerItem, + "notes": note, + "nonstock_id": newNonstockID, + "kandang_id": uint64(*item.Warehouse.KandangId), + } + if err := b.db.WithContext(ctx). + Model(&entity.ExpenseNonstock{}). + Where("id = ?", link.ExpenseNonstockID). + Updates(updateBody).Error; err != nil { + return err + } + updatedExpenses[link.ExpenseID] = struct{}{} + continue + } + + // Expense has multiple nonstocks: create new expense header for this item, then move existing nonstock to it. + var kandangID *uint + var projectFK *uint + if item.Warehouse != nil && item.Warehouse.KandangId != nil { + id := uint(*item.Warehouse.KandangId) + kandangID = &id + if project, err := b.projectFlockKandangRepo.GetActiveByKandangID(ctx, uint(*item.Warehouse.KandangId)); err == nil && project != nil { + pid := uint(project.Id) + projectFK = &pid + } + } + pricePerItem := item.Price + if payload.TransportPerItem != nil { + pricePerItem = *payload.TransportPerItem + } + totalPrice := pricePerItem * payload.ReceivedQty + + gItem := groupedItem{ + item: item, + payload: payload, + projectFK: projectFK, + kandangID: kandangID, + totalPrice: totalPrice, + } + + newNonstockID, err := b.findExpeditionNonstockID(ctx, supplierID) + if err != nil { + return err + } + + expenseDetail, err := b.createExpenseViaService(c, purchase, []groupedItem{gItem}, newDate, newNonstockID, purchase.PoNumber, supplierID) + if err != nil { + return err + } + + var createdNonstockID uint64 + if expenseDetail != nil { + noteMap := mapExpenseNotes(expenseDetail) + createdNonstockID = noteMap[payload.PurchaseItemID] + } + + note := fmt.Sprintf("purchase_item:%d", payload.PurchaseItemID) + updateBody := map[string]interface{}{ + "expense_id": expenseDetail.Id, + "qty": payload.ReceivedQty, + "price": pricePerItem, + "notes": note, + "nonstock_id": newNonstockID, + } + if kandangID != nil { + updateBody["kandang_id"] = uint64(*kandangID) + } + if projectFK != nil { + updateBody["project_flock_kandang_id"] = uint64(*projectFK) + } + + if err := b.db.WithContext(ctx). + Model(&entity.ExpenseNonstock{}). + Where("id = ?", link.ExpenseNonstockID). + Updates(updateBody).Error; err != nil { + return err + } + + if createdNonstockID != 0 { + if err := b.db.WithContext(ctx).Delete(&entity.ExpenseNonstock{}, createdNonstockID).Error; err != nil { + return err + } + } + + if link.ExpenseID != 0 { + updatedExpenses[link.ExpenseID] = struct{}{} + } + if expenseDetail != nil && expenseDetail.Id != 0 { + updatedExpenses[uint64(expenseDetail.Id)] = struct{}{} + } + continue + } + + // Otherwise create new expense/nonstock in grouping flow. } - if requiresDelete { - toRecreate = append(toRecreate, payload) - continue + baseKey := fmt.Sprintf("%d:%s:%d", supplierID, utils.FormatDate(receivedDate), payload.WarehouseID) + key := baseKey + if hasLink && link.ExpenseNonstockID != 0 && link.ExpenseID != 0 { + key = fmt.Sprintf("%s:%d", baseKey, payload.PurchaseItemID) } - if handledUpdate { - continue - } - - key := fmt.Sprintf("%d:%s:%d", supplierID, utils.FormatDate(receivedDate), payload.WarehouseID) var kandangID *uint var projectFK *uint @@ -481,54 +554,6 @@ func (b *expenseBridge) OnItemsReceived(c *fiber.Ctx, purchaseID uint, updates [ kandangID: kandangID, totalPrice: totalPrice, }) - if existingID, ok := existingExpenseByKey[key]; ok && existingID != 0 { - updatedExpenses[existingID] = struct{}{} - } - } - - // For payloads that require delete/recreate, clean up their old links first. - if len(toRecreate) > 0 { - if err := b.cleanupExistingNonstocks(ctx, toRecreate); err != nil { - return err - } - // Then add them back into grouping for creation. - for _, payload := range toRecreate { - item := itemMap[payload.PurchaseItemID] - if item == nil || payload.ReceivedDate == nil { - continue - } - receivedDate := payload.ReceivedDate.UTC().Truncate(24 * time.Hour) - supplierID := payload.SupplierID - if supplierID == 0 { - supplierID = purchase.SupplierId - } - key := fmt.Sprintf("%d:%s:%d", supplierID, utils.FormatDate(receivedDate), payload.WarehouseID) - - var kandangID *uint - var projectFK *uint - if item.Warehouse != nil && item.Warehouse.KandangId != nil { - id := uint(*item.Warehouse.KandangId) - kandangID = &id - if project, err := b.projectFlockKandangRepo.GetActiveByKandangID(ctx, uint(*item.Warehouse.KandangId)); err == nil && project != nil { - pid := uint(project.Id) - projectFK = &pid - } - } - - pricePerItem := item.Price - if payload.TransportPerItem != nil { - pricePerItem = *payload.TransportPerItem - } - totalPrice := pricePerItem * payload.ReceivedQty - - groups[key] = append(groups[key], groupedItem{ - item: item, - payload: payload, - projectFK: projectFK, - kandangID: kandangID, - totalPrice: totalPrice, - }) - } } for key, items := range groups { @@ -536,7 +561,7 @@ func (b *expenseBridge) OnItemsReceived(c *fiber.Ctx, purchaseID uint, updates [ continue } parts := strings.Split(key, ":") - if len(parts) != 3 { + if len(parts) < 3 { return errors.New("invalid expense grouping key") } expenseDate, err := utils.ParseDateString(parts[1]) @@ -566,13 +591,6 @@ 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 - } - } - if len(updatedExpenses) > 0 { if err := b.markExpensesUpdated(ctx, updatedExpenses, purchase.CreatedBy); err != nil { return err @@ -691,25 +709,7 @@ func (b *expenseBridge) linkExpenseNonstocksToItems(ctx context.Context, detail return nil } - noteToExpenseNonstock := make(map[uint]uint64) - for _, kandang := range detail.Kandangs { - for _, pengajuan := range kandang.Pengajuans { - note := strings.TrimSpace(pengajuan.Notes) - if note == "" { - continue - } - const prefix = "purchase_item:" - if !strings.HasPrefix(note, prefix) { - continue - } - idStr := strings.TrimPrefix(note, prefix) - var itemID uint - if _, err := fmt.Sscanf(idStr, "%d", &itemID); err != nil { - continue - } - noteToExpenseNonstock[itemID] = pengajuan.Id - } - } + noteToExpenseNonstock := mapExpenseNotes(detail) if len(noteToExpenseNonstock) == 0 { return nil @@ -730,3 +730,29 @@ func (b *expenseBridge) linkExpenseNonstocksToItems(ctx context.Context, detail return nil } + +func mapExpenseNotes(detail *expenseDto.ExpenseDetailDTO) map[uint]uint64 { + result := make(map[uint]uint64) + if detail == nil { + return result + } + for _, kandang := range detail.Kandangs { + for _, pengajuan := range kandang.Pengajuans { + note := strings.TrimSpace(pengajuan.Notes) + if note == "" { + continue + } + const prefix = "purchase_item:" + if !strings.HasPrefix(note, prefix) { + continue + } + idStr := strings.TrimPrefix(note, prefix) + var itemID uint + if _, err := fmt.Sscanf(idStr, "%d", &itemID); err != nil { + continue + } + result[itemID] = pengajuan.Id + } + } + return result +} diff --git a/internal/modules/purchases/services/purchase.service.go b/internal/modules/purchases/services/purchase.service.go index 6efcf7ec..6874fd8b 100644 --- a/internal/modules/purchases/services/purchase.service.go +++ b/internal/modules/purchases/services/purchase.service.go @@ -16,6 +16,7 @@ import ( rProduct "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/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" + projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/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" @@ -43,16 +44,17 @@ const ( ) type purchaseService struct { - Log *logrus.Logger - Validate *validator.Validate - PurchaseRepo rPurchase.PurchaseRepository - ProductRepo rProduct.ProductRepository - WarehouseRepo rWarehouse.WarehouseRepository - SupplierRepo rSupplier.SupplierRepository - ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository - ApprovalSvc commonSvc.ApprovalService - ExpenseBridge PurchaseExpenseBridge - approvalWorkflow approvalutils.ApprovalWorkflowKey + Log *logrus.Logger + Validate *validator.Validate + PurchaseRepo rPurchase.PurchaseRepository + ProductRepo rProduct.ProductRepository + WarehouseRepo rWarehouse.WarehouseRepository + SupplierRepo rSupplier.SupplierRepository + ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository + ProjectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository + ApprovalSvc commonSvc.ApprovalService + ExpenseBridge PurchaseExpenseBridge + approvalWorkflow approvalutils.ApprovalWorkflowKey } type staffAdjustmentPayload struct { @@ -67,20 +69,22 @@ func NewPurchaseService( warehouseRepo rWarehouse.WarehouseRepository, supplierRepo rSupplier.SupplierRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, + projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, approvalSvc commonSvc.ApprovalService, expenseBridge PurchaseExpenseBridge, ) PurchaseService { return &purchaseService{ - Log: utils.Log, - Validate: validate, - PurchaseRepo: purchaseRepo, - ProductRepo: productRepo, - WarehouseRepo: warehouseRepo, - SupplierRepo: supplierRepo, - ProductWarehouseRepo: productWarehouseRepo, - ApprovalSvc: approvalSvc, - ExpenseBridge: expenseBridge, - approvalWorkflow: utils.ApprovalWorkflowPurchase, + Log: utils.Log, + Validate: validate, + PurchaseRepo: purchaseRepo, + ProductRepo: productRepo, + WarehouseRepo: warehouseRepo, + SupplierRepo: supplierRepo, + ProductWarehouseRepo: productWarehouseRepo, + ProjectFlockKandangRepo: projectFlockKandangRepo, + ApprovalSvc: approvalSvc, + ExpenseBridge: expenseBridge, + approvalWorkflow: utils.ApprovalWorkflowPurchase, } } func (s *purchaseService) withRelations(db *gorm.DB) *gorm.DB { @@ -221,6 +225,7 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase productId uint warehouseId uint subQty float64 + pfkID *uint } if len(req.Items) == 0 { @@ -229,9 +234,9 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase warehouseCache := make(map[uint]*entity.Warehouse) productSupplierCache := make(map[uint]bool) - getWarehouse := func(id uint) (*entity.Warehouse, error) { + getWarehouse := func(id uint) (*entity.Warehouse, *uint, error) { if warehouse, ok := warehouseCache[id]; ok { - return warehouse, nil + return warehouse, nil, nil } warehouse, err := s.WarehouseRepo.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { return db.Preload("Area").Preload("Location") @@ -239,21 +244,37 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Warehouse %d not found", id)) + return nil, nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Warehouse %d not found", id)) } s.Log.Errorf("Failed to get warehouse %d: %+v", id, err) - return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get warehouse") + return nil, nil, fiber.NewError(fiber.StatusInternalServerError, "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)) + } + var pfkID *uint + if s.ProjectFlockKandangRepo != nil { + if pfk, err := s.ProjectFlockKandangRepo.GetActiveByKandangID(c.Context(), uint(*warehouse.KandangId)); err == nil && pfk != nil { + 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)) + } 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") + } } warehouseCache[id] = warehouse - return warehouse, nil + return warehouse, pfkID, nil } aggregated := make([]*aggregatedItem, 0, len(req.Items)) indexMap := make(map[string]int) for _, item := range req.Items { - if _, err := getWarehouse(item.WarehouseID); err != nil { + _, pfkID, err := getWarehouse(item.WarehouseID) + if err != nil { return nil, err } @@ -282,6 +303,7 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase productId: productId, warehouseId: warehouseId, subQty: item.Quantity, + pfkID: pfkID, } aggregated = append(aggregated, entry) indexMap[key] = len(aggregated) - 1 @@ -308,14 +330,15 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase 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, - VehicleNumber: &emptyVehicle, + ProductId: item.productId, + WarehouseId: item.warehouseId, + ProjectFlockKandangId: item.pfkID, + SubQty: item.subQty, + TotalQty: 0, + TotalUsed: 0, + Price: 0, + TotalPrice: 0, + VehicleNumber: &emptyVehicle, }) } @@ -332,6 +355,10 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase return err } + if err := purchaseRepoTx.BackfillProjectFlockKandang(c.Context(), purchase.Id); err != nil { + return err + } + actorID := uint(purchase.CreatedBy) if err := s.createPurchaseApproval(c.Context(), tx, purchase.Id, utils.PurchaseStepPengajuan, entity.ApprovalActionCreated, actorID, nil, false); err != nil { return err