feat/BE/US-278/TASK-288,289-adjust schema database,Create trigger in expense module, add filter in warehouse linked to project flock

This commit is contained in:
ragilap
2025-12-08 01:23:21 +07:00
parent 4638fba318
commit 0a18753dde
10 changed files with 322 additions and 195 deletions
+1
View File
@@ -68,6 +68,7 @@ func (PurchaseModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
warehouseRepo,
supplierRepo,
productWarehouseRepo,
projectFlockKandangRepository,
approvalService,
expenseBridge,
)
@@ -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
@@ -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
}
@@ -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