mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-05-20 13:31:56 +00:00
fixing filter pw for transfer, add transfer delete
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
package entities
|
||||
|
||||
type ProductWarehouse struct {
|
||||
Id uint `gorm:"primaryKey;column:id"`
|
||||
ProductId uint `gorm:"column:product_id;not null"`
|
||||
WarehouseId uint `gorm:"column:warehouse_id;not null"`
|
||||
ProjectFlockKandangId *uint `gorm:"column:project_flock_kandang_id"`
|
||||
Quantity float64 `gorm:"column:qty;type:numeric(15,3);default:0"`
|
||||
Id uint `gorm:"primaryKey;column:id"`
|
||||
ProductId uint `gorm:"column:product_id;not null"`
|
||||
WarehouseId uint `gorm:"column:warehouse_id;not null"`
|
||||
ProjectFlockKandangId *uint `gorm:"column:project_flock_kandang_id"`
|
||||
Quantity float64 `gorm:"column:qty;type:numeric(15,3);default:0"`
|
||||
AvailableQty *float64 `gorm:"-"`
|
||||
|
||||
// Relations
|
||||
Product Product `gorm:"foreignKey:ProductId;references:Id"`
|
||||
|
||||
@@ -70,6 +70,7 @@ const (
|
||||
P_TransferGetAll = "lti.inventory.transfer.list"
|
||||
P_TransferGetOne = "lti.inventory.transfer.detail"
|
||||
P_TransferCreateOne = "lti.inventory.transfer.create"
|
||||
P_TransferDeleteOne = "lti.inventory.transfer.delete"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
+1
@@ -32,6 +32,7 @@ func (u *ProductWarehouseController) GetAll(c *fiber.Ctx) error {
|
||||
Flags: c.Query("flags", ""),
|
||||
KandangId: uint(c.QueryInt("kandang_id", 0)),
|
||||
TransferContext: c.Query(utils.TransferContextKey, ""),
|
||||
StockMode: c.Query("stock_mode", ""),
|
||||
Type: c.Query("type", ""),
|
||||
}
|
||||
|
||||
|
||||
@@ -12,10 +12,11 @@ import (
|
||||
// === DTO Structs ===
|
||||
|
||||
type ProductWarehouseRelationDTO struct {
|
||||
Id uint `json:"id"`
|
||||
ProductId uint `json:"product_id"`
|
||||
WarehouseId uint `json:"warehouse_id"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
Id uint `json:"id"`
|
||||
ProductId uint `json:"product_id"`
|
||||
WarehouseId uint `json:"warehouse_id"`
|
||||
Quantity float64 `json:"quantity"`
|
||||
AvailableQty *float64 `json:"available_qty,omitempty"`
|
||||
}
|
||||
|
||||
type ProductWarehouseListDTO struct {
|
||||
@@ -61,10 +62,11 @@ type ProjectFlockRelationDTO struct {
|
||||
|
||||
func ToProductWarehouseRelationDTO(e entity.ProductWarehouse) ProductWarehouseRelationDTO {
|
||||
return ProductWarehouseRelationDTO{
|
||||
Id: e.Id,
|
||||
ProductId: e.ProductId, // Field yang benar dari entity
|
||||
WarehouseId: e.WarehouseId, // Field yang benar dari entity
|
||||
Quantity: e.Quantity,
|
||||
Id: e.Id,
|
||||
ProductId: e.ProductId, // Field yang benar dari entity
|
||||
WarehouseId: e.WarehouseId, // Field yang benar dari entity
|
||||
Quantity: e.Quantity,
|
||||
AvailableQty: e.AvailableQty,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
@@ -12,6 +13,7 @@ import (
|
||||
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/validations"
|
||||
kandangrepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -27,6 +29,8 @@ type productWarehouseService struct {
|
||||
KandangRepo kandangrepo.KandangRepository
|
||||
}
|
||||
|
||||
const stockModeExcludeChickin = "exclude_chickin"
|
||||
|
||||
func NewProductWarehouseService(repo repository.ProductWarehouseRepository, validate *validator.Validate, kandangRepo kandangrepo.KandangRepository) ProductWarehouseService {
|
||||
return &productWarehouseService{
|
||||
Log: utils.Log,
|
||||
@@ -189,6 +193,11 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query)
|
||||
s.Log.Errorf("Failed to get productWarehouses: %+v", err)
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
productWarehouses, err = s.applyTransferAvailableQty(c, params, productWarehouses)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return productWarehouses, total, nil
|
||||
}
|
||||
|
||||
@@ -229,3 +238,80 @@ func (s productWarehouseService) GetOne(c *fiber.Ctx, id uint) (*entity.ProductW
|
||||
}
|
||||
return productWarehouse, nil
|
||||
}
|
||||
|
||||
func (s productWarehouseService) applyTransferAvailableQty(c *fiber.Ctx, params *validation.Query, rows []entity.ProductWarehouse) ([]entity.ProductWarehouse, error) {
|
||||
if len(rows) == 0 {
|
||||
return rows, nil
|
||||
}
|
||||
if params == nil ||
|
||||
params.TransferContext != utils.TransferContextInventoryTransfer ||
|
||||
params.StockMode != stockModeExcludeChickin {
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
ayamPWIDs := make([]uint, 0)
|
||||
for i := range rows {
|
||||
if isAyamProductByFlags(rows[i].Product.Flags) {
|
||||
ayamPWIDs = append(ayamPWIDs, rows[i].Id)
|
||||
}
|
||||
}
|
||||
if len(ayamPWIDs) == 0 {
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
type usageRow struct {
|
||||
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
|
||||
UsedQty float64 `gorm:"column:used_qty"`
|
||||
}
|
||||
usageRows := make([]usageRow, 0)
|
||||
if err := s.Repository.DB().WithContext(c.Context()).
|
||||
Table("stock_allocations").
|
||||
Select("product_warehouse_id, COALESCE(SUM(qty), 0) AS used_qty").
|
||||
Where("product_warehouse_id IN ?", ayamPWIDs).
|
||||
Where("usable_type = ?", fifo.UsableKeyProjectChickin.String()).
|
||||
Where("status = ?", entity.StockAllocationStatusActive).
|
||||
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
|
||||
Where("deleted_at IS NULL").
|
||||
Group("product_warehouse_id").
|
||||
Scan(&usageRows).Error; err != nil {
|
||||
s.Log.Errorf("Failed to calculate available transfer stock after chickin consumption: %+v", err)
|
||||
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal menghitung stok tersedia untuk transfer")
|
||||
}
|
||||
|
||||
usageMap := make(map[uint]float64, len(usageRows))
|
||||
for _, row := range usageRows {
|
||||
usageMap[row.ProductWarehouseID] = row.UsedQty
|
||||
}
|
||||
|
||||
filtered := make([]entity.ProductWarehouse, 0, len(rows))
|
||||
for i := range rows {
|
||||
row := rows[i]
|
||||
if !isAyamProductByFlags(row.Product.Flags) {
|
||||
filtered = append(filtered, row)
|
||||
continue
|
||||
}
|
||||
|
||||
available := row.Quantity - usageMap[row.Id]
|
||||
if available < 0 {
|
||||
available = 0
|
||||
}
|
||||
row.AvailableQty = &available
|
||||
|
||||
if available <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
filtered = append(filtered, row)
|
||||
}
|
||||
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
func isAyamProductByFlags(flags []entity.Flag) bool {
|
||||
for _, flag := range flags {
|
||||
if utils.CanonicalFlagType(strings.TrimSpace(flag.Name)) == utils.FlagAyam {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
+1
@@ -20,5 +20,6 @@ type Query struct {
|
||||
Flags string `query:"flags" validate:"omitempty"`
|
||||
KandangId uint `query:"kandang_id" validate:"omitempty,number,min=1"`
|
||||
TransferContext string `query:"transfer_context" validate:"omitempty,oneof=inventory_transfer"`
|
||||
StockMode string `query:"stock_mode" validate:"omitempty,oneof=exclude_chickin"`
|
||||
Type string `query:"type" validate:"omitempty"`
|
||||
}
|
||||
|
||||
@@ -109,3 +109,23 @@ func (u *TransferController) CreateOne(c *fiber.Ctx) error {
|
||||
Data: dto.ToTransferDetailDTO(*result),
|
||||
})
|
||||
}
|
||||
|
||||
func (u *TransferController) DeleteOne(c *fiber.Ctx) error {
|
||||
param := c.Params("id")
|
||||
|
||||
id, err := strconv.Atoi(param)
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
|
||||
}
|
||||
|
||||
if err := u.TransferService.DeleteOne(c, uint(id)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusOK).
|
||||
JSON(response.Common{
|
||||
Code: fiber.StatusOK,
|
||||
Status: "success",
|
||||
Message: "Delete transfer successfully",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -18,5 +18,6 @@ func TransferRoutes(v1 fiber.Router, u user.UserService, s transfer.TransferServ
|
||||
route.Get("/", m.RequirePermissions(m.P_TransferGetAll), ctrl.GetAll)
|
||||
route.Post("/", m.RequirePermissions(m.P_TransferCreateOne), ctrl.CreateOne)
|
||||
route.Get("/:id", m.RequirePermissions(m.P_TransferGetOne), ctrl.GetOne)
|
||||
route.Delete("/:id", m.RequirePermissions(m.P_TransferDeleteOne), ctrl.DeleteOne)
|
||||
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
@@ -21,14 +23,17 @@ import (
|
||||
projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
|
||||
rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type TransferService interface {
|
||||
GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.StockTransfer, int64, error)
|
||||
GetOne(ctx *fiber.Ctx, id uint) (*entity.StockTransfer, error)
|
||||
CreateOne(ctx *fiber.Ctx, req *validation.TransferRequest, files []*multipart.FileHeader) (*entity.StockTransfer, error)
|
||||
DeleteOne(ctx *fiber.Ctx, id uint) error
|
||||
}
|
||||
|
||||
type transferService struct {
|
||||
@@ -49,6 +54,8 @@ type transferService struct {
|
||||
ExpenseBridge TransferExpenseBridge
|
||||
}
|
||||
|
||||
const transferDeleteDownstreamGuardMessage = "Transfer stock tidak dapat dihapus karena stok transfer sudah dipakai transaksi turunan. Hapus dependensi terkait secara manual terlebih dahulu."
|
||||
|
||||
func NewTransferService(validate *validator.Validate, stockTransferRepo rStockTransfer.StockTransferRepository, stockTransferDetailRepo rStockTransfer.StockTransferDetailRepository, stockTransferDeliveryRepo rStockTransfer.StockTransferDeliveryRepository, stockTransferDeliveryItemRepo rStockTransfer.StockTransferDeliveryItemRepository, stockLogsRepo rStockLogs.StockLogRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, supplierRepo rSupplier.SupplierRepository, warehouseRepo warehouseRepo.WarehouseRepository, projectFlockKandangRepo projectFlockKandangRepo.ProjectFlockKandangRepository, projectFlockPopulationRepo projectFlockKandangRepo.ProjectFlockPopulationRepository, documentSvc commonSvc.DocumentService, fifoStockV2Svc commonSvc.FifoStockV2Service, expenseBridge TransferExpenseBridge) TransferService {
|
||||
return &transferService{
|
||||
Log: utils.Log,
|
||||
@@ -104,6 +111,7 @@ func (s transferService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit
|
||||
|
||||
transfers, total, err := s.StockTransferRepo.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
|
||||
db = s.withRelations(db)
|
||||
db = db.Where("stock_transfers.deleted_at IS NULL")
|
||||
if scope.Restrict {
|
||||
if len(scope.IDs) == 0 {
|
||||
return db.Where("1 = 0")
|
||||
@@ -145,6 +153,7 @@ func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, e
|
||||
Joins("JOIN warehouses w_from ON w_from.id = stock_transfers.from_warehouse_id").
|
||||
Joins("JOIN warehouses w_to ON w_to.id = stock_transfers.to_warehouse_id").
|
||||
Where("stock_transfers.id = ?", id).
|
||||
Where("stock_transfers.deleted_at IS NULL").
|
||||
Where("w_from.location_id IN ? OR w_to.location_id IN ?", scope.IDs, scope.IDs).
|
||||
Count(&count).Error; err != nil {
|
||||
return nil, err
|
||||
@@ -155,7 +164,7 @@ func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, e
|
||||
}
|
||||
|
||||
transferPtr, err := s.StockTransferRepo.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
|
||||
return s.withRelations(db)
|
||||
return s.withRelations(db).Where("stock_transfers.deleted_at IS NULL")
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
@@ -619,6 +628,210 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *transferService) DeleteOne(c *fiber.Ctx, id uint) error {
|
||||
if err := s.ensureTransferAccess(c.Context(), id, c); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.FifoStockV2Svc == nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "FIFO v2 service is not available")
|
||||
}
|
||||
|
||||
actorID, err := m.ActorIDFromContext(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var deletedDetails []entity.StockTransferDetail
|
||||
err = s.StockTransferRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
|
||||
stockLogRepoTx := rStockLogs.NewStockLogRepository(tx)
|
||||
|
||||
var transfer entity.StockTransfer
|
||||
if err := tx.WithContext(c.Context()).
|
||||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("id = ?", uint64(id)).
|
||||
Where("deleted_at IS NULL").
|
||||
Take(&transfer).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Transfer dengan ID %d tidak ditemukan", id))
|
||||
}
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data transfer")
|
||||
}
|
||||
|
||||
var details []entity.StockTransferDetail
|
||||
if err := tx.WithContext(c.Context()).
|
||||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("stock_transfer_id = ?", transfer.Id).
|
||||
Where("deleted_at IS NULL").
|
||||
Order("id ASC").
|
||||
Find(&details).Error; err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil detail transfer")
|
||||
}
|
||||
if len(details) == 0 {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "Transfer tidak memiliki detail produk")
|
||||
}
|
||||
|
||||
detailIDs := make([]uint64, 0, len(details))
|
||||
for _, detail := range details {
|
||||
detailIDs = append(detailIDs, detail.Id)
|
||||
}
|
||||
if err := s.ensureNoDownstreamConsumptionForDelete(c.Context(), tx, detailIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
type reflowKey struct {
|
||||
flagGroupCode string
|
||||
productWarehouseID uint
|
||||
}
|
||||
destReflows := make(map[reflowKey]struct{})
|
||||
|
||||
for _, detail := range details {
|
||||
if detail.SourceProductWarehouseID == nil || *detail.SourceProductWarehouseID == 0 {
|
||||
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Detail transfer %d tidak memiliki source product warehouse valid", detail.Id))
|
||||
}
|
||||
if detail.DestProductWarehouseID == nil || *detail.DestProductWarehouseID == 0 {
|
||||
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Detail transfer %d tidak memiliki destination product warehouse valid", detail.Id))
|
||||
}
|
||||
|
||||
flagGroupCode, err := s.resolveTransferFlagGroup(c.Context(), tx, uint(detail.ProductId))
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("FIFO v2 route tidak ditemukan untuk produk %d: %v", detail.ProductId, err))
|
||||
}
|
||||
|
||||
rollbackRes, err := s.FifoStockV2Svc.Rollback(c.Context(), commonSvc.FifoStockV2RollbackRequest{
|
||||
ProductWarehouseID: uint(*detail.SourceProductWarehouseID),
|
||||
Usable: commonSvc.FifoStockV2Ref{
|
||||
ID: uint(detail.Id),
|
||||
LegacyTypeKey: fifo.UsableKeyStockTransferOut.String(),
|
||||
FunctionCode: "STOCK_TRANSFER_OUT",
|
||||
},
|
||||
Reason: fmt.Sprintf("transfer delete #%s", transfer.MovementNumber),
|
||||
Tx: tx,
|
||||
})
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal rollback FIFO v2 transfer detail %d: %v", detail.Id, err))
|
||||
}
|
||||
|
||||
releasedQty := 0.0
|
||||
if rollbackRes != nil {
|
||||
releasedQty = rollbackRes.ReleasedQty
|
||||
}
|
||||
if detail.UsageQty > 1e-6 && releasedQty < detail.UsageQty-1e-6 {
|
||||
return fiber.NewError(
|
||||
fiber.StatusBadRequest,
|
||||
fmt.Sprintf("Rollback FIFO v2 source transfer detail %d tidak lengkap. Dibutuhkan %.3f, terlepas %.3f", detail.Id, detail.UsageQty, releasedQty),
|
||||
)
|
||||
}
|
||||
|
||||
if releasedQty > 1e-6 {
|
||||
if err := s.appendStockLog(
|
||||
c.Context(),
|
||||
stockLogRepoTx,
|
||||
uint(*detail.SourceProductWarehouseID),
|
||||
actorID,
|
||||
releasedQty,
|
||||
0,
|
||||
uint(detail.Id),
|
||||
fmt.Sprintf("TRANSFER DELETE #%s", transfer.MovementNumber),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
destDecreaseQty := detail.TotalQty
|
||||
if destDecreaseQty <= 1e-6 {
|
||||
destDecreaseQty = detail.UsageQty
|
||||
}
|
||||
if destDecreaseQty > 1e-6 {
|
||||
if err := s.appendStockLog(
|
||||
c.Context(),
|
||||
stockLogRepoTx,
|
||||
uint(*detail.DestProductWarehouseID),
|
||||
actorID,
|
||||
0,
|
||||
destDecreaseQty,
|
||||
uint(detail.Id),
|
||||
fmt.Sprintf("TRANSFER DELETE #%s", transfer.MovementNumber),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
destReflows[reflowKey{
|
||||
flagGroupCode: flagGroupCode,
|
||||
productWarehouseID: uint(*detail.DestProductWarehouseID),
|
||||
}] = struct{}{}
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
if err := tx.WithContext(c.Context()).
|
||||
Where("stock_transfer_detail_id IN ?", detailIDs).
|
||||
Delete(&entity.StockTransferDeliveryItem{}).Error; err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus item delivery transfer")
|
||||
}
|
||||
if err := tx.WithContext(c.Context()).
|
||||
Model(&entity.StockTransferDelivery{}).
|
||||
Where("stock_transfer_id = ?", transfer.Id).
|
||||
Where("deleted_at IS NULL").
|
||||
Updates(map[string]any{
|
||||
"deleted_at": now,
|
||||
"updated_at": now,
|
||||
}).Error; err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus delivery transfer")
|
||||
}
|
||||
if err := tx.WithContext(c.Context()).
|
||||
Model(&entity.StockTransferDetail{}).
|
||||
Where("id IN ?", detailIDs).
|
||||
Where("deleted_at IS NULL").
|
||||
Updates(map[string]any{
|
||||
"deleted_at": now,
|
||||
"updated_at": now,
|
||||
}).Error; err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus detail transfer")
|
||||
}
|
||||
|
||||
asOf := transfer.TransferDate
|
||||
for key := range destReflows {
|
||||
if _, err := s.FifoStockV2Svc.Reflow(c.Context(), commonSvc.FifoStockV2ReflowRequest{
|
||||
FlagGroupCode: key.flagGroupCode,
|
||||
ProductWarehouseID: key.productWarehouseID,
|
||||
AsOf: &asOf,
|
||||
Tx: tx,
|
||||
}); err != nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal reflow stok tujuan saat delete transfer: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.WithContext(c.Context()).
|
||||
Model(&entity.StockTransfer{}).
|
||||
Where("id = ?", transfer.Id).
|
||||
Where("deleted_at IS NULL").
|
||||
Updates(map[string]any{
|
||||
"deleted_at": now,
|
||||
"updated_at": now,
|
||||
}).Error; err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus transfer")
|
||||
}
|
||||
|
||||
deletedDetails = append(deletedDetails, details...)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
if fiberErr, ok := err.(*fiber.Error); ok {
|
||||
return fiberErr
|
||||
}
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Gagal menghapus transfer")
|
||||
}
|
||||
|
||||
if len(deletedDetails) > 0 && s.ExpenseBridge != nil {
|
||||
if err := s.ExpenseBridge.OnItemsDeleted(c.Context(), uint64(id), deletedDetails); err != nil {
|
||||
s.Log.Errorf("Failed to cleanup transfer expense link for transfer_id=%d: %+v", id, err)
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Transfer berhasil dihapus, namun sinkronisasi expense gagal. Silakan cek modul expense")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *transferService) resolveTransferFlagGroup(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
@@ -692,3 +905,179 @@ func (s *transferService) getActiveProjectFlockKandangID(ctx context.Context, wa
|
||||
|
||||
return uint(projectFlockKandang.Id), nil
|
||||
}
|
||||
|
||||
func (s *transferService) ensureTransferAccess(ctx context.Context, id uint, c *fiber.Ctx) error {
|
||||
scope, err := m.ResolveLocationScope(c, s.StockTransferRepo.DB())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !scope.Restrict {
|
||||
return nil
|
||||
}
|
||||
if len(scope.IDs) == 0 {
|
||||
return fiber.NewError(fiber.StatusNotFound, "Transfer not found")
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := s.StockTransferRepo.DB().WithContext(ctx).
|
||||
Table("stock_transfers").
|
||||
Joins("JOIN warehouses w_from ON w_from.id = stock_transfers.from_warehouse_id").
|
||||
Joins("JOIN warehouses w_to ON w_to.id = stock_transfers.to_warehouse_id").
|
||||
Where("stock_transfers.id = ?", id).
|
||||
Where("stock_transfers.deleted_at IS NULL").
|
||||
Where("w_from.location_id IN ? OR w_to.location_id IN ?", scope.IDs, scope.IDs).
|
||||
Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return fiber.NewError(fiber.StatusNotFound, "Transfer not found")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *transferService) ensureNoDownstreamConsumptionForDelete(ctx context.Context, tx *gorm.DB, detailIDs []uint64) error {
|
||||
if len(detailIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
db := s.StockTransferRepo.DB().WithContext(ctx)
|
||||
if tx != nil {
|
||||
db = tx.WithContext(ctx)
|
||||
}
|
||||
|
||||
type downstreamRow struct {
|
||||
UsableType string `gorm:"column:usable_type"`
|
||||
UsableID uint64 `gorm:"column:usable_id"`
|
||||
}
|
||||
|
||||
var rows []downstreamRow
|
||||
if err := db.Table("stock_allocations").
|
||||
Select("usable_type, usable_id").
|
||||
Where("stockable_type = ?", fifo.StockableKeyStockTransferIn.String()).
|
||||
Where("stockable_id IN ?", detailIDs).
|
||||
Where("status = ?", entity.StockAllocationStatusActive).
|
||||
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
|
||||
Where("deleted_at IS NULL").
|
||||
Group("usable_type, usable_id").
|
||||
Scan(&rows).Error; err != nil {
|
||||
s.Log.Errorf("Failed to validate downstream stock transfer consumption: %+v", err)
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi transaksi turunan transfer stock")
|
||||
}
|
||||
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
dependencyMap := make(map[string]map[uint64]struct{})
|
||||
for _, row := range rows {
|
||||
label := mapTransferDownstreamUsableLabel(row.UsableType)
|
||||
if _, ok := dependencyMap[label]; !ok {
|
||||
dependencyMap[label] = make(map[uint64]struct{})
|
||||
}
|
||||
dependencyMap[label][row.UsableID] = struct{}{}
|
||||
}
|
||||
|
||||
labels := make([]string, 0, len(dependencyMap))
|
||||
for label := range dependencyMap {
|
||||
labels = append(labels, label)
|
||||
}
|
||||
sort.Strings(labels)
|
||||
|
||||
details := make([]string, 0, len(labels))
|
||||
for _, label := range labels {
|
||||
ids := sortedUint64Keys(dependencyMap[label])
|
||||
details = append(details, fmt.Sprintf("%s=%s", label, joinUint64(ids)))
|
||||
}
|
||||
|
||||
return fiber.NewError(
|
||||
fiber.StatusBadRequest,
|
||||
fmt.Sprintf("%s Dependensi aktif: %s.", transferDeleteDownstreamGuardMessage, strings.Join(details, ", ")),
|
||||
)
|
||||
}
|
||||
|
||||
func mapTransferDownstreamUsableLabel(usableType string) string {
|
||||
switch strings.ToUpper(strings.TrimSpace(usableType)) {
|
||||
case fifo.UsableKeyRecordingStock.String(), fifo.UsableKeyRecordingDepletion.String():
|
||||
return "Recording"
|
||||
case fifo.UsableKeyProjectChickin.String():
|
||||
return "Chickin"
|
||||
case fifo.UsableKeyMarketingDelivery.String():
|
||||
return "Marketing"
|
||||
case fifo.UsableKeyTransferToLayingOut.String():
|
||||
return "TransferToLaying"
|
||||
case fifo.UsableKeyStockTransferOut.String():
|
||||
return "TransferStock"
|
||||
case fifo.UsableKeyAdjustmentOut.String():
|
||||
return "Adjustment"
|
||||
default:
|
||||
return strings.ToUpper(strings.TrimSpace(usableType))
|
||||
}
|
||||
}
|
||||
|
||||
func sortedUint64Keys(input map[uint64]struct{}) []uint64 {
|
||||
if len(input) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]uint64, 0, len(input))
|
||||
for id := range input {
|
||||
if id == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, id)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
|
||||
return out
|
||||
}
|
||||
|
||||
func joinUint64(values []uint64) string {
|
||||
if len(values) == 0 {
|
||||
return "-"
|
||||
}
|
||||
parts := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
parts = append(parts, fmt.Sprintf("%d", value))
|
||||
}
|
||||
return strings.Join(parts, "|")
|
||||
}
|
||||
|
||||
func (s *transferService) appendStockLog(
|
||||
ctx context.Context,
|
||||
stockLogRepo rStockLogs.StockLogRepository,
|
||||
productWarehouseID uint,
|
||||
actorID uint,
|
||||
increase float64,
|
||||
decrease float64,
|
||||
loggableID uint,
|
||||
notes string,
|
||||
) error {
|
||||
if productWarehouseID == 0 || (increase <= 1e-6 && decrease <= 1e-6) {
|
||||
return nil
|
||||
}
|
||||
|
||||
stockLog := &entity.StockLog{
|
||||
ProductWarehouseId: productWarehouseID,
|
||||
CreatedBy: actorID,
|
||||
Increase: increase,
|
||||
Decrease: decrease,
|
||||
LoggableType: string(utils.StockLogTypeTransfer),
|
||||
LoggableId: loggableID,
|
||||
Notes: notes,
|
||||
}
|
||||
|
||||
stockLogs, err := stockLogRepo.GetByProductWarehouse(ctx, productWarehouseID, 1)
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
|
||||
}
|
||||
if len(stockLogs) > 0 {
|
||||
latestStockLog := stockLogs[0]
|
||||
stockLog.Stock = latestStockLog.Stock + increase - decrease
|
||||
} else {
|
||||
stockLog.Stock = increase - decrease
|
||||
}
|
||||
if err := stockLogRepo.CreateOne(ctx, stockLog, nil); err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat stock log saat delete transfer")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ import (
|
||||
|
||||
const (
|
||||
chickinDeletePopulationGuardMessage = "Chickin tidak dapat dihapus karena masih memiliki population aktif"
|
||||
chickinDeleteDownstreamGuardMessage = "Chickin tidak bisa dihapus karena masih dipakai oleh transaksi turunan. Hapus/unexecute Marketing, Recording, dan Transfer to Laying terlebih dahulu."
|
||||
chickinDeleteDownstreamGuardMessage = "Chickin tidak bisa dihapus karena masih dipakai oleh transaksi turunan. Hapus/unexecute Marketing, Recording, Transfer, Adjustment, dan Transfer to Laying terlebih dahulu."
|
||||
)
|
||||
|
||||
type ChickinService interface {
|
||||
@@ -264,16 +264,16 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti
|
||||
availableQty = 0
|
||||
}
|
||||
if flockCategory == strings.ToUpper(string(utils.ProjectFlockCategoryLaying)) {
|
||||
transferAvailable, err := s.resolveLayingTransferAvailableQty(c.Context(), nil, req.ProjectFlockKandangId, chickinReq.ProductWarehouseId)
|
||||
sourceAvailable, err := s.resolveLayingSourceAvailableQty(c.Context(), nil, chickinReq.ProductWarehouseId, &chickinDate)
|
||||
if err != nil {
|
||||
s.Log.Errorf("Failed to resolve laying transfer availability for pfk=%d pw=%d: %+v", req.ProjectFlockKandangId, chickinReq.ProductWarehouseId, err)
|
||||
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi stok transfer laying")
|
||||
}
|
||||
if transferAvailable < 0 {
|
||||
transferAvailable = 0
|
||||
if sourceAvailable < 0 {
|
||||
sourceAvailable = 0
|
||||
}
|
||||
if transferAvailable < availableQty {
|
||||
availableQty = transferAvailable
|
||||
if sourceAvailable < availableQty {
|
||||
availableQty = sourceAvailable
|
||||
}
|
||||
}
|
||||
|
||||
@@ -554,36 +554,44 @@ func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *chickinService) resolveLayingTransferAvailableQty(ctx context.Context, tx *gorm.DB, targetProjectFlockKandangID, productWarehouseID uint) (float64, error) {
|
||||
if targetProjectFlockKandangID == 0 || productWarehouseID == 0 {
|
||||
func (s *chickinService) resolveLayingSourceAvailableQty(ctx context.Context, tx *gorm.DB, productWarehouseID uint, asOf *time.Time) (float64, error) {
|
||||
if productWarehouseID == 0 || s.FifoStockV2Svc == nil {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
db := s.Repository.DB().WithContext(ctx)
|
||||
db := s.Repository.DB()
|
||||
if tx != nil {
|
||||
db = tx.WithContext(ctx)
|
||||
db = tx
|
||||
}
|
||||
|
||||
var available float64
|
||||
err := db.Table("laying_transfer_targets ltt").
|
||||
Select("COALESCE(SUM(GREATEST(0, COALESCE(ltt.total_qty,0) - COALESCE(ltt.total_used,0))), 0) AS available").
|
||||
Joins("JOIN laying_transfers lt ON lt.id = ltt.laying_transfer_id AND lt.deleted_at IS NULL").
|
||||
Where("ltt.deleted_at IS NULL").
|
||||
Where("ltt.target_project_flock_kandang_id = ?", targetProjectFlockKandangID).
|
||||
Where("ltt.product_warehouse_id = ?", productWarehouseID).
|
||||
Where("lt.executed_at IS NOT NULL").
|
||||
Where(`(
|
||||
SELECT a.action
|
||||
FROM approvals a
|
||||
WHERE a.approvable_type = ?
|
||||
AND a.approvable_id = lt.id
|
||||
ORDER BY a.id DESC
|
||||
LIMIT 1
|
||||
) = ?`, string(utils.ApprovalWorkflowTransferToLaying), entity.ApprovalActionApproved).
|
||||
Scan(&available).Error
|
||||
flagGroupCode, err := resolveChickinFlagGroupByProductWarehouse(ctx, db, productWarehouseID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if strings.TrimSpace(flagGroupCode) == "" {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
gatherRows, err := s.FifoStockV2Svc.Gather(ctx, commonSvc.FifoStockV2GatherRequest{
|
||||
FlagGroupCode: flagGroupCode,
|
||||
Lane: commonSvc.FifoStockV2Lane("STOCKABLE"),
|
||||
AllocationPurpose: entity.StockAllocationPurposeConsume,
|
||||
ProductWarehouseID: productWarehouseID,
|
||||
AsOf: asOf,
|
||||
Limit: 10000,
|
||||
Tx: tx,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
available := 0.0
|
||||
for _, row := range gatherRows {
|
||||
if row.AvailableQuantity <= 0 {
|
||||
continue
|
||||
}
|
||||
available += row.AvailableQuantity
|
||||
}
|
||||
return available, nil
|
||||
}
|
||||
|
||||
@@ -650,6 +658,8 @@ func (s *chickinService) ensureNoDownstreamConsumptionForDelete(ctx context.Cont
|
||||
Where("sa.usable_type IN ?", []string{
|
||||
fifo.UsableKeyMarketingDelivery.String(),
|
||||
fifo.UsableKeyRecordingDepletion.String(),
|
||||
fifo.UsableKeyStockTransferOut.String(),
|
||||
fifo.UsableKeyAdjustmentOut.String(),
|
||||
fifo.UsableKeyTransferToLayingOut.String(),
|
||||
}).
|
||||
Group("sa.usable_type, sa.usable_id").
|
||||
@@ -664,6 +674,8 @@ func (s *chickinService) ensureNoDownstreamConsumptionForDelete(ctx context.Cont
|
||||
|
||||
marketingIDs := make(map[uint]struct{})
|
||||
recordingIDs := make(map[uint]struct{})
|
||||
transferIDs := make(map[uint]struct{})
|
||||
adjustmentIDs := make(map[uint]struct{})
|
||||
transferLayingIDs := make(map[uint]struct{})
|
||||
|
||||
for _, row := range rows {
|
||||
@@ -672,18 +684,28 @@ func (s *chickinService) ensureNoDownstreamConsumptionForDelete(ctx context.Cont
|
||||
marketingIDs[row.UsableID] = struct{}{}
|
||||
case fifo.UsableKeyRecordingDepletion.String():
|
||||
recordingIDs[row.UsableID] = struct{}{}
|
||||
case fifo.UsableKeyStockTransferOut.String():
|
||||
transferIDs[row.UsableID] = struct{}{}
|
||||
case fifo.UsableKeyAdjustmentOut.String():
|
||||
adjustmentIDs[row.UsableID] = struct{}{}
|
||||
case fifo.UsableKeyTransferToLayingOut.String():
|
||||
transferLayingIDs[row.UsableID] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
details := make([]string, 0, 3)
|
||||
details := make([]string, 0, 5)
|
||||
if ids := sortedIDs(marketingIDs); len(ids) > 0 {
|
||||
details = append(details, fmt.Sprintf("Marketing=%s", joinUint(ids)))
|
||||
}
|
||||
if ids := sortedIDs(recordingIDs); len(ids) > 0 {
|
||||
details = append(details, fmt.Sprintf("Recording=%s", joinUint(ids)))
|
||||
}
|
||||
if ids := sortedIDs(transferIDs); len(ids) > 0 {
|
||||
details = append(details, fmt.Sprintf("Transfer=%s", joinUint(ids)))
|
||||
}
|
||||
if ids := sortedIDs(adjustmentIDs); len(ids) > 0 {
|
||||
details = append(details, fmt.Sprintf("Adjustment=%s", joinUint(ids)))
|
||||
}
|
||||
if ids := sortedIDs(transferLayingIDs); len(ids) > 0 {
|
||||
details = append(details, fmt.Sprintf("TransferToLaying=%s", joinUint(ids)))
|
||||
}
|
||||
@@ -1292,17 +1314,8 @@ func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB,
|
||||
}
|
||||
|
||||
if !shouldRestoreWarehouseQty {
|
||||
var affectedTransferTargetIDs []uint
|
||||
if err := tx.WithContext(ctx).
|
||||
Model(&entity.StockAllocation{}).
|
||||
Where("usable_type = ? AND usable_id = ? AND status = ? AND allocation_purpose = ? AND stockable_type = ?",
|
||||
fifo.UsableKeyProjectChickin.String(),
|
||||
chickin.Id,
|
||||
entity.StockAllocationStatusActive,
|
||||
entity.StockAllocationPurposeConsume,
|
||||
fifo.StockableKeyTransferToLayingIn.String(),
|
||||
).
|
||||
Pluck("stockable_id", &affectedTransferTargetIDs).Error; err != nil {
|
||||
affectedStockables, err := s.listActiveConsumeStockableRefsByUsable(ctx, tx, chickin.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1325,12 +1338,15 @@ func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB,
|
||||
return err
|
||||
}
|
||||
s.Log.Infof(
|
||||
"Release chickin stock laying id=%d released_consume_alloc=%d transfer_targets=%d",
|
||||
"Release chickin stock laying id=%d released_consume_alloc=%d transfer_targets=%d stock_transfer_sources=%d purchase_sources=%d adjustment_sources=%d",
|
||||
chickin.Id,
|
||||
releaseResult.RowsAffected,
|
||||
len(affectedTransferTargetIDs),
|
||||
len(affectedStockables[fifo.StockableKeyTransferToLayingIn.String()]),
|
||||
len(affectedStockables[fifo.StockableKeyStockTransferIn.String()]),
|
||||
len(affectedStockables[fifo.StockableKeyPurchaseItems.String()]),
|
||||
len(affectedStockables[fifo.StockableKeyAdjustmentIn.String()]),
|
||||
)
|
||||
if err := s.resyncTransferTargetUsageFromAllocations(ctx, tx, affectedTransferTargetIDs); err != nil {
|
||||
if err := s.resyncStockableSourceUsageAfterRelease(ctx, tx, affectedStockables); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.Repository.UpdateUsageFields(ctx, tx, chickin.Id, 0, 0); err != nil {
|
||||
@@ -1465,63 +1481,179 @@ func (s *chickinService) logWarehouseQtySnapshot(
|
||||
)
|
||||
}
|
||||
|
||||
func (s *chickinService) resyncTransferTargetUsageFromAllocations(ctx context.Context, tx *gorm.DB, transferTargetIDs []uint) error {
|
||||
if tx == nil || len(transferTargetIDs) == 0 {
|
||||
return nil
|
||||
func (s *chickinService) listActiveConsumeStockableRefsByUsable(ctx context.Context, tx *gorm.DB, chickinID uint) (map[string][]uint, error) {
|
||||
result := map[string][]uint{
|
||||
fifo.StockableKeyTransferToLayingIn.String(): nil,
|
||||
fifo.StockableKeyStockTransferIn.String(): nil,
|
||||
fifo.StockableKeyPurchaseItems.String(): nil,
|
||||
fifo.StockableKeyAdjustmentIn.String(): nil,
|
||||
}
|
||||
if tx == nil || chickinID == 0 {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
unique := make([]uint, 0, len(transferTargetIDs))
|
||||
seen := make(map[uint]struct{}, len(transferTargetIDs))
|
||||
for _, id := range transferTargetIDs {
|
||||
if id == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
unique = append(unique, id)
|
||||
type row struct {
|
||||
StockableType string `gorm:"column:stockable_type"`
|
||||
StockableID uint `gorm:"column:stockable_id"`
|
||||
}
|
||||
if len(unique) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := tx.WithContext(ctx).
|
||||
Model(&entity.LayingTransferTarget{}).
|
||||
Where("id IN ?", unique).
|
||||
Update("total_used", 0).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
type usageRow struct {
|
||||
StockableID uint `gorm:"column:stockable_id"`
|
||||
Used float64 `gorm:"column:used"`
|
||||
}
|
||||
var usageRows []usageRow
|
||||
var rows []row
|
||||
if err := tx.WithContext(ctx).
|
||||
Table("stock_allocations").
|
||||
Select("stockable_id, COALESCE(SUM(qty), 0) AS used").
|
||||
Where("stockable_type = ?", fifo.StockableKeyTransferToLayingIn.String()).
|
||||
Where("status = ?", entity.StockAllocationStatusActive).
|
||||
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
|
||||
Where("stockable_id IN ?", unique).
|
||||
Group("stockable_id").
|
||||
Scan(&usageRows).Error; err != nil {
|
||||
Select("stockable_type, stockable_id").
|
||||
Where("usable_type = ? AND usable_id = ? AND status = ? AND allocation_purpose = ?",
|
||||
fifo.UsableKeyProjectChickin.String(),
|
||||
chickinID,
|
||||
entity.StockAllocationStatusActive,
|
||||
entity.StockAllocationPurposeConsume,
|
||||
).
|
||||
Where("stockable_type IN ?", []string{
|
||||
fifo.StockableKeyTransferToLayingIn.String(),
|
||||
fifo.StockableKeyStockTransferIn.String(),
|
||||
fifo.StockableKeyPurchaseItems.String(),
|
||||
fifo.StockableKeyAdjustmentIn.String(),
|
||||
}).
|
||||
Group("stockable_type, stockable_id").
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
if row.StockableID == 0 {
|
||||
continue
|
||||
}
|
||||
result[row.StockableType] = append(result[row.StockableType], row.StockableID)
|
||||
}
|
||||
for key, ids := range result {
|
||||
result[key] = uniqueUint(ids)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *chickinService) resyncStockableSourceUsageAfterRelease(ctx context.Context, tx *gorm.DB, stockableRefs map[string][]uint) error {
|
||||
if tx == nil || len(stockableRefs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := s.resetAndResyncUsedQuantity(
|
||||
ctx,
|
||||
tx,
|
||||
"laying_transfer_targets",
|
||||
"id",
|
||||
"total_used",
|
||||
fifo.StockableKeyTransferToLayingIn.String(),
|
||||
stockableRefs[fifo.StockableKeyTransferToLayingIn.String()],
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, row := range usageRows {
|
||||
if err := tx.WithContext(ctx).
|
||||
Model(&entity.LayingTransferTarget{}).
|
||||
Where("id = ?", row.StockableID).
|
||||
Update("total_used", row.Used).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.resetAndResyncUsedQuantity(
|
||||
ctx,
|
||||
tx,
|
||||
"stock_transfer_details",
|
||||
"id",
|
||||
"total_used",
|
||||
fifo.StockableKeyStockTransferIn.String(),
|
||||
stockableRefs[fifo.StockableKeyStockTransferIn.String()],
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.resetAndResyncUsedQuantity(
|
||||
ctx,
|
||||
tx,
|
||||
"purchase_items",
|
||||
"id",
|
||||
"total_used",
|
||||
fifo.StockableKeyPurchaseItems.String(),
|
||||
stockableRefs[fifo.StockableKeyPurchaseItems.String()],
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.resetAndResyncUsedQuantity(
|
||||
ctx,
|
||||
tx,
|
||||
"adjustment_stocks",
|
||||
"id",
|
||||
"total_used",
|
||||
fifo.StockableKeyAdjustmentIn.String(),
|
||||
stockableRefs[fifo.StockableKeyAdjustmentIn.String()],
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *chickinService) resetAndResyncUsedQuantity(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
tableName string,
|
||||
idColumn string,
|
||||
usedColumn string,
|
||||
stockableType string,
|
||||
ids []uint,
|
||||
) error {
|
||||
ids = uniqueUint(ids)
|
||||
if tx == nil || len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := tx.WithContext(ctx).
|
||||
Table(tableName).
|
||||
Where(fmt.Sprintf("%s IN ?", idColumn), ids).
|
||||
Update(usedColumn, 0).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
UPDATE %s AS t
|
||||
SET %s = a.used
|
||||
FROM (
|
||||
SELECT stockable_id, COALESCE(SUM(qty), 0) AS used
|
||||
FROM stock_allocations
|
||||
WHERE stockable_type = ?
|
||||
AND status = ?
|
||||
AND allocation_purpose = ?
|
||||
AND stockable_id IN ?
|
||||
GROUP BY stockable_id
|
||||
) AS a
|
||||
WHERE t.%s = a.stockable_id
|
||||
`, tableName, usedColumn, idColumn)
|
||||
|
||||
if err := tx.WithContext(ctx).Exec(
|
||||
query,
|
||||
stockableType,
|
||||
entity.StockAllocationStatusActive,
|
||||
entity.StockAllocationPurposeConsume,
|
||||
ids,
|
||||
).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func uniqueUint(values []uint) []uint {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]uint, 0, len(values))
|
||||
seen := make(map[uint]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
if value == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeDateOnlyUTC(value time.Time) time.Time {
|
||||
if value.IsZero() {
|
||||
return time.Time{}
|
||||
|
||||
@@ -7,14 +7,14 @@ import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"gorm.io/gorm"
|
||||
|
||||
sProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project-flock-kandangs/services"
|
||||
rProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
|
||||
rKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories"
|
||||
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
|
||||
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
|
||||
rExpense "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories"
|
||||
rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
|
||||
rKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories"
|
||||
rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories"
|
||||
sProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project-flock-kandangs/services"
|
||||
rProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
||||
|
||||
rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
|
||||
@@ -33,13 +33,14 @@ func (ProjectFlockKandangModule) RegisterRoutes(router fiber.Router, db *gorm.DB
|
||||
|
||||
approvalRepo := commonRepo.NewApprovalRepository(db)
|
||||
approvalService := commonSvc.NewApprovalService(approvalRepo)
|
||||
fifoStockV2Service := commonSvc.NewFifoStockV2Service(db, utils.Log)
|
||||
// register workflow steps for chickin approvals
|
||||
if err := approvalService.RegisterWorkflowSteps(utils.ApprovalWorkflowProjectFlockKandang, utils.ProjectFlockKandangApprovalSteps); err != nil {
|
||||
panic(fmt.Sprintf("failed to register chickin approval workflow: %v", err))
|
||||
}
|
||||
|
||||
expenseRepo := rExpense.NewExpenseRepository(db)
|
||||
projectFlockKandangService := sProjectFlockKandang.NewProjectFlockKandangService(projectFlockKandangRepo, approvalService, expenseRepo, warehouseRepo, productWarehouseRepo, projectFlockPopulationRepo,kandangRepo, validate)
|
||||
projectFlockKandangService := sProjectFlockKandang.NewProjectFlockKandangService(projectFlockKandangRepo, approvalService, fifoStockV2Service, expenseRepo, warehouseRepo, productWarehouseRepo, projectFlockPopulationRepo, kandangRepo, validate)
|
||||
userService := sUser.NewUserService(userRepo, validate)
|
||||
|
||||
ProjectFlockKandangRoutes(router, userService, projectFlockKandangService)
|
||||
|
||||
+89
-1
@@ -1,8 +1,10 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -35,6 +37,7 @@ type projectFlockKandangService struct {
|
||||
Validate *validator.Validate
|
||||
Repository repository.ProjectFlockKandangRepository
|
||||
ApprovalSvc commonSvc.ApprovalService
|
||||
FifoStockV2Svc commonSvc.FifoStockV2Service
|
||||
ExpenseRepo expenseRepo.ExpenseRepository
|
||||
WarehouseRepo rWarehouse.WarehouseRepository
|
||||
ProductWarehouseRepo rProductWarehouse.ProductWarehouseRepository
|
||||
@@ -69,12 +72,13 @@ type ExpenseSummary struct {
|
||||
Reference string `json:"reference_number"`
|
||||
}
|
||||
|
||||
func NewProjectFlockKandangService(repo repository.ProjectFlockKandangRepository, approvalSvc commonSvc.ApprovalService, expenseRepo expenseRepo.ExpenseRepository, warehouseRepo rWarehouse.WarehouseRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, populationRepo repository.ProjectFlockPopulationRepository, kandangRepo kandangRepo.KandangRepository, validate *validator.Validate) ProjectFlockKandangService {
|
||||
func NewProjectFlockKandangService(repo repository.ProjectFlockKandangRepository, approvalSvc commonSvc.ApprovalService, fifoStockV2Svc commonSvc.FifoStockV2Service, expenseRepo expenseRepo.ExpenseRepository, warehouseRepo rWarehouse.WarehouseRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, populationRepo repository.ProjectFlockPopulationRepository, kandangRepo kandangRepo.KandangRepository, validate *validator.Validate) ProjectFlockKandangService {
|
||||
return &projectFlockKandangService{
|
||||
Log: utils.Log,
|
||||
Validate: validate,
|
||||
Repository: repo,
|
||||
ApprovalSvc: approvalSvc,
|
||||
FifoStockV2Svc: fifoStockV2Svc,
|
||||
ExpenseRepo: expenseRepo,
|
||||
WarehouseRepo: warehouseRepo,
|
||||
ProductWarehouseRepo: productWarehouseRepo,
|
||||
@@ -671,7 +675,91 @@ func (s projectFlockKandangService) calculateAvailableQuantityForProductWarehous
|
||||
if availableQty < 0 {
|
||||
availableQty = 0
|
||||
}
|
||||
|
||||
sourceAvailable, err := s.resolveLayingSourceAvailableQty(c.Context(), nil, productWarehouse.Id, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if sourceAvailable < availableQty {
|
||||
availableQty = sourceAvailable
|
||||
}
|
||||
}
|
||||
|
||||
return availableQty, nil
|
||||
}
|
||||
|
||||
func (s projectFlockKandangService) resolveLayingSourceAvailableQty(ctx context.Context, tx *gorm.DB, productWarehouseID uint, asOf *time.Time) (float64, error) {
|
||||
if productWarehouseID == 0 || s.FifoStockV2Svc == nil {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
flagGroupCode, err := s.resolveFlagGroupByProductWarehouse(ctx, tx, productWarehouseID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if strings.TrimSpace(flagGroupCode) == "" {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
gatherRows, err := s.FifoStockV2Svc.Gather(ctx, commonSvc.FifoStockV2GatherRequest{
|
||||
FlagGroupCode: flagGroupCode,
|
||||
Lane: commonSvc.FifoStockV2Lane("STOCKABLE"),
|
||||
AllocationPurpose: entity.StockAllocationPurposeConsume,
|
||||
ProductWarehouseID: productWarehouseID,
|
||||
AsOf: asOf,
|
||||
Limit: 10000,
|
||||
Tx: tx,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
total := 0.0
|
||||
for _, row := range gatherRows {
|
||||
if row.AvailableQuantity <= 0 {
|
||||
continue
|
||||
}
|
||||
total += row.AvailableQuantity
|
||||
}
|
||||
return math.Max(total, 0), nil
|
||||
}
|
||||
|
||||
func (s projectFlockKandangService) resolveFlagGroupByProductWarehouse(ctx context.Context, tx *gorm.DB, productWarehouseID uint) (string, error) {
|
||||
type row struct {
|
||||
FlagGroupCode string `gorm:"column:flag_group_code"`
|
||||
}
|
||||
selected := row{}
|
||||
|
||||
db := s.Repository.DB()
|
||||
if tx != nil {
|
||||
db = tx
|
||||
}
|
||||
|
||||
err := db.WithContext(ctx).
|
||||
Table("fifo_stock_v2_route_rules rr").
|
||||
Select("rr.flag_group_code").
|
||||
Joins("JOIN fifo_stock_v2_flag_groups fg ON fg.code = rr.flag_group_code AND fg.is_active = TRUE").
|
||||
Where("rr.is_active = TRUE").
|
||||
Where("rr.lane = 'STOCKABLE'").
|
||||
Where(`
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM product_warehouses pw
|
||||
JOIN flags f ON f.flagable_id = pw.product_id
|
||||
JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.is_active = TRUE
|
||||
WHERE pw.id = ?
|
||||
AND f.flagable_type = ?
|
||||
AND fm.flag_group_code = rr.flag_group_code
|
||||
)
|
||||
`, productWarehouseID, entity.FlagableTypeProduct).
|
||||
Order("fg.priority ASC, rr.id ASC").
|
||||
Limit(1).
|
||||
Take(&selected).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "", nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(selected.FlagGroupCode), nil
|
||||
}
|
||||
|
||||
@@ -155,7 +155,6 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
|
||||
productWarehouseRepo,
|
||||
warehouseRepo,
|
||||
approvalService,
|
||||
fifoService,
|
||||
fifoStockV2Service,
|
||||
validate,
|
||||
)
|
||||
|
||||
@@ -91,7 +91,6 @@ func (TransferLayingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, val
|
||||
productWarehouseRepo,
|
||||
warehouseRepo,
|
||||
approvalService,
|
||||
fifoService,
|
||||
fifoStockV2Service,
|
||||
validate,
|
||||
)
|
||||
|
||||
+132
@@ -2,15 +2,22 @@ package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/common/repository"
|
||||
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type LayingTransferTargetRepository interface {
|
||||
repository.BaseRepository[entity.LayingTransferTarget]
|
||||
GetByLayingTransferId(ctx context.Context, layingTransferId uint) ([]entity.LayingTransferTarget, error)
|
||||
GetActiveDownstreamConsumptions(ctx context.Context, targetIDs []uint) ([]TargetDownstreamConsumption, error)
|
||||
GetEarliestRecordingDateByTarget(ctx context.Context, targetProjectFlockKandangID uint, sinceDate time.Time) (*time.Time, error)
|
||||
CountActiveTransferSourceConsumeAllocations(ctx context.Context, transferID uint, productWarehouseID uint) (int64, error)
|
||||
SyncPopulationUsageByProjectFlockKandang(ctx context.Context, projectFlockKandangID uint) error
|
||||
}
|
||||
|
||||
type LayingTransferTargetRepositoryImpl struct {
|
||||
@@ -18,6 +25,11 @@ type LayingTransferTargetRepositoryImpl struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
type TargetDownstreamConsumption struct {
|
||||
UsableType string `gorm:"column:usable_type"`
|
||||
UsableID uint `gorm:"column:usable_id"`
|
||||
}
|
||||
|
||||
func NewLayingTransferTargetRepository(db *gorm.DB) LayingTransferTargetRepository {
|
||||
return &LayingTransferTargetRepositoryImpl{
|
||||
BaseRepositoryImpl: repository.NewBaseRepository[entity.LayingTransferTarget](db),
|
||||
@@ -36,3 +48,123 @@ func (r *LayingTransferTargetRepositoryImpl) GetByLayingTransferId(ctx context.C
|
||||
}
|
||||
return targets, nil
|
||||
}
|
||||
|
||||
func (r *LayingTransferTargetRepositoryImpl) GetActiveDownstreamConsumptions(ctx context.Context, targetIDs []uint) ([]TargetDownstreamConsumption, error) {
|
||||
if len(targetIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var rows []TargetDownstreamConsumption
|
||||
err := r.db.WithContext(ctx).
|
||||
Table("stock_allocations").
|
||||
Select("usable_type, usable_id").
|
||||
Where("stockable_type = ?", fifo.StockableKeyTransferToLayingIn.String()).
|
||||
Where("stockable_id IN ?", targetIDs).
|
||||
Where("status = ?", entity.StockAllocationStatusActive).
|
||||
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
|
||||
Where("deleted_at IS NULL").
|
||||
Group("usable_type, usable_id").
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (r *LayingTransferTargetRepositoryImpl) GetEarliestRecordingDateByTarget(ctx context.Context, targetProjectFlockKandangID uint, sinceDate time.Time) (*time.Time, error) {
|
||||
if targetProjectFlockKandangID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var earliest entity.Recording
|
||||
query := r.db.WithContext(ctx).
|
||||
Model(&entity.Recording{}).
|
||||
Where("project_flock_kandangs_id = ?", targetProjectFlockKandangID).
|
||||
Where("deleted_at IS NULL")
|
||||
if !sinceDate.IsZero() {
|
||||
query = query.Where("record_datetime >= ?", sinceDate)
|
||||
}
|
||||
if err := query.Order("record_datetime ASC").Limit(1).Take(&earliest).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
d := earliest.RecordDatetime.UTC()
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
func (r *LayingTransferTargetRepositoryImpl) CountActiveTransferSourceConsumeAllocations(ctx context.Context, transferID uint, productWarehouseID uint) (int64, error) {
|
||||
if transferID == 0 || productWarehouseID == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
var count int64
|
||||
err := r.db.WithContext(ctx).
|
||||
Model(&entity.StockAllocation{}).
|
||||
Where("product_warehouse_id = ?", productWarehouseID).
|
||||
Where("usable_type = ?", fifo.UsableKeyTransferToLayingOut.String()).
|
||||
Where("usable_id = ?", transferID).
|
||||
Where("status = ?", entity.StockAllocationStatusActive).
|
||||
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
|
||||
Count(&count).Error
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (r *LayingTransferTargetRepositoryImpl) SyncPopulationUsageByProjectFlockKandang(ctx context.Context, projectFlockKandangID uint) error {
|
||||
if projectFlockKandangID == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var populationIDs []uint
|
||||
if err := r.db.WithContext(ctx).
|
||||
Table("project_flock_populations pfp").
|
||||
Select("pfp.id").
|
||||
Joins("JOIN project_chickins pc ON pc.id = pfp.project_chickin_id").
|
||||
Where("pc.project_flock_kandang_id = ?", projectFlockKandangID).
|
||||
Pluck("pfp.id", &populationIDs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(populationIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
type usageRow struct {
|
||||
StockableID uint `gorm:"column:stockable_id"`
|
||||
Used float64 `gorm:"column:used"`
|
||||
}
|
||||
var usageRows []usageRow
|
||||
if err := r.db.WithContext(ctx).
|
||||
Table("stock_allocations").
|
||||
Select("stockable_id, COALESCE(SUM(qty), 0) AS used").
|
||||
Where("stockable_type = ?", fifo.StockableKeyProjectFlockPopulation.String()).
|
||||
Where("status = ?", entity.StockAllocationStatusActive).
|
||||
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
|
||||
Where("stockable_id IN ?", populationIDs).
|
||||
Group("stockable_id").
|
||||
Scan(&usageRows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := r.db.WithContext(ctx).
|
||||
Model(&entity.ProjectFlockPopulation{}).
|
||||
Where("id IN ?", populationIDs).
|
||||
Update("total_used_qty", 0).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, row := range usageRows {
|
||||
if err := r.db.WithContext(ctx).
|
||||
Model(&entity.ProjectFlockPopulation{}).
|
||||
Where("id = ?", row.StockableID).
|
||||
Update("total_used_qty", row.Used).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
+162
-112
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -56,12 +57,12 @@ type transferLayingService struct {
|
||||
WarehouseRepo rWarehouse.WarehouseRepository
|
||||
StockLogRepo rStockLogs.StockLogRepository
|
||||
ApprovalService commonSvc.ApprovalService
|
||||
FifoSvc commonSvc.FifoService
|
||||
FifoStockV2Svc commonSvc.FifoStockV2Service
|
||||
}
|
||||
|
||||
const (
|
||||
transferToLayingFlagGroupCode = "AYAM"
|
||||
transferToLayingFlagGroupCode = "AYAM"
|
||||
transferLayingDeleteDownstreamGuardMessage = "Transfer laying tidak dapat dihapus karena stok target transfer sudah dipakai transaksi turunan. Hapus dependensi terkait secara manual terlebih dahulu."
|
||||
)
|
||||
|
||||
func NewTransferLayingService(
|
||||
@@ -74,7 +75,6 @@ func NewTransferLayingService(
|
||||
productWarehouseRepo rInventory.ProductWarehouseRepository,
|
||||
warehouseRepo rWarehouse.WarehouseRepository,
|
||||
approvalService commonSvc.ApprovalService,
|
||||
fifoSvc commonSvc.FifoService,
|
||||
fifoStockV2Svc commonSvc.FifoStockV2Service,
|
||||
validate *validator.Validate,
|
||||
) TransferLayingService {
|
||||
@@ -91,7 +91,6 @@ func NewTransferLayingService(
|
||||
WarehouseRepo: warehouseRepo,
|
||||
StockLogRepo: rStockLogs.NewStockLogRepository(repo.DB()),
|
||||
ApprovalService: approvalService,
|
||||
FifoSvc: fifoSvc,
|
||||
FifoStockV2Svc: fifoStockV2Svc,
|
||||
}
|
||||
}
|
||||
@@ -610,6 +609,9 @@ func (s transferLayingService) DeleteOne(c *fiber.Ctx, id uint) error {
|
||||
if isLegacyTransfer(transfer) {
|
||||
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Transfer laying legacy %s tidak dapat dihapus", transfer.TransferNumber))
|
||||
}
|
||||
if err := s.ensureNoDownstreamConsumptionForDelete(c.Context(), nil, transfer.TransferNumber, transfer.Targets); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
approvalRepo := commonRepo.NewApprovalRepository(s.Repository.DB())
|
||||
|
||||
@@ -635,6 +637,16 @@ func (s transferLayingService) DeleteOne(c *fiber.Ctx, id uint) error {
|
||||
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error {
|
||||
repoTx := s.Repository.WithTx(dbTransaction)
|
||||
|
||||
// Lock header row to keep delete deterministic after single downstream guard check.
|
||||
if _, err := repoTx.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
|
||||
return db.Clauses(clause.Locking{Strength: "UPDATE"})
|
||||
}); err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return fiber.NewError(fiber.StatusNotFound, "TransferLaying not found")
|
||||
}
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get transfer laying")
|
||||
}
|
||||
|
||||
if err := repoTx.DeleteOne(c.Context(), id); err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete transfer laying")
|
||||
}
|
||||
@@ -1053,6 +1065,11 @@ func (s transferLayingService) Unexecute(c *fiber.Ctx, id uint) (*entity.LayingT
|
||||
)
|
||||
}
|
||||
|
||||
type targetReflowKey struct {
|
||||
productWarehouseID uint
|
||||
}
|
||||
targetReflow := make(map[targetReflowKey]struct{})
|
||||
|
||||
for _, target := range targets {
|
||||
if target.ProductWarehouseId == nil || *target.ProductWarehouseId == 0 {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Target product warehouse tidak ditemukan untuk transfer %d", transfer.Id))
|
||||
@@ -1060,15 +1077,6 @@ func (s transferLayingService) Unexecute(c *fiber.Ctx, id uint) (*entity.LayingT
|
||||
if target.TotalQty <= 0 {
|
||||
continue
|
||||
}
|
||||
if err := s.FifoSvc.AdjustStockableQuantity(c.Context(), commonSvc.StockAdjustRequest{
|
||||
StockableKey: fifo.StockableKeyTransferToLayingIn,
|
||||
StockableID: target.Id,
|
||||
ProductWarehouseID: *target.ProductWarehouseId,
|
||||
Quantity: -target.TotalQty,
|
||||
Tx: dbTransaction,
|
||||
}); err != nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal rollback stok target transfer laying: %v", err))
|
||||
}
|
||||
|
||||
stockLogDecrease := &entity.StockLog{
|
||||
ProductWarehouseId: *target.ProductWarehouseId,
|
||||
@@ -1092,6 +1100,20 @@ func (s transferLayingService) Unexecute(c *fiber.Ctx, id uint) (*entity.LayingT
|
||||
if err := stockLogRepoTx.CreateOne(c.Context(), stockLogDecrease, nil); err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok keluar target saat unexecute")
|
||||
}
|
||||
|
||||
if err := targetRepoTx.PatchOne(c.Context(), target.Id, map[string]any{
|
||||
"total_qty": 0,
|
||||
"total_used": 0,
|
||||
}, nil); err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Gagal rollback kuantitas target transfer laying")
|
||||
}
|
||||
targetReflow[targetReflowKey{productWarehouseID: *target.ProductWarehouseId}] = struct{}{}
|
||||
}
|
||||
asOf := normalizeDateOnlyUTC(transfer.TransferDate)
|
||||
for key := range targetReflow {
|
||||
if err := reflowTransferLayingScope(c.Context(), s.FifoStockV2Svc, dbTransaction, key.productWarehouseID, &asOf); err != nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal rollback FIFO v2 target transfer laying: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
rollbackResult, err := s.FifoStockV2Svc.Rollback(c.Context(), commonSvc.FifoStockV2RollbackRequest{
|
||||
@@ -1229,9 +1251,6 @@ func (s *transferLayingService) executeApprovedTransferMovement(
|
||||
if s.FifoStockV2Svc == nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "FIFO v2 service is not available")
|
||||
}
|
||||
if s.FifoSvc == nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "FIFO service is not available")
|
||||
}
|
||||
|
||||
stockAllocationRepo := commonRepo.NewStockAllocationRepository(tx)
|
||||
targetRepoTx := repository.NewLayingTransferTargetRepository(tx)
|
||||
@@ -1327,29 +1346,22 @@ func (s *transferLayingService) executeApprovedTransferMovement(
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok keluar")
|
||||
}
|
||||
|
||||
type targetReflowKey struct {
|
||||
productWarehouseID uint
|
||||
}
|
||||
targetReflow := make(map[targetReflowKey]struct{})
|
||||
|
||||
for _, target := range targets {
|
||||
if target.ProductWarehouseId == nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Target product warehouse tidak ditemukan untuk transfer %d", transfer.Id))
|
||||
}
|
||||
|
||||
note := fmt.Sprintf("Transfer to Laying #%s", transfer.TransferNumber)
|
||||
_, err := s.FifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{
|
||||
StockableKey: fifo.StockableKeyTransferToLayingIn,
|
||||
StockableID: target.Id,
|
||||
ProductWarehouseID: *target.ProductWarehouseId,
|
||||
Quantity: target.TotalQty,
|
||||
Note: ¬e,
|
||||
Tx: tx,
|
||||
})
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal replenish stock ke target warehouse: %v", err))
|
||||
}
|
||||
|
||||
if err := targetRepoTx.PatchOne(ctx, target.Id, map[string]any{
|
||||
"total_qty": target.TotalQty,
|
||||
}, nil); err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Gagal update target total qty")
|
||||
}
|
||||
targetReflow[targetReflowKey{productWarehouseID: *target.ProductWarehouseId}] = struct{}{}
|
||||
|
||||
stockLogIncrease := &entity.StockLog{
|
||||
ProductWarehouseId: *target.ProductWarehouseId,
|
||||
@@ -1376,6 +1388,11 @@ func (s *transferLayingService) executeApprovedTransferMovement(
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok masuk")
|
||||
}
|
||||
}
|
||||
for key := range targetReflow {
|
||||
if err := reflowTransferLayingScope(ctx, s.FifoStockV2Svc, tx, key.productWarehouseID, &asOf); err != nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gagal reflow FIFO v2 target transfer laying: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1595,31 +1612,19 @@ func (s *transferLayingService) hasDownstreamRecordingOnTarget(
|
||||
targetProjectFlockKandangID uint,
|
||||
sinceDate time.Time,
|
||||
) (bool, time.Time, error) {
|
||||
if targetProjectFlockKandangID == 0 {
|
||||
return false, time.Time{}, nil
|
||||
}
|
||||
|
||||
db := s.Repository.DB().WithContext(ctx)
|
||||
targetRepo := s.LayingTransferTargetRepo
|
||||
if tx != nil {
|
||||
db = tx.WithContext(ctx)
|
||||
targetRepo = repository.NewLayingTransferTargetRepository(tx)
|
||||
}
|
||||
|
||||
var earliest entity.Recording
|
||||
query := db.Model(&entity.Recording{}).
|
||||
Where("project_flock_kandangs_id = ?", targetProjectFlockKandangID).
|
||||
Where("deleted_at IS NULL")
|
||||
if !sinceDate.IsZero() {
|
||||
query = query.Where("record_datetime >= ?", sinceDate)
|
||||
}
|
||||
|
||||
if err := query.Order("record_datetime ASC").Limit(1).Take(&earliest).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return false, time.Time{}, nil
|
||||
}
|
||||
recordDate, err := targetRepo.GetEarliestRecordingDateByTarget(ctx, targetProjectFlockKandangID, sinceDate)
|
||||
if err != nil {
|
||||
return false, time.Time{}, err
|
||||
}
|
||||
|
||||
return true, normalizeDateOnlyUTC(earliest.RecordDatetime), nil
|
||||
if recordDate == nil {
|
||||
return false, time.Time{}, nil
|
||||
}
|
||||
return true, normalizeDateOnlyUTC(*recordDate), nil
|
||||
}
|
||||
|
||||
func (s *transferLayingService) countActiveTransferSourceConsumeAllocations(
|
||||
@@ -1628,81 +1633,45 @@ func (s *transferLayingService) countActiveTransferSourceConsumeAllocations(
|
||||
transferID uint,
|
||||
productWarehouseID uint,
|
||||
) (int64, error) {
|
||||
if transferID == 0 || productWarehouseID == 0 {
|
||||
return 0, nil
|
||||
targetRepo := s.LayingTransferTargetRepo
|
||||
if tx != nil {
|
||||
targetRepo = repository.NewLayingTransferTargetRepository(tx)
|
||||
}
|
||||
if tx == nil {
|
||||
return 0, errors.New("transaction is required")
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := tx.WithContext(ctx).
|
||||
Model(&entity.StockAllocation{}).
|
||||
Where("product_warehouse_id = ?", productWarehouseID).
|
||||
Where("usable_type = ?", fifo.UsableKeyTransferToLayingOut.String()).
|
||||
Where("usable_id = ?", transferID).
|
||||
Where("status = ?", entity.StockAllocationStatusActive).
|
||||
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
|
||||
Count(&count).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return count, nil
|
||||
return targetRepo.CountActiveTransferSourceConsumeAllocations(ctx, transferID, productWarehouseID)
|
||||
}
|
||||
|
||||
func (s *transferLayingService) resyncPopulationUsageByProjectFlockKandang(ctx context.Context, tx *gorm.DB, projectFlockKandangID uint) error {
|
||||
if projectFlockKandangID == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
db := s.Repository.DB().WithContext(ctx)
|
||||
targetRepo := s.LayingTransferTargetRepo
|
||||
if tx != nil {
|
||||
db = tx.WithContext(ctx)
|
||||
targetRepo = repository.NewLayingTransferTargetRepository(tx)
|
||||
}
|
||||
return targetRepo.SyncPopulationUsageByProjectFlockKandang(ctx, projectFlockKandangID)
|
||||
}
|
||||
|
||||
var populationIDs []uint
|
||||
if err := db.Table("project_flock_populations pfp").
|
||||
Select("pfp.id").
|
||||
Joins("JOIN project_chickins pc ON pc.id = pfp.project_chickin_id").
|
||||
Where("pc.project_flock_kandang_id = ?", projectFlockKandangID).
|
||||
Pluck("pfp.id", &populationIDs).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(populationIDs) == 0 {
|
||||
func sortedIDs(input map[uint]struct{}) []uint {
|
||||
if len(input) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
type usageRow struct {
|
||||
StockableID uint `gorm:"column:stockable_id"`
|
||||
Used float64 `gorm:"column:used"`
|
||||
}
|
||||
var usageRows []usageRow
|
||||
if err := db.Table("stock_allocations").
|
||||
Select("stockable_id, COALESCE(SUM(qty), 0) AS used").
|
||||
Where("stockable_type = ?", fifo.StockableKeyProjectFlockPopulation.String()).
|
||||
Where("status = ?", entity.StockAllocationStatusActive).
|
||||
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
|
||||
Where("stockable_id IN ?", populationIDs).
|
||||
Group("stockable_id").
|
||||
Scan(&usageRows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := db.Model(&entity.ProjectFlockPopulation{}).
|
||||
Where("id IN ?", populationIDs).
|
||||
Update("total_used_qty", 0).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, row := range usageRows {
|
||||
if err := db.Model(&entity.ProjectFlockPopulation{}).
|
||||
Where("id = ?", row.StockableID).
|
||||
Update("total_used_qty", row.Used).Error; err != nil {
|
||||
return err
|
||||
out := make([]uint, 0, len(input))
|
||||
for id := range input {
|
||||
if id == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, id)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
|
||||
return out
|
||||
}
|
||||
|
||||
return nil
|
||||
func joinUint(values []uint) string {
|
||||
if len(values) == 0 {
|
||||
return "-"
|
||||
}
|
||||
parts := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
parts = append(parts, fmt.Sprintf("%d", value))
|
||||
}
|
||||
return strings.Join(parts, "|")
|
||||
}
|
||||
|
||||
func normalizeDateOnlyUTC(value time.Time) time.Time {
|
||||
@@ -1721,3 +1690,84 @@ func isLegacyTransfer(transfer *entity.LayingTransfer) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *transferLayingService) ensureNoDownstreamConsumptionForDelete(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
transferNumber string,
|
||||
targets []entity.LayingTransferTarget,
|
||||
) error {
|
||||
targetIDs := make([]uint, 0, len(targets))
|
||||
for _, target := range targets {
|
||||
if target.Id == 0 {
|
||||
continue
|
||||
}
|
||||
targetIDs = append(targetIDs, target.Id)
|
||||
}
|
||||
if len(targetIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
targetRepo := s.LayingTransferTargetRepo
|
||||
if tx != nil {
|
||||
targetRepo = repository.NewLayingTransferTargetRepository(tx)
|
||||
}
|
||||
|
||||
rows, err := targetRepo.GetActiveDownstreamConsumptions(ctx, targetIDs)
|
||||
if err != nil {
|
||||
s.Log.Errorf("Failed to validate downstream consumption for transfer laying %s: %+v", transferNumber, err)
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memvalidasi transaksi turunan transfer laying")
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
dependencyMap := make(map[string]map[uint]struct{})
|
||||
for _, row := range rows {
|
||||
label := mapTransferLayingDownstreamUsableLabel(row.UsableType)
|
||||
if _, ok := dependencyMap[label]; !ok {
|
||||
dependencyMap[label] = make(map[uint]struct{})
|
||||
}
|
||||
dependencyMap[label][row.UsableID] = struct{}{}
|
||||
}
|
||||
|
||||
labels := make([]string, 0, len(dependencyMap))
|
||||
for label := range dependencyMap {
|
||||
labels = append(labels, label)
|
||||
}
|
||||
sort.Strings(labels)
|
||||
|
||||
details := make([]string, 0, len(labels))
|
||||
for _, label := range labels {
|
||||
details = append(details, fmt.Sprintf("%s=%s", label, joinUint(sortedIDs(dependencyMap[label]))))
|
||||
}
|
||||
|
||||
return fiber.NewError(
|
||||
fiber.StatusBadRequest,
|
||||
fmt.Sprintf(
|
||||
"%s Transfer %s. Dependensi aktif: %s.",
|
||||
transferLayingDeleteDownstreamGuardMessage,
|
||||
transferNumber,
|
||||
strings.Join(details, ", "),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func mapTransferLayingDownstreamUsableLabel(usableType string) string {
|
||||
switch strings.ToUpper(strings.TrimSpace(usableType)) {
|
||||
case fifo.UsableKeyRecordingStock.String(), fifo.UsableKeyRecordingDepletion.String():
|
||||
return "Recording"
|
||||
case fifo.UsableKeyProjectChickin.String():
|
||||
return "Chickin"
|
||||
case fifo.UsableKeyMarketingDelivery.String():
|
||||
return "Marketing"
|
||||
case fifo.UsableKeyTransferToLayingOut.String():
|
||||
return "TransferToLaying"
|
||||
case fifo.UsableKeyStockTransferOut.String():
|
||||
return "TransferStock"
|
||||
case fifo.UsableKeyAdjustmentOut.String():
|
||||
return "Adjustment"
|
||||
default:
|
||||
return strings.ToUpper(strings.TrimSpace(usableType))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user