Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into fix/BE/Report-purchasing-Debt-supplier-and-Closing-counting-sapronak

This commit is contained in:
ragilap
2026-01-23 11:53:54 +07:00
15 changed files with 570 additions and 131 deletions
+1
View File
@@ -9,6 +9,7 @@ workflow:
include: include:
- local: "ci/development.yml" - local: "ci/development.yml"
rules: rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == "development"' - if: '$CI_COMMIT_BRANCH == "development"'
- local: "ci/staging.yml" - local: "ci/staging.yml"
+32 -34
View File
@@ -1,6 +1,6 @@
stages: stages:
- build - build
- migrate # - migrate
- deploy - deploy
- seed - seed
@@ -25,7 +25,7 @@ variables:
COMPOSE_FILE: "docker-compose.yaml" COMPOSE_FILE: "docker-compose.yaml"
# ========================= # =========================
# BUILD (AUTO) # BUILD (AUTO)
# ========================= # =========================
build_production: build_production:
stage: build stage: build
@@ -51,39 +51,39 @@ build_production:
# ========================= # =========================
# MIGRATE (PRODUCTION - MANUAL) # MIGRATE (PRODUCTION - MANUAL)
# ========================= # =========================
migrate_production: #migrate_production:
stage: migrate # stage: migrate
rules: # rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' # - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"'
when: manual # when: manual
allow_failure: false # allow_failure: false
needs: # needs:
- job: build_production # - job: build_production
artifacts: false # artifacts: false
script: | # script: |
set -e # set -e
cd /opt/deploy/lti # cd /opt/deploy/lti
test -f .env || (echo "❌ .env not found" && exit 1) # test -f .env || (echo "❌ .env not found" && exit 1)
set -a # set -a
. ./.env # . ./.env
set +a # set +a
# Validasi env wajib # Validasi env wajib
: "${DB_HOST:?DB_HOST not set}" # : "${DB_HOST:?DB_HOST not set}"
: "${DB_PORT:?DB_PORT not set}" # : "${DB_PORT:?DB_PORT not set}"
: "${DB_USER:?DB_USER not set}" # : "${DB_USER:?DB_USER not set}"
: "${DB_PASSWORD:?DB_PASSWORD not set}" # : "${DB_PASSWORD:?DB_PASSWORD not set}"
: "${DB_NAME:?DB_NAME not set}" # : "${DB_NAME:?DB_NAME not set}"
DB_SSLMODE="${DB_SSLMODE:-require}" # DB_SSLMODE="${DB_SSLMODE:-require}"
export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE}" # export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE}"
echo "✅ Running migrations (production)..." # echo "✅ Running migrations (production)..."
docker run --rm \ # docker run --rm \
-v "$CI_PROJECT_DIR/internal/database/migrations:/migrations:ro" \ # -v "$CI_PROJECT_DIR/internal/database/migrations:/migrations:ro" \
migrate/migrate:v4.15.2 \ # migrate/migrate:v4.15.2 \
-path=/migrations -database "$DATABASE_URL" up # -path=/migrations -database "$DATABASE_URL" up
# ========================= # =========================
@@ -94,8 +94,8 @@ deploy_production:
rules: rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"'
needs: needs:
- job: migrate_production # - job: migrate_production
artifacts: false # artifacts: false
- job: build_production - job: build_production
artifacts: false artifacts: false
script: | script: |
@@ -129,5 +129,3 @@ seed_production:
docker compose --env-file .env pull seed docker compose --env-file .env pull seed
docker compose --env-file .env run --rm seed docker compose --env-file .env run --rm seed
+81 -41
View File
@@ -6,31 +6,31 @@ stages:
default: default:
tags: tags:
- self-hosted-prod - self-hosted-stg
workflow: workflow:
rules: rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"'
when: always when: always
- when: never - when: never
variables: variables:
DOCKER_BUILDKIT: "1" DOCKER_BUILDKIT: "1"
IMAGE_TAG: "production_${CI_COMMIT_SHORT_SHA}" IMAGE_TAG: "staging_${CI_COMMIT_SHORT_SHA}"
IMAGE_NAME: "${CI_REGISTRY_IMAGE}:${IMAGE_TAG}" IMAGE_NAME: "${CI_REGISTRY_IMAGE}:${IMAGE_TAG}"
IMAGE_LATEST: "${CI_REGISTRY_IMAGE}:production_latest" IMAGE_LATEST: "${CI_REGISTRY_IMAGE}:staging_latest"
DEPLOY_DIR: "/opt/deploy/lti" DEPLOY_DIR: "/opt/deploy/stg-lti-api"
COMPOSE_FILE: "docker-compose.yaml" COMPOSE_FILE: "docker-compose.yaml"
# ========================= # =========================
# BUILD (AUTO) # BUILD (AUTO)
# ========================= # =========================
build_production: build_staging:
stage: build stage: build
rules: rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"'
script: | script: |
set -e set -e
docker info docker info
@@ -49,54 +49,93 @@ build_production:
# ========================= # =========================
# MIGRATE (PRODUCTION - MANUAL) # MIGRATE (AUTO)
# ========================= # =========================
migrate_production: migrate_staging:
stage: migrate stage: migrate
rules: rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"'
when: manual
allow_failure: false
needs: needs:
- job: build_production - job: build_staging
artifacts: false artifacts: false
script: | script: |
set -e set -e
cd /opt/deploy/lti echo "✅ Running migrations (staging) ..."
test -f .env || (echo "❌ .env not found" && exit 1)
cd "$DEPLOY_DIR"
test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found in $DEPLOY_DIR" && exit 1)
test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1)
# ✅ load env dari server
set -a set -a
. ./.env . ./.env
set +a set +a
# Validasi env wajib # ✅ validasi
: "${DB_HOST:?DB_HOST not set}" test -n "$DB_HOST" || (echo "❌ DB_HOST empty" && exit 1)
: "${DB_PORT:?DB_PORT not set}" test -n "$DB_PORT" || (echo "❌ DB_PORT empty" && exit 1)
: "${DB_USER:?DB_USER not set}" test -n "$DB_USER" || (echo "❌ DB_USER empty" && exit 1)
: "${DB_PASSWORD:?DB_PASSWORD not set}" test -n "$DB_PASSWORD" || (echo "❌ DB_PASSWORD empty" && exit 1)
: "${DB_NAME:?DB_NAME not set}" test -n "$DB_NAME" || (echo "❌ DB_NAME empty" && exit 1)
DB_SSLMODE="${DB_SSLMODE:-require}" export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE:-disable}"
export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE}" echo "✅ DATABASE_URL=$DATABASE_URL"
echo "✅ Running migrations (production)..." # ✅ Pastikan postgres & redis ON (sesuaikan nama service compose kamu!)
docker run --rm \ echo "✅ Ensuring postgres & redis running ..."
docker compose -f "$COMPOSE_FILE" up -d stg-postgres-lti stg-redis-lti || true
# ✅ Ambil network key dari compose
COMPOSE_NETWORK_KEY="$(docker compose -f "$COMPOSE_FILE" config | awk '/networks:/ {getline; print $1}' | tr -d ':')"
echo "✅ Compose network key: $COMPOSE_NETWORK_KEY"
# ✅ Cari network name yang dipakai docker
NETWORK_NAME="$(docker network ls --format '{{.Name}}' | grep "_${COMPOSE_NETWORK_KEY}$" | head -n 1)"
test -n "$NETWORK_NAME" || (echo "❌ Cannot find docker network for compose ($COMPOSE_NETWORK_KEY)" && exit 1)
echo "✅ Docker network detected: $NETWORK_NAME"
# ✅ Migrations dari repo (CI workspace)
echo "✅ Checking migrations from repo..."
ls -lah "$CI_PROJECT_DIR/internal/database/migrations"
echo "✅ Running migrations via migrate/migrate container"
set +e
out=$(docker run --rm \
--network "$NETWORK_NAME" \
-v "$CI_PROJECT_DIR/internal/database/migrations:/migrations:ro" \ -v "$CI_PROJECT_DIR/internal/database/migrations:/migrations:ro" \
migrate/migrate:v4.15.2 \ migrate/migrate:v4.15.2 \
-path=/migrations -database "$DATABASE_URL" up -path=/migrations -database "$DATABASE_URL" up 2>&1)
code=$?
set -e
echo "$out"
# ✅ Handle no change dengan benar (tidak false-success)
if echo "$out" | grep -qi "no change"; then
echo "✅ No change (already up to date)"
exit 0
fi
if [ $code -ne 0 ]; then
echo "❌ Migration failed with exit code $code"
exit $code
fi
echo "✅ Migration applied successfully"
# ========================= # =========================
# DEPLOY (AUTO) # DEPLOY (AUTO)
# ========================= # =========================
deploy_production: deploy_staging:
stage: deploy stage: deploy
rules: rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"'
needs: needs:
- job: migrate_production - job: migrate_staging
artifacts: false artifacts: false
- job: build_production - job: build_staging
artifacts: false artifacts: false
script: | script: |
set -e set -e
@@ -115,19 +154,20 @@ deploy_production:
# ========================= # =========================
# SEED (MANUAL) # SEED (MANUAL)
# ========================= # =========================
seed_production: seed_staging:
stage: seed stage: seed
rules: rules:
- if: '$CI_COMMIT_BRANCH == "production"' - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"'
when: manual needs:
- job: deploy_staging
artifacts: false
when: manual
allow_failure: false
script: | script: |
set -e set -e
cd /opt/deploy/lti cd "$DEPLOY_DIR"
test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found" && exit 1)
test -f .env || (echo "❌ .env not found" && exit 1) test -f .env || (echo "❌ .env not found" && exit 1)
echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY" docker compose -f "$COMPOSE_FILE" pull seed || true
docker compose -f "$COMPOSE_FILE" run --rm seed%
docker compose --env-file .env pull seed
docker compose --env-file .env run --rm seed
@@ -15,6 +15,7 @@ type SalesDTO struct {
Id uint `json:"id"` Id uint `json:"id"`
RealizationDate time.Time `json:"realization_date"` RealizationDate time.Time `json:"realization_date"`
Age int `json:"age"` Age int `json:"age"`
Week int `json:"week"`
DoNumber string `json:"do_number"` DoNumber string `json:"do_number"`
Product *productDTO.ProductRelationDTO `json:"product,omitempty"` Product *productDTO.ProductRelationDTO `json:"product,omitempty"`
Customer *customerDTO.CustomerRelationDTO `json:"customer,omitempty"` Customer *customerDTO.CustomerRelationDTO `json:"customer,omitempty"`
@@ -43,7 +44,7 @@ type PenjualanRealisasiResponseDTO struct {
func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO { func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO {
age := calculateAgeFromChickin(e.MarketingProduct.ProductWarehouse.ProjectFlockKandang, e.DeliveryDate) ageInDay, ageInWeeks := calculateAgeFromChickin(e.MarketingProduct.ProductWarehouse.ProjectFlockKandang, e.DeliveryDate)
var product *productDTO.ProductRelationDTO var product *productDTO.ProductRelationDTO
if e.MarketingProduct.ProductWarehouse.Product.Id != 0 { if e.MarketingProduct.ProductWarehouse.Product.Id != 0 {
@@ -73,7 +74,8 @@ func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO {
return SalesDTO{ return SalesDTO{
Id: e.Id, Id: e.Id,
RealizationDate: realizationDate, RealizationDate: realizationDate,
Age: age, Age: ageInDay,
Week: ageInWeeks,
DoNumber: doNumber, DoNumber: doNumber,
Product: product, Product: product,
Customer: customer, Customer: customer,
@@ -124,9 +126,9 @@ func ToPenjualanRealisasiResponseDTO(e []entity.MarketingDeliveryProduct) Penjua
} }
} }
func calculateAgeFromChickin(projectFlockKandang *entity.ProjectFlockKandang, deliveryDate *time.Time) int { func calculateAgeFromChickin(projectFlockKandang *entity.ProjectFlockKandang, deliveryDate *time.Time) (int, int) {
if projectFlockKandang == nil || deliveryDate == nil || len(projectFlockKandang.Chickins) == 0 { if projectFlockKandang == nil || deliveryDate == nil || len(projectFlockKandang.Chickins) == 0 {
return 0 return 0, 0
} }
earliestChickinDate := projectFlockKandang.Chickins[0].ChickInDate earliestChickinDate := projectFlockKandang.Chickins[0].ChickInDate
@@ -136,7 +138,16 @@ func calculateAgeFromChickin(projectFlockKandang *entity.ProjectFlockKandang, de
} }
} }
ageInDays := int(deliveryDate.Sub(earliestChickinDate).Hours() / 24) diff := deliveryDate.Sub(earliestChickinDate)
ageInWeeks := ageInDays / 7 ageInDays := int(diff.Hours() / 24)
return ageInWeeks
var ageInWeeks int
if ageInDays <= 0 {
ageInWeeks = 0
} else {
ageInWeeks = ((ageInDays - 1) / 7) + 1
}
return ageInDays, ageInWeeks
} }
@@ -83,7 +83,7 @@ func (r *ConstantRepositoryImpl) GetConstants() map[string]interface{} {
"KANDANG", "KANDANG",
}, },
"stock_log": map[string][]string{ "stock_log": map[string][]string{
"log_types": []string{"TRANSFER", "ADJUSTMENT"}, "log_types": []string{"TRANSFER", "ADJUSTMENT", "MARKETING", "CHICKIN", "PURCHASE", "RECORDING"},
"transaction_types": []string{"INCREASE", "DECREASE"}, "transaction_types": []string{"INCREASE", "DECREASE"},
}, },
"supplier_categories": []string{ "supplier_categories": []string{
@@ -70,7 +70,8 @@ func (r *ExpenseRealizationRepositoryImpl) GetClosingOverhead(ctx context.Contex
Joins("JOIN expenses ON expenses.id = expense_nonstocks.expense_id"). Joins("JOIN expenses ON expenses.id = expense_nonstocks.expense_id").
Joins("LEFT JOIN project_flock_kandangs ON project_flock_kandangs.id = expense_nonstocks.project_flock_kandang_id"). Joins("LEFT JOIN project_flock_kandangs ON project_flock_kandangs.id = expense_nonstocks.project_flock_kandang_id").
Joins("LEFT JOIN kandangs ON kandangs.id = expense_nonstocks.kandang_id"). Joins("LEFT JOIN kandangs ON kandangs.id = expense_nonstocks.kandang_id").
Where("expenses.realization_date IS NOT NULL") Where("expenses.realization_date IS NOT NULL").
Where("expenses.category = ?", "BOP")
if projectFlockKandangID != nil { if projectFlockKandangID != nil {
db = db.Where(`( db = db.Where(`(
@@ -26,7 +26,10 @@ func NewMarketingProductRepository(db *gorm.DB) MarketingProductRepository {
func (r *MarketingProductRepositoryImpl) GetByMarketingID(ctx context.Context, marketingID uint) ([]entity.MarketingProduct, error) { func (r *MarketingProductRepositoryImpl) GetByMarketingID(ctx context.Context, marketingID uint) ([]entity.MarketingProduct, error) {
var products []entity.MarketingProduct var products []entity.MarketingProduct
if err := r.DB().WithContext(ctx).Where("marketing_id = ?", marketingID).Find(&products).Error; err != nil { if err := r.DB().WithContext(ctx).
Preload("ProductWarehouse.Product.Flags").
Where("marketing_id = ?", marketingID).
Find(&products).Error; err != nil {
return nil, err return nil, err
} }
if len(products) == 0 { if len(products) == 0 {
@@ -247,9 +247,27 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Delivery
itemDeliveryDate = &parsedDate itemDeliveryDate = &parsedDate
} }
// Hitung total_weight dan total_price otomatis // Cek apakah product punya flag PAKAN atau OVK
isPakanOrOVK := false
if foundMarketingProduct.ProductWarehouse.Product.Id != 0 && len(foundMarketingProduct.ProductWarehouse.Product.Flags) > 0 {
for _, flag := range foundMarketingProduct.ProductWarehouse.Product.Flags {
if flag.Name == string(utils.FlagPakan) || flag.Name == string(utils.FlagOVK) {
isPakanOrOVK = true
break
}
}
}
// Hitung total_weight dan total_price berdasarkan flag
totalWeight := requestedProduct.Qty * requestedProduct.AvgWeight totalWeight := requestedProduct.Qty * requestedProduct.AvgWeight
totalPrice := requestedProduct.UnitPrice * totalWeight var totalPrice float64
if isPakanOrOVK {
// PAKAN atau OVK: qty × unit_price
totalPrice = requestedProduct.Qty * requestedProduct.UnitPrice
} else {
// Produk lain: total_weight × unit_price
totalPrice = totalWeight * requestedProduct.UnitPrice
}
deliveryProduct.ProductWarehouseId = foundMarketingProduct.ProductWarehouseId deliveryProduct.ProductWarehouseId = foundMarketingProduct.ProductWarehouseId
deliveryProduct.UnitPrice = requestedProduct.UnitPrice deliveryProduct.UnitPrice = requestedProduct.UnitPrice
@@ -361,9 +379,27 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO
oldRequestedQty := deliveryProduct.UsageQty + deliveryProduct.PendingQty oldRequestedQty := deliveryProduct.UsageQty + deliveryProduct.PendingQty
// Hitung total_weight dan total_price otomatis // Cek apakah product punya flag PAKAN atau OVK
isPakanOrOVK := false
if foundMarketingProduct.ProductWarehouse.Product.Id != 0 && len(foundMarketingProduct.ProductWarehouse.Product.Flags) > 0 {
for _, flag := range foundMarketingProduct.ProductWarehouse.Product.Flags {
if flag.Name == string(utils.FlagPakan) || flag.Name == string(utils.FlagOVK) {
isPakanOrOVK = true
break
}
}
}
// Hitung total_weight dan total_price berdasarkan flag
totalWeight := requestedProduct.Qty * requestedProduct.AvgWeight totalWeight := requestedProduct.Qty * requestedProduct.AvgWeight
totalPrice := requestedProduct.UnitPrice * totalWeight var totalPrice float64
if isPakanOrOVK {
// PAKAN atau OVK: qty × unit_price
totalPrice = requestedProduct.Qty * requestedProduct.UnitPrice
} else {
// Produk lain: total_weight × unit_price
totalPrice = totalWeight * requestedProduct.UnitPrice
}
deliveryProduct.ProductWarehouseId = foundMarketingProduct.ProductWarehouseId deliveryProduct.ProductWarehouseId = foundMarketingProduct.ProductWarehouseId
deliveryProduct.UnitPrice = requestedProduct.UnitPrice deliveryProduct.UnitPrice = requestedProduct.UnitPrice
@@ -435,7 +471,13 @@ func (s deliveryOrdersService) consumeDeliveryStock(ctx context.Context, tx *gor
} }
if pw == nil || pw.Quantity < requestedQty { if pw == nil || pw.Quantity < requestedQty {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Insufficient stock. Available: %.2f, Requested: %.2f", func() float64 { if pw != nil { return pw.Quantity } else { return 0 } }(), requestedQty)) return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Insufficient stock. Available: %.2f, Requested: %.2f", func() float64 {
if pw != nil {
return pw.Quantity
} else {
return 0
}
}(), requestedQty))
} }
if err := deliveryProductRepo.UpdateFifoFields(ctx, deliveryProduct.Id, requestedQty, 0); err != nil { if err := deliveryProductRepo.UpdateFifoFields(ctx, deliveryProduct.Id, requestedQty, 0); err != nil {
@@ -292,9 +292,35 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u
for _, rp := range req.MarketingProducts { for _, rp := range req.MarketingProducts {
if old, ok := oldByPW[rp.ProductWarehouseId]; ok { if old, ok := oldByPW[rp.ProductWarehouseId]; ok {
// Hitung total_weight dan total_price otomatis // Get product untuk cek flag PAKAN atau OVK
productWarehouse, err := s.ProductWarehouseRepo.GetByID(c.Context(), rp.ProductWarehouseId, func(db *gorm.DB) *gorm.DB {
return db.Preload("Product.Flags")
})
if err != nil {
return err
}
// Cek apakah product punya flag PAKAN atau OVK
isPakanOrOVK := false
if productWarehouse.Product.Id != 0 && len(productWarehouse.Product.Flags) > 0 {
for _, flag := range productWarehouse.Product.Flags {
if flag.Name == string(utils.FlagPakan) || flag.Name == string(utils.FlagOVK) {
isPakanOrOVK = true
break
}
}
}
// Hitung total_weight dan total_price berdasarkan flag
totalWeight := rp.Qty * rp.AvgWeight totalWeight := rp.Qty * rp.AvgWeight
totalPrice := rp.UnitPrice * totalWeight var totalPrice float64
if isPakanOrOVK {
// PAKAN atau OVK: qty × unit_price
totalPrice = rp.Qty * rp.UnitPrice
} else {
// Produk lain: total_weight × unit_price
totalPrice = totalWeight * rp.UnitPrice
}
updateBody := map[string]any{ updateBody := map[string]any{
"product_warehouse_id": rp.ProductWarehouseId, "product_warehouse_id": rp.ProductWarehouseId,
@@ -592,9 +618,34 @@ func (s salesOrdersService) Approval(c *fiber.Ctx, req *validation.Approve) ([]e
func (s *salesOrdersService) createMarketingProductWithDelivery(ctx context.Context, marketingId uint, rp validation.CreateMarketingProduct, marketingProductRepo repository.MarketingProductRepository, invDeliveryRepo repository.MarketingDeliveryProductRepository) error { func (s *salesOrdersService) createMarketingProductWithDelivery(ctx context.Context, marketingId uint, rp validation.CreateMarketingProduct, marketingProductRepo repository.MarketingProductRepository, invDeliveryRepo repository.MarketingDeliveryProductRepository) error {
// Hitung total_weight dan total_price otomatis // Get product untuk cek flag PAKAN atau OVK
productWarehouse, err := s.ProductWarehouseRepo.GetByID(ctx, rp.ProductWarehouseId, func(db *gorm.DB) *gorm.DB {
return db.Preload("Product.Flags")
})
if err != nil {
return err
}
// Cek apakah product punya flag PAKAN atau OVK
isPakanOrOVK := false
if productWarehouse.Product.Id != 0 && len(productWarehouse.Product.Flags) > 0 {
for _, flag := range productWarehouse.Product.Flags {
if flag.Name == string(utils.FlagPakan) || flag.Name == string(utils.FlagOVK) {
isPakanOrOVK = true
break
}
}
}
totalWeight := rp.Qty * rp.AvgWeight totalWeight := rp.Qty * rp.AvgWeight
totalPrice := rp.UnitPrice * totalWeight var totalPrice float64
if isPakanOrOVK {
// PAKAN atau OVK: qty × unit_price
totalPrice = rp.Qty * rp.UnitPrice
} else {
// Produk lain: total_weight × unit_price
totalPrice = totalWeight * rp.UnitPrice
}
marketingProduct := &entity.MarketingProduct{ marketingProduct := &entity.MarketingProduct{
MarketingId: marketingId, MarketingId: marketingId,
@@ -16,6 +16,7 @@ import (
rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
rRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" rRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories"
sRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services" sRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services"
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"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
@@ -31,6 +32,7 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
projectFlockPopulationRepo := rProjectFlock.NewProjectFlockPopulationRepository(db) projectFlockPopulationRepo := rProjectFlock.NewProjectFlockPopulationRepository(db)
productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db)
stockAllocationRepo := commonRepo.NewStockAllocationRepository(db) stockAllocationRepo := commonRepo.NewStockAllocationRepository(db)
stockLogRepo := rStockLogs.NewStockLogRepository(db)
productionStandardRepo := rProductionStandard.NewProductionStandardRepository(db) productionStandardRepo := rProductionStandard.NewProductionStandardRepository(db)
productionStandardDetailRepo := rProductionStandard.NewProductionStandardDetailRepository(db) productionStandardDetailRepo := rProductionStandard.NewProductionStandardDetailRepository(db)
standardGrowthDetailRepo := rProductionStandard.NewStandardGrowthDetailRepository(db) standardGrowthDetailRepo := rProductionStandard.NewStandardGrowthDetailRepository(db)
@@ -113,6 +115,7 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
approvalRepo, approvalRepo,
approvalService, approvalService,
fifoService, fifoService,
stockLogRepo,
productionStandardService, productionStandardService,
validate, validate,
) )
@@ -13,6 +13,7 @@ import (
sProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/services" sProductionStandard "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/services"
rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories"
rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations"
"gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils"
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
@@ -39,8 +40,8 @@ type RecordingService interface {
} }
type RecordingFIFOIntegrationService interface { type RecordingFIFOIntegrationService interface {
ConsumeRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock) error ConsumeRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock, note string, actorID uint) error
ReleaseRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock) error ReleaseRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock, note string, actorID uint) error
} }
var recordingStockUsableKey = fifo.UsableKeyRecordingStock var recordingStockUsableKey = fifo.UsableKeyRecordingStock
@@ -57,6 +58,7 @@ type recordingService struct {
ApprovalSvc commonSvc.ApprovalService ApprovalSvc commonSvc.ApprovalService
ProductionStandardSvc sProductionStandard.ProductionStandardService ProductionStandardSvc sProductionStandard.ProductionStandardService
FifoSvc commonSvc.FifoService FifoSvc commonSvc.FifoService
StockLogRepo rStockLogs.StockLogRepository
} }
func NewRecordingService( func NewRecordingService(
@@ -67,6 +69,7 @@ func NewRecordingService(
approvalRepo commonRepo.ApprovalRepository, approvalRepo commonRepo.ApprovalRepository,
approvalSvc commonSvc.ApprovalService, approvalSvc commonSvc.ApprovalService,
fifoSvc commonSvc.FifoService, fifoSvc commonSvc.FifoService,
stockLogRepo rStockLogs.StockLogRepository,
productionStandardSvc sProductionStandard.ProductionStandardService, productionStandardSvc sProductionStandard.ProductionStandardService,
validate *validator.Validate, validate *validator.Validate,
) RecordingService { ) RecordingService {
@@ -81,6 +84,7 @@ func NewRecordingService(
ApprovalSvc: approvalSvc, ApprovalSvc: approvalSvc,
ProductionStandardSvc: productionStandardSvc, ProductionStandardSvc: productionStandardSvc,
FifoSvc: fifoSvc, FifoSvc: fifoSvc,
StockLogRepo: stockLogRepo,
} }
} }
@@ -88,12 +92,14 @@ func NewRecordingFIFOIntegrationService(
repo repository.RecordingRepository, repo repository.RecordingRepository,
productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository,
fifoSvc commonSvc.FifoService, fifoSvc commonSvc.FifoService,
stockLogRepo rStockLogs.StockLogRepository,
) RecordingFIFOIntegrationService { ) RecordingFIFOIntegrationService {
return &recordingService{ return &recordingService{
Log: utils.Log, Log: utils.Log,
Repository: repo, Repository: repo,
ProductWarehouseRepo: productWarehouseRepo, ProductWarehouseRepo: productWarehouseRepo,
FifoSvc: fifoSvc, FifoSvc: fifoSvc,
StockLogRepo: stockLogRepo,
} }
} }
@@ -159,14 +165,13 @@ func (s recordingService) GetNextDay(c *fiber.Ctx, projectFlockKandangId uint) (
return 0, fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is required") return 0, fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is required")
} }
db := s.Repository.DB().WithContext(c.Context()) day, err := s.computeRecordingDay(c.Context(), projectFlockKandangId, time.Now().UTC())
next, err := s.Repository.GenerateNextDay(db, projectFlockKandangId)
if err != nil { if err != nil {
s.Log.Errorf("Failed to compute next recording day for project_flock_kandang_id=%d: %+v", projectFlockKandangId, err) s.Log.Errorf("Failed to compute recording day for project_flock_kandang_id=%d: %+v", projectFlockKandangId, err)
return 0, err return 0, err
} }
return next, nil return day, nil
} }
func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Recording, error) { func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Recording, error) {
@@ -208,6 +213,11 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
} }
} }
day, err := s.computeRecordingDay(ctx, pfk.Id, recordTime)
if err != nil {
return nil, err
}
if !isLaying && len(req.Eggs) > 0 { if !isLaying && len(req.Eggs) > 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "Egg details permitted only for laying project flocks") return nil, fiber.NewError(fiber.StatusBadRequest, "Egg details permitted only for laying project flocks")
} }
@@ -221,13 +231,8 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
} }
var createdRecording entity.Recording var createdRecording entity.Recording
transactionErr := s.Repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { transactionErr := s.Repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
nextDay, err := s.Repository.GenerateNextDay(tx, req.ProjectFlockKandangId)
if err != nil {
s.Log.Errorf("Failed to determine recording day: %+v", err)
return err
}
if s.ProductionStandardSvc != nil { if s.ProductionStandardSvc != nil {
if err := s.ProductionStandardSvc.EnsureWeekAvailable(ctx, pfk.ProjectFlock.ProductionStandardId, category, nextDay); err != nil { if err := s.ProductionStandardSvc.EnsureWeekAvailable(ctx, pfk.ProjectFlock.ProductionStandardId, category, day); err != nil {
return err return err
} }
} }
@@ -241,7 +246,6 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
return fiber.NewError(fiber.StatusBadRequest, "Recording for this project flock today already exists") return fiber.NewError(fiber.StatusBadRequest, "Recording for this project flock today already exists")
} }
day := nextDay
createdRecording = entity.Recording{ createdRecording = entity.Recording{
ProjectFlockKandangId: req.ProjectFlockKandangId, ProjectFlockKandangId: req.ProjectFlockKandangId,
RecordDatetime: recordTime, RecordDatetime: recordTime,
@@ -274,7 +278,8 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
} }
applyStockDesiredQuantities(mappedStocks, stockDesired, s.FifoSvc != nil) applyStockDesiredQuantities(mappedStocks, stockDesired, s.FifoSvc != nil)
if err := s.consumeRecordingStocks(ctx, tx, mappedStocks); err != nil { note := fmt.Sprintf("Recording-Create#%d", createdRecording.Id)
if err := s.consumeRecordingStocks(ctx, tx, mappedStocks, note, actorID); err != nil {
return err return err
} }
@@ -293,7 +298,8 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
return err return err
} }
if s.FifoSvc != nil { if s.FifoSvc != nil {
if err := s.consumeRecordingDepletions(ctx, tx, mappedDepletions); err != nil { note := fmt.Sprintf("Recording-Create#%d", createdRecording.Id)
if err := s.consumeRecordingDepletions(ctx, tx, mappedDepletions, note, actorID); err != nil {
return err return err
} }
} }
@@ -304,7 +310,8 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
return err return err
} }
if s.FifoSvc != nil { if s.FifoSvc != nil {
if err := s.replenishRecordingEggs(ctx, tx, mappedEggs); err != nil { note := fmt.Sprintf("Recording-Create#%d", createdRecording.Id)
if err := s.replenishRecordingEggs(ctx, tx, mappedEggs, note, actorID); err != nil {
return err return err
} }
} }
@@ -346,6 +353,10 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
} }
ctx := c.Context() ctx := c.Context()
actorID, err := m.ActorIDFromContext(c)
if err != nil {
return nil, err
}
var recordingEntity *entity.Recording var recordingEntity *entity.Recording
var updatedRecording *entity.Recording var updatedRecording *entity.Recording
@@ -431,14 +442,16 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
} }
if hasStockChanges { if hasStockChanges {
if err := s.syncRecordingStocks(ctx, tx, recordingEntity.Id, existingStocks, req.Stocks); err != nil { note := fmt.Sprintf("Recording-Edit#%d", recordingEntity.Id)
if err := s.syncRecordingStocks(ctx, tx, recordingEntity.Id, existingStocks, req.Stocks, note, actorID); err != nil {
return err return err
} }
} }
if hasDepletionChanges { if hasDepletionChanges {
if s.FifoSvc != nil { if s.FifoSvc != nil {
if err := s.releaseRecordingDepletions(ctx, tx, existingDepletions); err != nil { note := fmt.Sprintf("Recording-Edit#%d", recordingEntity.Id)
if err := s.releaseRecordingDepletions(ctx, tx, existingDepletions, note, actorID); err != nil {
return err return err
} }
} }
@@ -464,7 +477,8 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
} }
if s.FifoSvc != nil { if s.FifoSvc != nil {
if err := s.consumeRecordingDepletions(ctx, tx, mappedDepletions); err != nil { note := fmt.Sprintf("Recording-Edit#%d", recordingEntity.Id)
if err := s.consumeRecordingDepletions(ctx, tx, mappedDepletions, note, actorID); err != nil {
return err return err
} }
} }
@@ -480,6 +494,28 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
if err := ensureRecordingEggsUnused(existingEggs); err != nil { if err := ensureRecordingEggsUnused(existingEggs); err != nil {
return err return err
} }
if s.StockLogRepo != nil {
note := fmt.Sprintf("Recording-Edit#%d", recordingEntity.Id)
logs := make([]*entity.StockLog, 0, len(existingEggs))
for _, egg := range existingEggs {
if egg.ProductWarehouseId == 0 || egg.Qty <= 0 {
continue
}
logs = append(logs, &entity.StockLog{
ProductWarehouseId: egg.ProductWarehouseId,
CreatedBy: actorID,
Decrease: float64(egg.Qty),
LoggableType: string(utils.StockLogTypeRecording),
LoggableId: recordingEntity.Id,
Notes: note,
})
}
if len(logs) > 0 {
if err := s.StockLogRepo.WithTx(tx).CreateMany(ctx, logs, nil); err != nil {
return err
}
}
}
if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, nil, existingEggs, nil)); err != nil { if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, nil, existingEggs, nil)); err != nil {
s.Log.Errorf("Failed to adjust product warehouses for eggs: %+v", err) s.Log.Errorf("Failed to adjust product warehouses for eggs: %+v", err)
return err return err
@@ -498,7 +534,8 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
} }
if s.FifoSvc != nil { if s.FifoSvc != nil {
if err := s.replenishRecordingEggs(ctx, tx, mappedEggs); err != nil { note := fmt.Sprintf("Recording-Edit#%d", recordingEntity.Id)
if err := s.replenishRecordingEggs(ctx, tx, mappedEggs, note, actorID); err != nil {
return err return err
} }
} else { } else {
@@ -675,7 +712,7 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error {
return err return err
} }
if s.FifoSvc != nil { if s.FifoSvc != nil {
if err := s.releaseRecordingDepletions(ctx, tx, oldDepletions); err != nil { if err := s.releaseRecordingDepletions(ctx, tx, oldDepletions, "", 0); err != nil {
return err return err
} }
} }
@@ -697,7 +734,7 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error {
return err return err
} }
if err := s.releaseRecordingStocks(ctx, tx, oldStocks); err != nil { if err := s.releaseRecordingStocks(ctx, tx, oldStocks, "", 0); err != nil {
return err return err
} }
@@ -756,10 +793,19 @@ func (s *recordingService) ensureProductWarehousesExist(c *fiber.Ctx, stocks []v
return nil return nil
} }
func (s *recordingService) consumeRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock) error { func (s *recordingService) consumeRecordingStocks(
ctx context.Context,
tx *gorm.DB,
stocks []entity.RecordingStock,
note string,
actorID uint,
) error {
if len(stocks) == 0 || s.FifoSvc == nil { if len(stocks) == 0 || s.FifoSvc == nil {
return nil return nil
} }
if strings.TrimSpace(note) != "" && s.StockLogRepo == nil {
return errors.New("stock log repository is not available")
}
for _, stock := range stocks { for _, stock := range stocks {
if stock.Id == 0 { if stock.Id == 0 {
@@ -792,15 +838,42 @@ func (s *recordingService) consumeRecordingStocks(ctx context.Context, tx *gorm.
if err := s.Repository.UpdateStockUsage(tx, stock.Id, result.UsageQuantity, result.PendingQuantity); err != nil { if err := s.Repository.UpdateStockUsage(tx, stock.Id, result.UsageQuantity, result.PendingQuantity); err != nil {
return err return err
} }
logDecrease := result.UsageQuantity
if result.PendingQuantity > 0 {
logDecrease += result.PendingQuantity
}
if logDecrease > 0 && strings.TrimSpace(note) != "" && actorID != 0 {
log := &entity.StockLog{
ProductWarehouseId: stock.ProductWarehouseId,
CreatedBy: actorID,
Decrease: logDecrease,
LoggableType: string(utils.StockLogTypeRecording),
LoggableId: stock.RecordingId,
Notes: note,
}
if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil {
return err
}
}
} }
return nil return nil
} }
func (s *recordingService) consumeRecordingDepletions(ctx context.Context, tx *gorm.DB, depletions []entity.RecordingDepletion) error { func (s *recordingService) consumeRecordingDepletions(
ctx context.Context,
tx *gorm.DB,
depletions []entity.RecordingDepletion,
note string,
actorID uint,
) error {
if len(depletions) == 0 || s.FifoSvc == nil { if len(depletions) == 0 || s.FifoSvc == nil {
return nil return nil
} }
if strings.TrimSpace(note) != "" && s.StockLogRepo == nil {
return errors.New("stock log repository is not available")
}
for _, depletion := range depletions { for _, depletion := range depletions {
if depletion.Id == 0 { if depletion.Id == 0 {
@@ -832,19 +905,67 @@ func (s *recordingService) consumeRecordingDepletions(ctx context.Context, tx *g
if err := s.Repository.UpdateDepletionPending(tx, depletion.Id, result.PendingQuantity); err != nil { if err := s.Repository.UpdateDepletionPending(tx, depletion.Id, result.PendingQuantity); err != nil {
return err return err
} }
logDecrease := result.UsageQuantity
if result.PendingQuantity > 0 {
logDecrease += result.PendingQuantity
}
if logDecrease > 0 && strings.TrimSpace(note) != "" && actorID != 0 {
log := &entity.StockLog{
ProductWarehouseId: sourceWarehouseID,
CreatedBy: actorID,
Decrease: logDecrease,
LoggableType: string(utils.StockLogTypeRecording),
LoggableId: depletion.RecordingId,
Notes: note,
}
if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil {
return err
}
}
destDelta := depletion.Qty + depletion.PendingQty
if depletion.ProductWarehouseId != 0 && destDelta > 0 && strings.TrimSpace(note) != "" && actorID != 0 {
log := &entity.StockLog{
ProductWarehouseId: depletion.ProductWarehouseId,
CreatedBy: actorID,
Increase: destDelta,
LoggableType: string(utils.StockLogTypeRecording),
LoggableId: depletion.RecordingId,
Notes: note,
}
if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil {
return err
}
}
} }
return nil return nil
} }
func (s *recordingService) ConsumeRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock) error { func (s *recordingService) ConsumeRecordingStocks(
return s.consumeRecordingStocks(ctx, tx, stocks) ctx context.Context,
tx *gorm.DB,
stocks []entity.RecordingStock,
note string,
actorID uint,
) error {
return s.consumeRecordingStocks(ctx, tx, stocks, note, actorID)
} }
func (s *recordingService) releaseRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock) error { func (s *recordingService) releaseRecordingStocks(
ctx context.Context,
tx *gorm.DB,
stocks []entity.RecordingStock,
note string,
actorID uint,
) error {
if len(stocks) == 0 || s.FifoSvc == nil { if len(stocks) == 0 || s.FifoSvc == nil {
return nil return nil
} }
if strings.TrimSpace(note) != "" && s.StockLogRepo == nil {
return errors.New("stock log repository is not available")
}
for _, stock := range stocks { for _, stock := range stocks {
if stock.Id == 0 { if stock.Id == 0 {
@@ -863,15 +984,38 @@ func (s *recordingService) releaseRecordingStocks(ctx context.Context, tx *gorm.
if err := s.Repository.UpdateStockUsage(tx, stock.Id, 0, 0); err != nil { if err := s.Repository.UpdateStockUsage(tx, stock.Id, 0, 0); err != nil {
return err return err
} }
if stock.UsageQty != nil && *stock.UsageQty > 0 && strings.TrimSpace(note) != "" && actorID != 0 {
log := &entity.StockLog{
ProductWarehouseId: stock.ProductWarehouseId,
CreatedBy: actorID,
Increase: *stock.UsageQty,
LoggableType: string(utils.StockLogTypeRecording),
LoggableId: stock.RecordingId,
Notes: note,
}
if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil {
return err
}
}
} }
return nil return nil
} }
func (s *recordingService) releaseRecordingDepletions(ctx context.Context, tx *gorm.DB, depletions []entity.RecordingDepletion) error { func (s *recordingService) releaseRecordingDepletions(
ctx context.Context,
tx *gorm.DB,
depletions []entity.RecordingDepletion,
note string,
actorID uint,
) error {
if len(depletions) == 0 || s.FifoSvc == nil { if len(depletions) == 0 || s.FifoSvc == nil {
return nil return nil
} }
if strings.TrimSpace(note) != "" && s.StockLogRepo == nil {
return errors.New("stock log repository is not available")
}
for _, depletion := range depletions { for _, depletion := range depletions {
if depletion.Id == 0 { if depletion.Id == 0 {
@@ -898,13 +1042,52 @@ func (s *recordingService) releaseRecordingDepletions(ctx context.Context, tx *g
if err := s.Repository.UpdateDepletionPending(tx, depletion.Id, 0); err != nil { if err := s.Repository.UpdateDepletionPending(tx, depletion.Id, 0); err != nil {
return err return err
} }
logIncrease := depletion.Qty
if depletion.PendingQty > 0 {
logIncrease += depletion.PendingQty
}
if logIncrease > 0 && strings.TrimSpace(note) != "" && actorID != 0 {
log := &entity.StockLog{
ProductWarehouseId: sourceWarehouseID,
CreatedBy: actorID,
Increase: logIncrease,
LoggableType: string(utils.StockLogTypeRecording),
LoggableId: depletion.RecordingId,
Notes: note,
}
if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil {
return err
}
}
destDelta := depletion.Qty + depletion.PendingQty
if depletion.ProductWarehouseId != 0 && destDelta > 0 && strings.TrimSpace(note) != "" && actorID != 0 {
log := &entity.StockLog{
ProductWarehouseId: depletion.ProductWarehouseId,
CreatedBy: actorID,
Decrease: destDelta,
LoggableType: string(utils.StockLogTypeRecording),
LoggableId: depletion.RecordingId,
Notes: note,
}
if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil {
return err
}
}
} }
return nil return nil
} }
func (s *recordingService) ReleaseRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock) error { func (s *recordingService) ReleaseRecordingStocks(
return s.releaseRecordingStocks(ctx, tx, stocks) ctx context.Context,
tx *gorm.DB,
stocks []entity.RecordingStock,
note string,
actorID uint,
) error {
return s.releaseRecordingStocks(ctx, tx, stocks, note, actorID)
} }
func (s *recordingService) resolvePopulationWarehouseID(ctx context.Context, projectFlockKandangID uint) (uint, error) { func (s *recordingService) resolvePopulationWarehouseID(ctx context.Context, projectFlockKandangID uint) (uint, error) {
@@ -929,6 +1112,40 @@ func (s *recordingService) resolvePopulationWarehouseID(ctx context.Context, pro
return 0, fiber.NewError(fiber.StatusBadRequest, "Source product warehouse populasi tidak ditemukan") return 0, fiber.NewError(fiber.StatusBadRequest, "Source product warehouse populasi tidak ditemukan")
} }
func (s *recordingService) computeRecordingDay(ctx context.Context, projectFlockKandangID uint, recordTime time.Time) (int, error) {
if projectFlockKandangID == 0 {
return 0, fiber.NewError(fiber.StatusBadRequest, "Project flock kandang tidak valid")
}
populations, err := s.ProjectFlockPopulationRepo.GetByProjectFlockKandangID(ctx, projectFlockKandangID)
if err != nil {
s.Log.Errorf("Failed to fetch populations for project_flock_kandang_id=%d: %+v", projectFlockKandangID, err)
return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data populasi")
}
var chickinDate time.Time
for _, pop := range populations {
if pop.ProjectChickin == nil || pop.ProjectChickin.ChickInDate.IsZero() {
continue
}
if chickinDate.IsZero() || pop.ProjectChickin.ChickInDate.Before(chickinDate) {
chickinDate = pop.ProjectChickin.ChickInDate
}
}
if chickinDate.IsZero() {
return 0, fiber.NewError(fiber.StatusBadRequest, "Tanggal chick in tidak ditemukan")
}
chickinDay := time.Date(chickinDate.Year(), chickinDate.Month(), chickinDate.Day(), 0, 0, 0, 0, time.UTC)
recordDay := time.Date(recordTime.Year(), recordTime.Month(), recordTime.Day(), 0, 0, 0, 0, time.UTC)
diff := int(recordDay.Sub(chickinDay).Hours() / 24)
if diff < 0 {
return 0, fiber.NewError(fiber.StatusBadRequest, "Record date tidak boleh sebelum tanggal chick in")
}
return diff + 1, nil
}
func buildWarehouseDeltas( func buildWarehouseDeltas(
oldDepletions, newDepletions []entity.RecordingDepletion, oldDepletions, newDepletions []entity.RecordingDepletion,
oldEggs, newEggs []entity.RecordingEgg, oldEggs, newEggs []entity.RecordingEgg,
@@ -963,27 +1180,48 @@ func (s *recordingService) adjustProductWarehouseQuantities(ctx context.Context,
return s.ProductWarehouseRepo.AdjustQuantities(ctx, deltas, func(*gorm.DB) *gorm.DB { return tx }) return s.ProductWarehouseRepo.AdjustQuantities(ctx, deltas, func(*gorm.DB) *gorm.DB { return tx })
} }
func (s *recordingService) replenishRecordingEggs(ctx context.Context, tx *gorm.DB, eggs []entity.RecordingEgg) error { func (s *recordingService) replenishRecordingEggs(
ctx context.Context,
tx *gorm.DB,
eggs []entity.RecordingEgg,
note string,
actorID uint,
) error {
if len(eggs) == 0 || s.FifoSvc == nil { if len(eggs) == 0 || s.FifoSvc == nil {
return nil return nil
} }
if strings.TrimSpace(note) != "" && s.StockLogRepo == nil {
return errors.New("stock log repository is not available")
}
for _, egg := range eggs { for _, egg := range eggs {
if egg.Id == 0 || egg.ProductWarehouseId == 0 || egg.Qty <= 0 { if egg.Id == 0 || egg.ProductWarehouseId == 0 || egg.Qty <= 0 {
continue continue
} }
note := fmt.Sprintf("Recording egg #%d", egg.Id)
if _, err := s.FifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{ if _, err := s.FifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{
StockableKey: fifo.StockableKeyRecordingEgg, StockableKey: fifo.StockableKeyRecordingEgg,
StockableID: egg.Id, StockableID: egg.Id,
ProductWarehouseID: egg.ProductWarehouseId, ProductWarehouseID: egg.ProductWarehouseId,
Quantity: float64(egg.Qty), Quantity: float64(egg.Qty),
Note: &note,
Tx: tx, Tx: tx,
}); err != nil { }); err != nil {
s.Log.Errorf("Failed to replenish FIFO stock for recording egg %d: %+v", egg.Id, err) s.Log.Errorf("Failed to replenish FIFO stock for recording egg %d: %+v", egg.Id, err)
return err return err
} }
if strings.TrimSpace(note) != "" && actorID != 0 {
log := &entity.StockLog{
ProductWarehouseId: egg.ProductWarehouseId,
CreatedBy: actorID,
Increase: float64(egg.Qty),
LoggableType: string(utils.StockLogTypeRecording),
LoggableId: egg.RecordingId,
Notes: note,
}
if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil {
return err
}
}
} }
return nil return nil
@@ -1034,6 +1272,8 @@ func (s *recordingService) syncRecordingStocks(
recordingID uint, recordingID uint,
existing []entity.RecordingStock, existing []entity.RecordingStock,
incoming []validation.Stock, incoming []validation.Stock,
note string,
actorID uint,
) error { ) error {
if s.FifoSvc == nil { if s.FifoSvc == nil {
if err := s.Repository.DeleteStocks(tx, recordingID); err != nil { if err := s.Repository.DeleteStocks(tx, recordingID); err != nil {
@@ -1080,7 +1320,7 @@ func (s *recordingService) syncRecordingStocks(
leftovers = append(leftovers, list...) leftovers = append(leftovers, list...)
} }
if len(leftovers) > 0 { if len(leftovers) > 0 {
if err := s.releaseRecordingStocks(ctx, tx, leftovers); err != nil { if err := s.releaseRecordingStocks(ctx, tx, leftovers, note, actorID); err != nil {
return err return err
} }
ids := make([]uint, 0, len(leftovers)) ids := make([]uint, 0, len(leftovers))
@@ -1099,7 +1339,7 @@ func (s *recordingService) syncRecordingStocks(
if len(stocksToConsume) == 0 { if len(stocksToConsume) == 0 {
return nil return nil
} }
return s.consumeRecordingStocks(ctx, tx, stocksToConsume) return s.consumeRecordingStocks(ctx, tx, stocksToConsume, note, actorID)
} }
type eggTotals struct { type eggTotals struct {
@@ -18,6 +18,7 @@ import (
rSupplier "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/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" 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" projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories"
rPurchase "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories" rPurchase "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/validations" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/validations"
"gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils"
@@ -830,9 +831,16 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
receivingAction = entity.ApprovalActionUpdated receivingAction = entity.ApprovalActionUpdated
} }
} }
noteSuffix := "receive"
if receivingAction == entity.ApprovalActionUpdated {
noteSuffix = "edit-receive"
}
receiveNote := fmt.Sprintf("%s#%s", strings.TrimSpace(*purchase.PoNumber), noteSuffix)
transactionErr := s.PurchaseRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { transactionErr := s.PurchaseRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
repoTx := rPurchase.NewPurchaseRepository(tx) repoTx := rPurchase.NewPurchaseRepository(tx)
pwRepoTx := rProductWarehouse.NewProductWarehouseRepository(tx) pwRepoTx := rProductWarehouse.NewProductWarehouseRepository(tx)
stockLogRepoTx := rStockLogs.NewStockLogRepository(tx)
deltas := make(map[uint]float64) deltas := make(map[uint]float64)
affected := make(map[uint]struct{}) affected := make(map[uint]struct{})
@@ -849,6 +857,11 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
pwID uint pwID uint
qty float64 qty float64
}, 0, len(prepared)) }, 0, len(prepared))
logEntries := make([]struct {
itemID uint
pwID uint
delta float64
}, 0, len(prepared))
for _, prep := range prepared { for _, prep := range prepared {
item := prep.item item := prep.item
@@ -869,6 +882,13 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
newPWID = &pwID newPWID = &pwID
deltaQty := prep.receivedQty - item.TotalQty deltaQty := prep.receivedQty - item.TotalQty
if newPWID != nil && deltaQty != 0 {
logEntries = append(logEntries, struct {
itemID uint
pwID uint
delta float64
}{itemID: item.Id, pwID: *newPWID, delta: deltaQty})
}
switch { switch {
case deltaQty > 0 && newPWID != nil: case deltaQty > 0 && newPWID != nil:
if s.FifoSvc != nil { if s.FifoSvc != nil {
@@ -993,6 +1013,33 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
} }
} }
if len(logEntries) > 0 {
logs := make([]*entity.StockLog, 0, len(logEntries))
for _, entry := range logEntries {
if entry.pwID == 0 || entry.delta == 0 {
continue
}
log := &entity.StockLog{
ProductWarehouseId: entry.pwID,
CreatedBy: actorID,
LoggableType: string(utils.StockLogTypePurchase),
LoggableId: purchase.Id,
Notes: receiveNote,
}
if entry.delta > 0 {
log.Increase = entry.delta
} else {
log.Decrease = -entry.delta
}
logs = append(logs, log)
}
if len(logs) > 0 {
if err := stockLogRepoTx.CreateMany(c.Context(), logs, nil); err != nil {
return err
}
}
}
if len(affected) > 0 { if len(affected) > 0 {
if err := pwRepoTx.CleanupEmpty(c.Context(), affected); err != nil { if err := pwRepoTx.CleanupEmpty(c.Context(), affected); err != nil {
return err return err
@@ -860,7 +860,7 @@ func (s *repportService) getUniformityByWeek(ctx context.Context, projectFlockKa
var rows []entity.ProjectFlockKandangUniformity var rows []entity.ProjectFlockKandangUniformity
if err := s.DB.WithContext(ctx). if err := s.DB.WithContext(ctx).
Model(&entity.ProjectFlockKandangUniformity{}). Model(&entity.ProjectFlockKandangUniformity{}).
Select("week, uniformity, uniform_date, id"). Select("week, uniformity, uniform_date, id, chart_data").
Where("project_flock_kandang_id = ?", projectFlockKandangID). Where("project_flock_kandang_id = ?", projectFlockKandangID).
Where("week IN ?", weeks). Where("week IN ?", weeks).
Order("uniform_date DESC"). Order("uniform_date DESC").
@@ -70,7 +70,7 @@ type HppPerKandangQuery struct {
type ProductionResultQuery struct { type ProductionResultQuery struct {
Page int `query:"page" validate:"omitempty,min=1,gt=0"` Page int `query:"page" validate:"omitempty,min=1,gt=0"`
Limit int `query:"limit" validate:"omitempty,min=1,max=1000,gt=0"` Limit int `query:"limit" validate:"omitempty,min=1,gt=0"`
ProjectFlockKandangID uint `query:"-" validate:"required,gt=0"` ProjectFlockKandangID uint `query:"-" validate:"required,gt=0"`
} }
+2
View File
@@ -113,6 +113,8 @@ const (
StockLogTypeTransfer StockLogType = "TRANSFER" StockLogTypeTransfer StockLogType = "TRANSFER"
StockLogTypeMarketing StockLogType = "MARKETING" StockLogTypeMarketing StockLogType = "MARKETING"
StockLogTypeChikin StockLogType = "CHICKIN" StockLogTypeChikin StockLogType = "CHICKIN"
StockLogTypePurchase StockLogType = "PURCHASE"
StockLogTypeRecording StockLogType = "RECORDING"
) )
// ------------------------------------------------------------------- // -------------------------------------------------------------------