feat(BE): fix delete project flock budget and uniformity, and fix uniformity with update purchase document

This commit is contained in:
ragilap
2026-01-02 20:43:57 +07:00
parent 2f8f84cb0d
commit 8de33a0f24
13 changed files with 320 additions and 128 deletions
@@ -6,6 +6,7 @@ import (
"fmt"
"mime"
"mime/multipart"
"net/url"
"path/filepath"
"strings"
"time"
@@ -305,6 +306,56 @@ func (s *documentService) PresignURL(ctx context.Context, document entity.Docume
return s.storage.PresignURL(ctx, document.Path, expires)
}
// ResolveDocumentURL normalizes a stored path or URL into a presigned URL.
func ResolveDocumentURL(
ctx context.Context,
svc DocumentService,
rawPath string,
expires time.Duration,
) (string, error) {
if svc == nil {
return "", nil
}
rawPath = strings.TrimSpace(rawPath)
if rawPath == "" {
return "", nil
}
key := rawPath
lower := strings.ToLower(rawPath)
if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") {
key = extractS3KeyFromURL(rawPath)
if key == "" {
return "", nil
}
}
return svc.PresignURL(ctx, entity.Document{Path: key}, expires)
}
func extractS3KeyFromURL(raw string) string {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return ""
}
path := strings.TrimPrefix(parsed.Path, "/")
if path == "" {
return ""
}
host := strings.ToLower(strings.TrimSpace(parsed.Host))
if strings.HasPrefix(host, "s3.") || strings.HasPrefix(host, "s3-") {
parts := strings.SplitN(path, "/", 2)
if len(parts) == 2 {
return parts[1]
}
return ""
}
return path
}
func (s *documentService) generateObjectKey(ext string) (string, error) {
normalizedExt := strings.TrimSpace(ext)
if normalizedExt != "" && !strings.HasPrefix(normalizedExt, ".") {
@@ -192,17 +192,6 @@ func (s *fifoService) Consume(ctx context.Context, req StockConsumeRequest) (*St
if req.Quantity < 0 {
return nil, errors.New("quantity must be zero or greater")
}
if s.logger.IsLevelEnabled(logrus.DebugLevel) {
s.logger.WithFields(logrus.Fields{
"usable_key": req.UsableKey.String(),
"usable_id": req.UsableID,
"requested_quantity": req.Quantity,
"allow_pending": req.AllowPending,
"product_warehouse_id": req.ProductWarehouseID,
}).Debug("fifo consume request")
}
cfg, ok := fifo.Usable(req.UsableKey)
if !ok {
return nil, fmt.Errorf("usable %q is not registered", req.UsableKey)
@@ -230,20 +219,6 @@ func (s *fifoService) Consume(ctx context.Context, req StockConsumeRequest) (*St
currentPending := ctxRow.PendingQty
currentTotal := currentUsage + currentPending
delta := req.Quantity - currentTotal
if s.logger.IsLevelEnabled(logrus.DebugLevel) {
s.logger.WithFields(logrus.Fields{
"usable_key": req.UsableKey.String(),
"usable_id": req.UsableID,
"product_warehouse_id": productWarehouseID,
"current_usage_qty": currentUsage,
"current_pending_qty": currentPending,
"current_total_qty": currentTotal,
"requested_quantity": req.Quantity,
"calculated_delta": delta,
"input_warehouse_match": req.ProductWarehouseID == 0 || req.ProductWarehouseID == productWarehouseID,
}).Debug("fifo consume context")
}
var (
usageDelta float64
pendingDelta float64
@@ -308,21 +283,6 @@ func (s *fifoService) Consume(ctx context.Context, req StockConsumeRequest) (*St
result.ReleasedQuantity = releasedAmount
result.UsageQuantity = currentUsage + usageDelta
result.PendingQuantity = currentPending + pendingDelta
if s.logger.IsLevelEnabled(logrus.DebugLevel) {
s.logger.WithFields(logrus.Fields{
"usable_key": req.UsableKey.String(),
"usable_id": req.UsableID,
"product_warehouse_id": productWarehouseID,
"usage_delta": usageDelta,
"pending_delta": pendingDelta,
"released_quantity": releasedAmount,
"added_allocations": len(addedAlloc),
"final_usage_qty": result.UsageQuantity,
"final_pending_qty": result.PendingQuantity,
"final_requested_qty": result.RequestedQuantity,
}).Debug("fifo consume result")
}
return nil
})
if err != nil {
@@ -336,14 +296,6 @@ func (s *fifoService) ReleaseUsage(ctx context.Context, req StockReleaseRequest)
if req.UsableID == 0 || strings.TrimSpace(req.UsableKey.String()) == "" {
return errors.New("usable key and id are required")
}
if s.logger.IsLevelEnabled(logrus.DebugLevel) {
s.logger.WithFields(logrus.Fields{
"usable_key": req.UsableKey.String(),
"usable_id": req.UsableID,
"reason": req.Reason,
}).Debug("fifo release request")
}
return s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error {
cfg, ok := fifo.Usable(req.UsableKey)
if !ok {
@@ -354,17 +306,6 @@ func (s *fifoService) ReleaseUsage(ctx context.Context, req StockReleaseRequest)
if err != nil {
return err
}
if s.logger.IsLevelEnabled(logrus.DebugLevel) {
s.logger.WithFields(logrus.Fields{
"usable_key": req.UsableKey.String(),
"usable_id": req.UsableID,
"product_warehouse_id": ctxRow.ProductWarehouseID,
"current_usage_qty": ctxRow.UsageQty,
"current_pending_qty": ctxRow.PendingQty,
"current_total_qty": ctxRow.UsageQty + ctxRow.PendingQty,
}).Debug("fifo release context")
}
var usageDelta, pendingDelta float64
if ctxRow.UsageQty > 0 {
if _, err := s.releaseUsagePortion(ctx, tx, req.UsableKey, req.UsableID, ctxRow.UsageQty); err != nil {
@@ -380,15 +321,6 @@ func (s *fifoService) ReleaseUsage(ctx context.Context, req StockReleaseRequest)
return err
}
if s.logger.IsLevelEnabled(logrus.DebugLevel) {
s.logger.WithFields(logrus.Fields{
"usable_key": req.UsableKey.String(),
"usable_id": req.UsableID,
"usage_delta": usageDelta,
"pending_delta": pendingDelta,
}).Debug("fifo release applied")
}
return s.allocations.ReleaseByUsable(ctx, req.UsableKey.String(), req.UsableID, req.Reason, func(db *gorm.DB) *gorm.DB {
return s.txOrDB(tx, db)
})
@@ -0,0 +1,86 @@
BEGIN;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'fk_project_flock_kandang_uniformity_project_flock_kandang'
) THEN
ALTER TABLE project_flock_kandang_uniformity
DROP CONSTRAINT fk_project_flock_kandang_uniformity_project_flock_kandang;
END IF;
END $$;
ALTER TABLE project_flock_kandang_uniformity
ADD CONSTRAINT fk_project_flock_kandang_uniformity_project_flock_kandang
FOREIGN KEY (project_flock_kandang_id)
REFERENCES project_flock_kandangs (id)
ON DELETE RESTRICT ON UPDATE CASCADE;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM pg_tables
WHERE tablename = 'project_budgets'
) THEN
IF EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'fk_project_budgets_project_flock_id'
) THEN
ALTER TABLE project_budgets
DROP CONSTRAINT fk_project_budgets_project_flock_id;
END IF;
ALTER TABLE project_budgets
ADD CONSTRAINT fk_project_budgets_project_flock_id
FOREIGN KEY (project_flock_id)
REFERENCES project_flocks(id);
END IF;
END $$;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM pg_tables
WHERE tablename = 'project_flock_kandang_uniformity'
) THEN
IF NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'project_flock_kandang_uniformity'
AND column_name = 'created_at'
) THEN
ALTER TABLE project_flock_kandang_uniformity
ADD COLUMN created_at TIMESTAMPTZ DEFAULT NOW();
END IF;
IF NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'project_flock_kandang_uniformity'
AND column_name = 'updated_at'
) THEN
ALTER TABLE project_flock_kandang_uniformity
ADD COLUMN updated_at TIMESTAMPTZ DEFAULT NOW();
END IF;
IF NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'project_flock_kandang_uniformity'
AND column_name = 'deleted_at'
) THEN
ALTER TABLE project_flock_kandang_uniformity
ADD COLUMN deleted_at TIMESTAMPTZ;
END IF;
END IF;
END $$;
COMMIT;
@@ -0,0 +1,90 @@
BEGIN;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'fk_project_flock_kandang_uniformity_project_flock_kandang'
) THEN
ALTER TABLE project_flock_kandang_uniformity
DROP CONSTRAINT fk_project_flock_kandang_uniformity_project_flock_kandang;
END IF;
END $$;
ALTER TABLE project_flock_kandang_uniformity
ADD CONSTRAINT fk_project_flock_kandang_uniformity_project_flock_kandang
FOREIGN KEY (project_flock_kandang_id)
REFERENCES project_flock_kandangs (id)
ON DELETE CASCADE ON UPDATE CASCADE;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM pg_tables
WHERE tablename = 'project_budgets'
) THEN
IF EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'fk_project_budgets_project_flock_id'
) THEN
ALTER TABLE project_budgets
DROP CONSTRAINT fk_project_budgets_project_flock_id;
END IF;
ALTER TABLE project_budgets
ADD CONSTRAINT fk_project_budgets_project_flock_id
FOREIGN KEY (project_flock_id)
REFERENCES project_flocks(id)
ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM pg_trigger
WHERE tgname = 'trg_soft_delete_fk_project_flock_kandang_uniformity'
) THEN
DROP TRIGGER trg_soft_delete_fk_project_flock_kandang_uniformity
ON project_flock_kandang_uniformity;
END IF;
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'project_flock_kandang_uniformity'
AND column_name = 'created_at'
) THEN
ALTER TABLE project_flock_kandang_uniformity
DROP COLUMN created_at;
END IF;
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'project_flock_kandang_uniformity'
AND column_name = 'updated_at'
) THEN
ALTER TABLE project_flock_kandang_uniformity
DROP COLUMN updated_at;
END IF;
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'project_flock_kandang_uniformity'
AND column_name = 'deleted_at'
) THEN
ALTER TABLE project_flock_kandang_uniformity
DROP COLUMN deleted_at;
END IF;
END $$;
COMMIT;
@@ -1,10 +1,6 @@
package entities
import (
"time"
"gorm.io/gorm"
)
import "time"
type ProjectFlockKandangUniformity struct {
Id uint `gorm:"primaryKey"`
@@ -18,9 +14,6 @@ type ProjectFlockKandangUniformity struct {
UniformQty float64 `gorm:"type:numeric(15,3)"`
NotUniformQty float64 `gorm:"type:numeric(15,3)"`
UniformDate *time.Time `gorm:"type:timestamptz"`
CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
CreatedBy uint `gorm:"not null"`
ProjectFlockKandang ProjectFlockKandang `gorm:"foreignKey:ProjectFlockKandangId;references:Id"`
@@ -22,6 +22,7 @@ import (
pfutils "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/utils"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/validations"
recordingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories"
uniformityRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/uniformities/repositories"
utils "gitlab.com/mbugroup/lti-api.git/internal/utils"
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
@@ -866,6 +867,14 @@ func (s projectflockService) detachKandangs(ctx context.Context, dbTransaction *
}
if len(pfkIDs) > 0 {
uniformityRepo := uniformityRepository.NewUniformityRepository(s.Repository.DB())
if dbTransaction != nil {
uniformityRepo = uniformityRepository.NewUniformityRepository(dbTransaction)
}
if err := uniformityRepo.DeleteByProjectFlockKandangIDs(ctx, pfkIDs); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to remove uniformity data for project flock kandang")
}
pwRepo := s.ProductWarehouseRepo
if dbTransaction != nil {
pwRepo = productWarehouseRepository.NewProductWarehouseRepository(dbTransaction)
@@ -333,13 +333,6 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
s.Log.Errorf("Failed to list existing stocks: %+v", err)
return err
}
if s.Log != nil && s.Log.IsLevelEnabled(logrus.DebugLevel) {
s.Log.WithFields(logrus.Fields{
"recording_id": recordingEntity.Id,
"existing": summarizeExistingStocks(existingStocks),
"incoming": summarizeIncomingStocks(req.Stocks),
}).Debug("recording update stock comparison")
}
if stocksMatch(existingStocks, req.Stocks) {
hasStockChanges = false
}
@@ -698,16 +691,6 @@ func (s *recordingService) consumeRecordingStocks(ctx context.Context, tx *gorm.
}
desiredTotal := desired + pending
if s.Log != nil && s.Log.IsLevelEnabled(logrus.DebugLevel) {
s.Log.WithFields(logrus.Fields{
"recording_stock_id": stock.Id,
"product_warehouse_id": stock.ProductWarehouseId,
"desired_usage_qty": desired,
"desired_pending_qty": pending,
"desired_total_qty": desiredTotal,
}).Debug("recording fifo consume start")
}
result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{
UsableKey: recordingStockUsableKey,
UsableID: stock.Id,
@@ -721,17 +704,6 @@ func (s *recordingService) consumeRecordingStocks(ctx context.Context, tx *gorm.
return err
}
if s.Log != nil && s.Log.IsLevelEnabled(logrus.DebugLevel) {
s.Log.WithFields(logrus.Fields{
"recording_stock_id": stock.Id,
"product_warehouse_id": stock.ProductWarehouseId,
"result_usage_qty": result.UsageQuantity,
"result_pending_qty": result.PendingQuantity,
"released_qty": result.ReleasedQuantity,
"added_allocations": len(result.AddedAllocations),
}).Debug("recording fifo consume result")
}
if err := s.Repository.UpdateStockUsage(tx, stock.Id, result.UsageQuantity, result.PendingQuantity); err != nil {
return err
}
@@ -754,23 +726,6 @@ func (s *recordingService) releaseRecordingStocks(ctx context.Context, tx *gorm.
continue
}
var usage float64
var pending float64
if stock.UsageQty != nil {
usage = *stock.UsageQty
}
if stock.PendingQty != nil {
pending = *stock.PendingQty
}
if s.Log != nil && s.Log.IsLevelEnabled(logrus.DebugLevel) {
s.Log.WithFields(logrus.Fields{
"recording_stock_id": stock.Id,
"product_warehouse_id": stock.ProductWarehouseId,
"current_usage_qty": usage,
"current_pending_qty": pending,
}).Debug("recording fifo release start")
}
if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{
UsableKey: recordingStockUsableKey,
UsableID: stock.Id,
@@ -74,7 +74,6 @@ type UniformityListDTO struct {
MeanDown float64 `json:"mean_down"`
StandardMeanWeight *float64 `json:"standard_mean_weight"`
StandardUniformity *float64 `json:"standard_uniformity"`
CreatedAt time.Time `json:"created_at"`
CreatedBy uint `json:"created_by"`
LatestApproval *approvalDTO.ApprovalRelationDTO `json:"latest_approval"`
}
@@ -154,7 +153,6 @@ func ToUniformityListDTOs(items []entity.ProjectFlockKandangUniformity) []Unifor
UniformQty: item.UniformQty,
MeanUp: item.MeanUp,
MeanDown: item.MeanDown,
CreatedAt: item.CreatedAt,
CreatedBy: item.CreatedBy,
LatestApproval: latestApproval,
}
@@ -1,6 +1,8 @@
package repository
import (
"context"
"gitlab.com/mbugroup/lti-api.git/internal/common/repository"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gorm.io/gorm"
@@ -8,6 +10,7 @@ import (
type UniformityRepository interface {
repository.BaseRepository[entity.ProjectFlockKandangUniformity]
DeleteByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) error
}
type UniformityRepositoryImpl struct {
@@ -19,3 +22,13 @@ func NewUniformityRepository(db *gorm.DB) UniformityRepository {
BaseRepositoryImpl: repository.NewBaseRepository[entity.ProjectFlockKandangUniformity](db),
}
}
func (r *UniformityRepositoryImpl) DeleteByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) error {
if len(projectFlockKandangIDs) == 0 {
return nil
}
return r.DB().WithContext(ctx).
Unscoped().
Where("project_flock_kandang_id IN ?", projectFlockKandangIDs).
Delete(&entity.ProjectFlockKandangUniformity{}).Error
}
@@ -99,7 +99,7 @@ func (s uniformityService) GetAll(c *fiber.Ctx, params *validation.Query) ([]ent
if params.Week != 0 {
db = db.Where("week = ?", params.Week)
}
return db.Order("uniform_date DESC").Order("created_at DESC")
return db.Order("uniform_date DESC").Order("id DESC")
})
if err != nil {
@@ -180,7 +180,10 @@ func (ctrl *PurchaseController) ReceiveProducts(c *fiber.Ctx) error {
req.Items = []validation.ReceivePurchaseItemRequest{singleItem}
}
}
req.TravelDocuments = form.File["travel_documents"]
if len(req.TravelDocuments) == 0 {
req.TravelDocuments = form.File["documents"]
}
result, err := ctrl.service.ReceiveProducts(c, uint(id), req)
if err != nil {
return err
@@ -999,6 +999,22 @@ func (s *purchaseService) uploadTravelDocument(
return "", errors.New("document service not available")
}
documents, err := s.DocumentSvc.ListByTarget(ctx, string(utils.DocumentableTypePurchaseItem), uint64(itemID))
if err != nil {
return "", err
}
if len(documents) > 0 {
var ids []uint
for _, doc := range documents {
if doc.Type == string(utils.DocumentTypePurchaseTravel) {
ids = append(ids, doc.Id)
}
}
if err := s.DocumentSvc.DeleteDocuments(ctx, ids, true); err != nil {
return "", err
}
}
documentFiles := []commonSvc.DocumentFile{{
File: file,
Type: string(utils.DocumentTypePurchaseTravel),
@@ -1015,7 +1031,7 @@ func (s *purchaseService) uploadTravelDocument(
if len(results) == 0 {
return "", errors.New("upload result is empty")
}
return results[0].URL, nil
return results[0].Document.Path, nil
}
func (s *purchaseService) DeleteItems(c *fiber.Ctx, id uint, req *validation.DeletePurchaseItemsRequest) (*entity.Purchase, error) {
@@ -1499,10 +1515,56 @@ func (s *purchaseService) loadPurchase(
if err := s.attachLatestApproval(ctx, purchase); err != nil {
s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", id, err)
}
s.applyTravelDocumentURLs(ctx, purchase)
return purchase, nil
}
func (s *purchaseService) applyTravelDocumentURLs(ctx context.Context, purchase *entity.Purchase) {
if purchase == nil || s.DocumentSvc == nil {
return
}
for i := range purchase.Items {
item := &purchase.Items[i]
documents, err := s.DocumentSvc.ListByTarget(ctx, string(utils.DocumentableTypePurchaseItem), uint64(item.Id))
if err != nil {
s.Log.Warnf("Unable to load travel documents for purchase item %d: %+v", item.Id, err)
} else {
var targetDoc *entity.Document
for j := len(documents) - 1; j >= 0; j-- {
if documents[j].Type == string(utils.DocumentTypePurchaseTravel) {
targetDoc = &documents[j]
break
}
}
if targetDoc != nil {
url, err := s.DocumentSvc.PresignURL(ctx, *targetDoc, 15*time.Minute)
if err != nil {
s.Log.Warnf("Unable to presign travel document for purchase item %d: %+v", item.Id, err)
} else if url != "" {
item.TravelNumberDocs = &url
continue
}
}
}
path := item.TravelNumberDocs
if path == nil || strings.TrimSpace(*path) == "" {
continue
}
url, err := commonSvc.ResolveDocumentURL(ctx, s.DocumentSvc, *path, 15*time.Minute)
if err != nil {
s.Log.Warnf("Unable to presign travel document for purchase item %d: %+v", item.Id, err)
continue
}
if url == "" {
continue
}
item.TravelNumberDocs = &url
}
}
func collectPFKIDsFromPurchase(p *entity.Purchase) []uint {
seen := make(map[uint]struct{})
ids := make([]uint, 0)