Compare commits

..

1 Commits

Author SHA1 Message Date
giovanni 09c951c16a adjust max limit 2026-01-20 11:49:40 +07:00
100 changed files with 2298 additions and 5003 deletions
+87 -18
View File
@@ -1,21 +1,90 @@
workflow:
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == "development"'
- if: '$CI_COMMIT_BRANCH == "staging"'
- if: '$CI_COMMIT_BRANCH == "production"'
- when: never
stages:
- deploy
include:
- local: "ci/development.yml"
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == "development"'
deploy-dev:
stage: deploy
image: alpine:3.20
variables:
DEPLOY_APP: "LTI-MBUGROUP"
# Opsional: kalau pakai submodule, ini bikin clone submodule pakai SSH juga
GIT_SUBMODULE_STRATEGY: recursive
GIT_DEPTH: "1"
- local: "ci/staging.yml"
rules:
- if: '$CI_COMMIT_BRANCH == "staging"'
before_script:
- echo "🧰 Installing dependencies..."
- apk update && apk add --no-cache openssh git curl bash
- local: "ci/production.yml"
rules:
- if: '$CI_COMMIT_BRANCH == "production"'
# Setup SSH di runner
- mkdir -p ~/.ssh
- echo "$SSH_PRIVATE_KEY" | tr -d '\r' > ~/.ssh/id_rsa
- chmod 600 ~/.ssh/id_rsa
- eval "$(ssh-agent -s)"
- ssh-add ~/.ssh/id_rsa
# Trust host keys (server + gitlab) biar SSH gak nanya interaktif
- ssh-keyscan -H "$SERVER_IP" >> ~/.ssh/known_hosts
- ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts
script:
- echo "🚀 Deploying latest code to $SERVER_USER@$SERVER_IP"
- >
if ssh -o StrictHostKeyChecking=no "$SERVER_USER@$SERVER_IP" "
set -e
cd /home/devops/docker/deployment/development/lti-api
# Pastikan remote origin SSH (antisipasi kalau pernah ke-set HTTPS)
git remote set-url origin git@gitlab.com:mbugroup/lti-api.git
# Pastikan server percaya gitlab.com juga (untuk git fetch via SSH)
mkdir -p ~/.ssh
ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts
# Fetch/reset pakai SSH
GIT_SSH_COMMAND='ssh -o StrictHostKeyChecking=no' git fetch origin development
git reset --hard origin/development
docker compose restart dev-api-lti || docker compose up -d dev-api-lti
"; then
STATUS='success';
else
STATUS='failed';
fi;
RUN_URL="${CI_PROJECT_URL}/-/pipelines/${CI_PIPELINE_ID}";
if [ "$STATUS" = "success" ]; then
COLOR=3066993;
TITLE="✅ Deployment API Succeeded";
DESC="Deployment job on branch \`${CI_COMMIT_REF_NAME}\` completed successfully.";
else
COLOR=15158332;
TITLE="❌ Deployment API Failed Gaes";
DESC="Deployment job on branch \`${CI_COMMIT_REF_NAME}\` failed.";
fi;
echo "{
\"username\": \"CI Bot\",
\"embeds\": [{
\"title\": \"$TITLE\",
\"description\": \"$DESC\",
\"color\": $COLOR,
\"fields\": [
{\"name\": \"Repository\", \"value\": \"${CI_PROJECT_PATH}\", \"inline\": true},
{\"name\": \"Actor\", \"value\": \"${GITLAB_USER_LOGIN}\", \"inline\": true},
{\"name\": \"Commit\", \"value\": \"${CI_COMMIT_SHA}\", \"inline\": false},
{\"name\": \"Pipeline\", \"value\": \"[Open run](${RUN_URL})\", \"inline\": false}
]
}]
}" > payload.json;
echo "📡 Sending notification to Discord...";
curl -sS -H "Content-Type: application/json" \
-d @payload.json "$DISCORD_WEBHOOK_URL";
only:
- development
environment:
name: development
-90
View File
@@ -1,90 +0,0 @@
stages:
- deploy
deploy-dev:
stage: deploy
image: alpine:3.20
variables:
DEPLOY_APP: "LTI-MBUGROUP"
# Opsional: kalau pakai submodule, ini bikin clone submodule pakai SSH juga
GIT_SUBMODULE_STRATEGY: recursive
GIT_DEPTH: "1"
before_script:
- echo "🧰 Installing dependencies..."
- apk update && apk add --no-cache openssh git curl bash
# Setup SSH di runner
- mkdir -p ~/.ssh
- echo "$SSH_PRIVATE_KEY" | tr -d '\r' > ~/.ssh/id_rsa
- chmod 600 ~/.ssh/id_rsa
- eval "$(ssh-agent -s)"
- ssh-add ~/.ssh/id_rsa
# Trust host keys (server + gitlab) biar SSH gak nanya interaktif
- ssh-keyscan -H "$SERVER_IP" >> ~/.ssh/known_hosts
- ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts
script:
- echo "🚀 Deploying latest code to $SERVER_USER@$SERVER_IP"
- >
if ssh -o StrictHostKeyChecking=no "$SERVER_USER@$SERVER_IP" "
set -e
cd /home/devops/docker/deployment/development/lti-api
# Pastikan remote origin SSH (antisipasi kalau pernah ke-set HTTPS)
git remote set-url origin git@gitlab.com:mbugroup/lti-api.git
# Pastikan server percaya gitlab.com juga (untuk git fetch via SSH)
mkdir -p ~/.ssh
ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts
# Fetch/reset pakai SSH
GIT_SSH_COMMAND='ssh -o StrictHostKeyChecking=no' git fetch origin development
git reset --hard origin/development
docker compose restart dev-api-lti || docker compose up -d dev-api-lti
"; then
STATUS='success';
else
STATUS='failed';
fi;
RUN_URL="${CI_PROJECT_URL}/-/pipelines/${CI_PIPELINE_ID}";
if [ "$STATUS" = "success" ]; then
COLOR=3066993;
TITLE="✅ Deployment API Succeeded";
DESC="Deployment job on branch \`${CI_COMMIT_REF_NAME}\` completed successfully.";
else
COLOR=15158332;
TITLE="❌ Deployment API Failed Gaes";
DESC="Deployment job on branch \`${CI_COMMIT_REF_NAME}\` failed.";
fi;
echo "{
\"username\": \"CI Bot\",
\"embeds\": [{
\"title\": \"$TITLE\",
\"description\": \"$DESC\",
\"color\": $COLOR,
\"fields\": [
{\"name\": \"Repository\", \"value\": \"${CI_PROJECT_PATH}\", \"inline\": true},
{\"name\": \"Actor\", \"value\": \"${GITLAB_USER_LOGIN}\", \"inline\": true},
{\"name\": \"Commit\", \"value\": \"${CI_COMMIT_SHA}\", \"inline\": false},
{\"name\": \"Pipeline\", \"value\": \"[Open run](${RUN_URL})\", \"inline\": false}
]
}]
}" > payload.json;
echo "📡 Sending notification to Discord...";
curl -sS -H "Content-Type: application/json" \
-d @payload.json "$DISCORD_WEBHOOK_URL";
only:
- development
environment:
name: development
-131
View File
@@ -1,131 +0,0 @@
stages:
- build
# - migrate
- deploy
- seed
default:
tags:
- self-hosted-prod
workflow:
rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"'
when: always
- when: never
variables:
DOCKER_BUILDKIT: "1"
IMAGE_TAG: "production_${CI_COMMIT_SHORT_SHA}"
IMAGE_NAME: "${CI_REGISTRY_IMAGE}:${IMAGE_TAG}"
IMAGE_LATEST: "${CI_REGISTRY_IMAGE}:production_latest"
DEPLOY_DIR: "/opt/deploy/lti"
COMPOSE_FILE: "docker-compose.yaml"
# =========================
# BUILD (AUTO)
# =========================
build_production:
stage: build
rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"'
script: |
set -e
docker info
echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
echo "✅ Build image: $IMAGE_NAME"
docker build -t "$IMAGE_NAME" -f Dockerfile .
echo "✅ Push image: $IMAGE_NAME"
docker push "$IMAGE_NAME"
echo "✅ Tag latest: $IMAGE_LATEST"
docker tag "$IMAGE_NAME" "$IMAGE_LATEST"
docker push "$IMAGE_LATEST"
# =========================
# MIGRATE (PRODUCTION - MANUAL)
# =========================
#migrate_production:
# stage: migrate
# rules:
# - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"'
# when: manual
# allow_failure: false
# needs:
# - job: build_production
# artifacts: false
# script: |
# set -e
# cd /opt/deploy/lti
# test -f .env || (echo "❌ .env not found" && exit 1)
# set -a
# . ./.env
# set +a
# Validasi env wajib
# : "${DB_HOST:?DB_HOST not set}"
# : "${DB_PORT:?DB_PORT not set}"
# : "${DB_USER:?DB_USER not set}"
# : "${DB_PASSWORD:?DB_PASSWORD not set}"
# : "${DB_NAME:?DB_NAME not set}"
# DB_SSLMODE="${DB_SSLMODE:-require}"
# export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE}"
# echo "✅ Running migrations (production)..."
# docker run --rm \
# -v "$CI_PROJECT_DIR/internal/database/migrations:/migrations:ro" \
# migrate/migrate:v4.15.2 \
# -path=/migrations -database "$DATABASE_URL" up
# =========================
# DEPLOY (AUTO)
# =========================
deploy_production:
stage: deploy
rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"'
needs:
# - job: migrate_production
# artifacts: false
- job: build_production
artifacts: false
script: |
set -e
docker info
echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
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)
docker compose -f "$COMPOSE_FILE" pull
docker compose -f "$COMPOSE_FILE" up -d --force-recreate
docker image prune -f
# =========================
# SEED (MANUAL)
# =========================
seed_production:
stage: seed
rules:
- if: '$CI_COMMIT_BRANCH == "production"'
when: manual
script: |
set -e
cd /opt/deploy/lti
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 --env-file .env pull seed
docker compose --env-file .env run --rm seed
-173
View File
@@ -1,173 +0,0 @@
stages:
- build
- migrate
- deploy
- seed
default:
tags:
- self-hosted-stg
workflow:
rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"'
when: always
- when: never
variables:
DOCKER_BUILDKIT: "1"
IMAGE_TAG: "staging_${CI_COMMIT_SHORT_SHA}"
IMAGE_NAME: "${CI_REGISTRY_IMAGE}:${IMAGE_TAG}"
IMAGE_LATEST: "${CI_REGISTRY_IMAGE}:staging_latest"
DEPLOY_DIR: "/opt/deploy/stg-lti-api"
COMPOSE_FILE: "docker-compose.yaml"
# =========================
# BUILD (AUTO)
# =========================
build_staging:
stage: build
rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"'
script: |
set -e
docker info
echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
echo "✅ Build image: $IMAGE_NAME"
docker build -t "$IMAGE_NAME" -f Dockerfile .
echo "✅ Push image: $IMAGE_NAME"
docker push "$IMAGE_NAME"
echo "✅ Tag latest: $IMAGE_LATEST"
docker tag "$IMAGE_NAME" "$IMAGE_LATEST"
docker push "$IMAGE_LATEST"
# =========================
# MIGRATE (AUTO)
# =========================
migrate_staging:
stage: migrate
rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"'
needs:
- job: build_staging
artifacts: false
script: |
set -e
echo "✅ Running migrations (staging) ..."
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
. ./.env
set +a
# ✅ validasi
test -n "$DB_HOST" || (echo "❌ DB_HOST empty" && exit 1)
test -n "$DB_PORT" || (echo "❌ DB_PORT empty" && exit 1)
test -n "$DB_USER" || (echo "❌ DB_USER empty" && exit 1)
test -n "$DB_PASSWORD" || (echo "❌ DB_PASSWORD empty" && exit 1)
test -n "$DB_NAME" || (echo "❌ DB_NAME empty" && exit 1)
export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE:-disable}"
echo "✅ DATABASE_URL=$DATABASE_URL"
# ✅ Pastikan postgres & redis ON (sesuaikan nama service compose kamu!)
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" \
migrate/migrate:v4.15.2 \
-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_staging:
stage: deploy
rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"'
needs:
- job: migrate_staging
artifacts: false
- job: build_staging
artifacts: false
script: |
set -e
docker info
echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
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)
docker compose -f "$COMPOSE_FILE" pull
docker compose -f "$COMPOSE_FILE" up -d --force-recreate
docker image prune -f
# =========================
# SEED (MANUAL)
# =========================
seed_staging:
stage: seed
rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"'
needs:
- job: deploy_staging
artifacts: false
when: manual
allow_failure: false
script: |
set -e
cd "$DEPLOY_DIR"
test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found" && exit 1)
test -f .env || (echo "❌ .env not found" && exit 1)
docker compose -f "$COMPOSE_FILE" pull seed || true
docker compose -f "$COMPOSE_FILE" run --rm seed%
@@ -1,309 +0,0 @@
package repository
import (
"context"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"gorm.io/gorm"
)
type HppCostRepository interface {
GetProjectFlockKandangIDs(ctx context.Context, projectFlockId uint) ([]uint, error)
GetDocCost(ctx context.Context, projectFlockKandangIDs []uint) (float64, error)
GetBudgetCostByProjectFlockId(ctx context.Context, projectFlockId uint) (float64, error)
GetExpedisionCost(ctx context.Context, projectFlockKandangIDs []uint) (float64, error)
GetFeedUsageCost(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, error)
GetOvkUsageCost(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, error)
GetTotalPopulation(ctx context.Context, projectFlockKandangIDs []uint) (float64, error)
GetPulletCost(ctx context.Context, projectFlockKandangId uint) (float64, error)
GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, float64, error)
GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, startDate *time.Time, endDate *time.Time) (float64, float64, error)
GetProjectFlockIDByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (uint, error)
GetTransferSourceSummary(ctx context.Context, projectFlockKandangId uint) (uint, float64, error)
}
type HppRepositoryImpl struct {
db *gorm.DB
}
func NewHppCostRepository(db *gorm.DB) HppCostRepository {
return &HppRepositoryImpl{db: db}
}
func (r *HppRepositoryImpl) GetProjectFlockKandangIDs(ctx context.Context, projectFlockId uint) ([]uint, error) {
var ids []uint
err := r.db.WithContext(ctx).
Table("project_flock_kandangs").
Select("id").
Where("project_flock_id = ?", projectFlockId).
Scan(&ids).Error
if err != nil {
return nil, err
}
return ids, nil
}
func (r *HppRepositoryImpl) GetDocCost(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) {
var total float64
err := r.db.WithContext(ctx).
Table("project_chickins AS pc").
Select("COALESCE(SUM(pc.usage_qty * COALESCE(pi.price, 0)), 0)").
Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id AND sa.stockable_type = ?", fifo.UsableKeyProjectChickin.String(), fifo.StockableKeyPurchaseItems.String()).
Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id").
Where("pc.project_flock_kandang_id IN (?)", projectFlockKandangIDs).
Scan(&total).Error
if err != nil {
return 0, err
}
return total, nil
}
func (r *HppRepositoryImpl) GetBudgetCostByProjectFlockId(ctx context.Context, projectFlockId uint) (float64, error) {
var total float64
err := r.db.WithContext(ctx).
Table("project_budgets AS pb").
Select("COALESCE(SUM(pb.qty * pb.price), 0)").
Where("pb.project_flock_id = ?", projectFlockId).
Scan(&total).Error
if err != nil {
return 0, err
}
return total, nil
}
func (r *HppRepositoryImpl) GetExpedisionCost(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) {
var total float64
err := r.db.WithContext(ctx).
Table("expense_nonstocks AS en").
Select("COALESCE(SUM(er.qty * er.price), 0)").
Joins("JOIN expense_realizations AS er ON er.expense_nonstock_id = en.id").
Joins("JOIN flags AS f ON f.flagable_id = en.nonstock_id AND f.flagable_type = ?", entity.FlagableTypeNonstock).
Where("en.project_flock_kandang_id IN (?)", projectFlockKandangIDs).
Where("f.name = ?", utils.FlagEkspedisi).
Scan(&total).Error
if err != nil {
return 0, err
}
return total, nil
}
func (r *HppRepositoryImpl) GetFeedUsageCost(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, error) {
if date == nil {
now := time.Now()
date = &now
}
var total float64
err := r.db.WithContext(ctx).
Table("recordings AS r").
Select("COALESCE(SUM(rs.usage_qty * COALESCE(pi.price, 0)), 0)").
Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id").
Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id").
Joins("JOIN flags AS f ON f.flagable_id = pw.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct).
Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String()).
Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id").
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
Where("r.record_datetime <= ?", *date).
Where("f.name = ?", utils.FlagPakan).
Scan(&total).Error
if err != nil {
return 0, err
}
return total, nil
}
func (r *HppRepositoryImpl) GetOvkUsageCost(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, error) {
if date == nil {
now := time.Now()
date = &now
}
flags := []utils.FlagType{
utils.FlagOVK,
utils.FlagObat,
utils.FlagVitamin,
utils.FlagKimia,
}
var total float64
err := r.db.WithContext(ctx).
Table("recordings AS r").
Select("COALESCE(SUM(rs.usage_qty * COALESCE(pi.price, 0)), 0)").
Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id").
Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id").
Joins("JOIN flags AS f ON f.flagable_id = pw.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct).
Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String()).
Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id").
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
Where("r.record_datetime <= ?", *date).
Where("f.name IN ?", flags).
Scan(&total).Error
if err != nil {
return 0, err
}
return total, nil
}
func (r *HppRepositoryImpl) GetTotalPopulation(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) {
var total float64
err := r.db.WithContext(ctx).
Table("project_chickins AS pc").
Select("COALESCE(SUM(pc.usage_qty), 0)").
Where("pc.project_flock_kandang_id IN (?)", projectFlockKandangIDs).
Scan(&total).Error
if err != nil {
return 0, err
}
return total, nil
}
func (r *HppRepositoryImpl) GetPulletCost(ctx context.Context, projectFlockKandangId uint) (float64, error) {
stockablePurchase := fifo.StockableKeyPurchaseItems.String()
stockableTransferIn := fifo.StockableKeyStockTransferIn.String()
usableProjectChickin := fifo.UsableKeyProjectChickin.String()
var total float64
err := r.db.WithContext(ctx).
Table("project_chickins AS pc").
Select(`
COALESCE(SUM(pc.usage_qty * CASE
WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0)
WHEN sa.stockable_type = ? THEN COALESCE(tpi.price, 0)
ELSE 0
END), 0)`,
stockablePurchase, stockableTransferIn).
Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id", usableProjectChickin).
Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase).
Joins("LEFT JOIN stock_allocations AS tsa ON tsa.usable_type = ? AND tsa.usable_id = sa.stockable_id AND sa.stockable_type = ? AND tsa.stockable_type = ?", stockableTransferIn, stockableTransferIn, stockablePurchase).
Joins("LEFT JOIN purchase_items AS tpi ON tpi.id = tsa.stockable_id").
Where("pc.project_flock_kandang_id = ?", projectFlockKandangId).
Scan(&total).Error
if err != nil {
return 0, err
}
return total, nil
}
func (r *HppRepositoryImpl) GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, float64, error) {
// if date == nil {
// now := time.Now()
// date = &now
// }
var totals struct {
TotalPieces float64
TotalWeightKg float64
}
err := r.db.WithContext(ctx).
Table("recordings AS r").
Select("COALESCE(SUM(re.qty), 0) AS total_pieces, COALESCE(SUM(re.weight), 0)AS total_weight_kg").
Joins("JOIN recording_eggs AS re ON re.recording_id = r.id").
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
Where("r.record_datetime <= ?", *date).
Scan(&totals).Error
if err != nil {
return 0, 0, err
}
return totals.TotalPieces, totals.TotalWeightKg, nil
}
func (r *HppRepositoryImpl) GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(
ctx context.Context,
projectFlockKandangIDs []uint,
startDate *time.Time,
endDate *time.Time,
) (float64, float64, error) {
if endDate == nil {
now := time.Now()
endDate = &now
}
type subResult struct {
UsableID uint
MdpUsageQty float64
MdpWeight float64
}
subQuery := r.db.WithContext(ctx).
Table("recordings AS r").
Select(`
DISTINCT sa.usable_id,
mdp.usage_qty AS mdp_usage_qty,
mdp.total_weight AS mdp_weight
`).
Joins("JOIN recording_eggs re ON re.recording_id = r.id").
Joins(
"JOIN stock_allocations sa ON sa.stockable_type = ? AND sa.stockable_id = re.id AND sa.usable_type = ?",
fifo.StockableKeyRecordingEgg.String(),
fifo.UsableKeyMarketingDelivery.String(),
).
Joins("JOIN marketing_delivery_products mdp ON mdp.id = sa.usable_id").
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
Where("r.record_datetime <= ?", *endDate).
Where("mdp.delivery_date = ?", *startDate)
var totals struct {
TotalPieces float64
TotalWeight float64
}
err := r.db.WithContext(ctx).
Table("(?) AS x", subQuery).
Select(`
COALESCE(SUM(x.mdp_usage_qty), 0) AS total_pieces,
COALESCE(SUM(x.mdp_weight), 0) AS total_weight
`).
Scan(&totals).Error
if err != nil {
return 0, 0, err
}
return totals.TotalPieces, totals.TotalWeight, nil
}
func (r *HppRepositoryImpl) GetProjectFlockIDByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (uint, error) {
var projectFlockID uint
err := r.db.WithContext(ctx).
Table("project_flock_kandangs").
Select("project_flock_id").
Where("id = ?", projectFlockKandangId).
Scan(&projectFlockID).Error
if err != nil {
return 0, err
}
return projectFlockID, nil
}
func (r *HppRepositoryImpl) GetTransferSourceSummary(ctx context.Context, projectFlockKandangId uint) (uint, float64, error) {
var summary struct {
ProjectFlockID uint
TotalQty float64
}
err := r.db.WithContext(ctx).
Table("laying_transfer_targets AS ltt").
Select("lt.from_project_flock_id AS project_flock_id, COALESCE(SUM(ltt.total_qty), 0) AS total_qty").
Joins("JOIN laying_transfers AS lt ON lt.id = ltt.laying_transfer_id").
Where("ltt.target_project_flock_kandang_id = ?", projectFlockKandangId).
Group("lt.from_project_flock_id").
Scan(&summary).Error
if err != nil {
return 0, 0, err
}
return summary.ProjectFlockID, summary.TotalQty, nil
}
@@ -25,7 +25,6 @@ type FifoService interface {
Replenish(ctx context.Context, req StockReplenishRequest) (*StockReplenishResult, error)
Consume(ctx context.Context, req StockConsumeRequest) (*StockConsumeResult, error)
ReleaseUsage(ctx context.Context, req StockReleaseRequest) error
AdjustStockableQuantity(ctx context.Context, req StockAdjustRequest) error
}
type fifoService struct {
@@ -96,15 +95,6 @@ type StockReplenishRequest struct {
Tx *gorm.DB
}
type StockAdjustRequest struct {
StockableKey fifo.StockableKey
StockableID uint
ProductWarehouseID uint
Quantity float64
Note *string
Tx *gorm.DB
}
type PendingResolution struct {
UsableKey fifo.UsableKey
UsableID uint
@@ -147,37 +137,6 @@ type StockReleaseRequest struct {
Reason *string
Tx *gorm.DB
}
func (s *fifoService) AdjustStockableQuantity(ctx context.Context, req StockAdjustRequest) error {
if req.StockableID == 0 || strings.TrimSpace(req.StockableKey.String()) == "" {
return errors.New("stockable key and id are required")
}
if req.ProductWarehouseID == 0 {
return errors.New("product warehouse id is required")
}
if req.Quantity == 0 {
return nil
}
if req.Quantity > 0 {
return errors.New("quantity must be negative")
}
cfg, ok := fifo.Stockable(req.StockableKey)
if !ok {
return fmt.Errorf("stockable %q is not registered", req.StockableKey)
}
return s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error {
if err := s.incrementStockableQty(ctx, tx, cfg, req.StockableID, req.Quantity); err != nil {
return err
}
return s.productWarehouseRepo.AdjustQuantities(ctx, map[uint]float64{
req.ProductWarehouseID: req.Quantity,
}, func(db *gorm.DB) *gorm.DB {
return s.txOrDB(tx, db)
})
})
}
func (s *fifoService) Replenish(ctx context.Context, req StockReplenishRequest) (*StockReplenishResult, error) {
if req.StockableID == 0 || strings.TrimSpace(req.StockableKey.String()) == "" {
@@ -1,272 +0,0 @@
package service
import (
"context"
"math"
"time"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
)
type HppService interface {
CalculateHppCost(projectFlockKandangId uint, date *time.Time) (*HppCostResponse, error)
GetTotalDepresiasiFlockGrowing(sourceProjectFlockID uint, date *time.Time) (float64, error)
GetTotalProductionCost(projectFlockKandangId uint, endDate *time.Time, depresiasiTransfer float64) (float64, error)
GetBudgetKandangLaying(projectFlockKandangId uint, endDate *time.Time) (float64, error)
GetDepresiasiTransfer(projectFlockKandangId uint, date *time.Time) (float64, error)
GetHppEstimationDanRealisasi(totalProductionCost float64, projectFlockKandangId uint, startDate *time.Time, endDate *time.Time) (*HppCostResponse, error)
}
type HppCostResponse struct {
Estimation HppCostDetail `json:"estimation"`
Real HppCostDetail `json:"real"`
}
type HppCostDetail struct {
HargaKg float64 `json:"harga_kg"`
HargaButir float64 `json:"harga_butir"`
Total float64 `json:"total"`
Kg float64 `json:"kg"`
Butir float64 `json:"butir"`
}
type hppService struct {
hppRepo commonRepo.HppCostRepository
}
func NewHppService(hppRepo commonRepo.HppCostRepository) HppService {
return &hppService{hppRepo: hppRepo}
}
func (s *hppService) CalculateHppCost(projectFlockKandangId uint, date *time.Time) (*HppCostResponse, error) {
if date == nil {
now := time.Now()
date = &now
}
location, err := time.LoadLocation("Asia/Jakarta")
if err != nil {
return nil, err
}
startOfDay := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, location)
endOfDay := startOfDay.Add(24 * time.Hour)
depresiasiTransfer, err := s.GetDepresiasiTransfer(projectFlockKandangId, &endOfDay)
if err != nil {
return nil, err
}
totalProductionCost, err := s.GetTotalProductionCost(projectFlockKandangId, &endOfDay, depresiasiTransfer)
if err != nil {
return nil, err
}
return s.GetHppEstimationDanRealisasi(totalProductionCost, projectFlockKandangId, &startOfDay, &endOfDay)
}
func (s *hppService) GetTotalDepresiasiFlockGrowing(sourceProjectFlockID uint, date *time.Time) (float64, error) {
if date == nil {
now := time.Now()
date = &now
}
if s.hppRepo == nil {
return 0, nil
}
kandangIDs, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), sourceProjectFlockID)
if err != nil {
return 0, err
}
docCost, err := s.hppRepo.GetDocCost(context.Background(), kandangIDs)
if err != nil {
return 0, err
}
budgetCost, err := s.hppRepo.GetBudgetCostByProjectFlockId(context.Background(), sourceProjectFlockID)
if err != nil {
return 0, err
}
expedisionCost, err := s.hppRepo.GetExpedisionCost(context.Background(), kandangIDs)
if err != nil {
return 0, err
}
feedCost, err := s.hppRepo.GetFeedUsageCost(context.Background(), kandangIDs, date)
if err != nil {
return 0, err
}
ovkCost, err := s.hppRepo.GetOvkUsageCost(context.Background(), kandangIDs, date)
if err != nil {
return 0, err
}
return docCost + budgetCost + expedisionCost + feedCost + ovkCost, nil
}
func (s *hppService) GetTotalProductionCost(projectFlockKandangId uint, endDate *time.Time, depresiasiTransfer float64) (float64, error) {
// if date == nil {
// now := time.Now()
// date = &now
// }
costPullet, err := s.hppRepo.GetPulletCost(context.Background(), projectFlockKandangId)
if err != nil {
return 0, err
}
costFeed, err := s.hppRepo.GetFeedUsageCost(context.Background(), []uint{projectFlockKandangId}, endDate)
if err != nil {
return 0, err
}
costOvk, err := s.hppRepo.GetOvkUsageCost(context.Background(), []uint{projectFlockKandangId}, endDate)
if err != nil {
return 0, err
}
costExpedision, err := s.hppRepo.GetExpedisionCost(context.Background(), []uint{projectFlockKandangId})
if err != nil {
return 0, err
}
costBudget, err := s.GetBudgetKandangLaying(projectFlockKandangId, endDate)
if err != nil {
return 0, err
}
return depresiasiTransfer + costPullet + costFeed + costOvk + costExpedision + costBudget, nil
}
func (s *hppService) GetBudgetKandangLaying(projectFlockKandangId uint, endDate *time.Time) (float64, error) {
// if date == nil {
// now := time.Now()
// date = &now
// }
if s.hppRepo == nil {
return 0, nil
}
projectFlockId, err := s.hppRepo.GetProjectFlockIDByProjectFlockKandangID(context.Background(), projectFlockKandangId)
if err != nil {
return 0, err
}
projectFlockKandangIds, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), projectFlockId)
if err != nil {
return 0, err
}
eggProduksiPiecesFlock, _, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), projectFlockKandangIds, endDate)
if err != nil {
return 0, err
}
eggProduksiPiecesKandang, _, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, endDate)
if err != nil {
return 0, err
}
totalBudgetCost, err := s.hppRepo.GetBudgetCostByProjectFlockId(context.Background(), projectFlockId)
if err != nil {
return 0, err
}
if eggProduksiPiecesFlock == 0 {
return 0, nil
}
return (totalBudgetCost * eggProduksiPiecesKandang) / eggProduksiPiecesFlock, nil
}
func (s *hppService) GetDepresiasiTransfer(projectFlockKandangId uint, endDate *time.Time) (float64, error) {
// if endDate == nil {
// now := time.Now()
// endDate = &now
// }
if s.hppRepo == nil {
return 0, nil
}
sourceProjectFlockID, transferTotalQty, err := s.hppRepo.GetTransferSourceSummary(context.Background(), projectFlockKandangId)
if err != nil {
return 0, err
}
kandangIDsGrowing, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), sourceProjectFlockID)
if err != nil {
return 0, err
}
totalPopulationFlockGrowing, err := s.hppRepo.GetTotalPopulation(context.Background(), kandangIDsGrowing)
if err != nil {
return 0, err
}
if totalPopulationFlockGrowing == 0 {
return 0, nil
}
totalDepresiasiFlockGrowing, err := s.GetTotalDepresiasiFlockGrowing(sourceProjectFlockID, endDate)
if err != nil {
return 0, err
}
return (totalDepresiasiFlockGrowing * transferTotalQty) / totalPopulationFlockGrowing, nil
}
func (s *hppService) GetHppEstimationDanRealisasi(totalProductionCost float64, projectFlockKandangId uint, startDate *time.Time, endDate *time.Time) (*HppCostResponse, error) {
if s.hppRepo == nil {
return &HppCostResponse{}, nil
}
estimPieces, estimWeightKg, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, endDate)
if err != nil {
return nil, err
}
realPieces, realWeightKg, err := s.hppRepo.GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, startDate, endDate)
if err != nil {
return nil, err
}
estimation := HppCostDetail{
Total: totalProductionCost,
Kg: estimWeightKg,
Butir: estimPieces,
}
if estimWeightKg > 0 {
estimation.HargaKg = roundToTwoDecimals(totalProductionCost / estimWeightKg)
}
if estimPieces > 0 {
estimation.HargaButir = roundToTwoDecimals(totalProductionCost / estimPieces)
}
real := HppCostDetail{
Total: totalProductionCost,
Kg: realWeightKg,
Butir: realPieces,
}
if realWeightKg > 0 {
real.HargaKg = roundToTwoDecimals(totalProductionCost / realWeightKg)
}
if realPieces > 0 {
real.HargaButir = roundToTwoDecimals(totalProductionCost / realPieces)
}
return &HppCostResponse{
Estimation: estimation,
Real: real,
}, nil
}
func roundToTwoDecimals(value float64) float64 {
return math.Round(value*100) / 100
}
@@ -1,3 +0,0 @@
ALTER TABLE recording_depletions
DROP COLUMN IF EXISTS pending_qty,
DROP COLUMN IF EXISTS source_product_warehouse_id;
@@ -1,17 +0,0 @@
ALTER TABLE recording_depletions
ADD COLUMN IF NOT EXISTS pending_qty numeric(15,3) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS source_product_warehouse_id bigint;
UPDATE recording_depletions rd
SET source_product_warehouse_id = src.product_warehouse_id
FROM recordings r
JOIN LATERAL (
SELECT pfp.product_warehouse_id
FROM project_chickins pc
JOIN project_flock_populations pfp ON pfp.project_chickin_id = pc.id
WHERE pc.project_flock_kandang_id = r.project_flock_kandangs_id
ORDER BY pfp.created_at ASC, pfp.id ASC
LIMIT 1
) AS src ON true
WHERE r.id = rd.recording_id
AND rd.source_product_warehouse_id IS NULL;
@@ -1,6 +0,0 @@
BEGIN;
ALTER TABLE payments
ALTER COLUMN bank_id SET NOT NULL;
COMMIT;
@@ -1,6 +0,0 @@
BEGIN;
ALTER TABLE payments
ALTER COLUMN bank_id DROP NOT NULL;
COMMIT;
@@ -1,3 +0,0 @@
ALTER TABLE adjustment_stocks ADD COLUMN stock_log_id INTEGER;
CREATE INDEX idx_adjustment_stocks_stock_log_id ON adjustment_stocks (stock_log_id);
@@ -1 +0,0 @@
ALTER TABLE adjustment_stocks DROP COLUMN IF EXISTS stock_log_id;
@@ -1,2 +0,0 @@
ALTER TABLE stock_logs
DROP COLUMN stock;
@@ -1,19 +0,0 @@
ALTER TABLE stock_logs
ADD COLUMN stock NUMERIC(15, 3) NOT NULL DEFAULT 0;
WITH calc AS (
SELECT
id,
SUM(COALESCE(increase, 0) - COALESCE(decrease, 0))
OVER (
PARTITION BY product_warehouse_id
ORDER BY id
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_stock
FROM stock_logs
)
UPDATE stock_logs t
SET stock = c.running_stock
FROM calc c
WHERE t.id = c.id;
+21 -9
View File
@@ -2,16 +2,28 @@ package entities
import "time"
// AdjustmentStock tracks FIFO allocation for stock adjustments
// - For INCREASE adjustments (Stockable): Tracks stock added to warehouse
// - For DECREASE adjustments (Usable): Tracks stock consumed from warehouse
type AdjustmentStock struct {
Id uint `gorm:"primaryKey"`
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
TotalQty float64 `gorm:"column:total_qty;default:0"`
TotalUsed float64 `gorm:"column:total_used;default:0"`
UsageQty float64 `gorm:"column:usage_qty;default:0"`
PendingQty float64 `gorm:"column:pending_qty;default:0"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime"`
Id uint `gorm:"primaryKey"`
StockLogId uint `gorm:"column:stock_log_id;not null;index"`
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
// === FIFO FIELDS FOR INCREASE ADJUSTMENT (Stockable) ===
// Tracks stock added to warehouse via adjustment INCREASE
TotalQty float64 `gorm:"column:total_qty;default:0"` // Total lot quantity available
TotalUsed float64 `gorm:"column:total_used;default:0"` // Quantity already used from this lot
// === FIFO FIELDS FOR DECREASE ADJUSTMENT (Usable) ===
// Tracks stock consumed from warehouse via adjustment DECREASE
UsageQty float64 `gorm:"column:usage_qty;default:0"` // Actual quantity consumed
PendingQty float64 `gorm:"column:pending_qty;default:0"` // Pending quantity (waiting for stock)
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime"`
// Relations
StockLog *StockLog `gorm:"foreignKey:StockLogId;references:Id"`
ProductWarehouse *ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"`
StockLog *StockLog `gorm:"polymorphic:Loggable;polymorphicType:LoggableType;polymorphicId:LoggableId;polymorphicValue:ADJUSTMENT"`
}
+4 -6
View File
@@ -1,12 +1,10 @@
package entities
type RecordingDepletion struct {
Id uint `gorm:"primaryKey"`
RecordingId uint `gorm:"column:recording_id;not null;index"`
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
SourceProductWarehouseId *uint `gorm:"column:source_product_warehouse_id"`
Qty float64 `gorm:"column:qty;not null"`
PendingQty float64 `gorm:"column:pending_qty"`
Id uint `gorm:"primaryKey"`
RecordingId uint `gorm:"column:recording_id;not null;index"`
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
Qty float64 `gorm:"column:qty;not null"`
Recording Recording `gorm:"foreignKey:RecordingId;references:Id"`
ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"`
-1
View File
@@ -9,7 +9,6 @@ type StockLog struct {
Increase float64 `gorm:"column:increase;type:numeric(15,3);default:0"`
Decrease float64 `gorm:"column:decrease;type:numeric(15,3);default:0"`
Stock float64 `gorm:"column:stock;type:numeric(15,3);not null;default:0"`
LoggableType string `gorm:"column:loggable_type;type:varchar(50);not null"`
LoggableId uint `gorm:"column:loggable_id;not null"`
@@ -28,31 +28,10 @@ func NewClosingController(closingService service.ClosingService, sapronakService
}
func (u *ClosingController) GetAll(c *fiber.Ctx) error {
var projectStatus *int
if raw := c.Query("project_status"); raw != "" {
statusValue, err := strconv.Atoi(raw)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid project_status")
}
projectStatus = &statusValue
}
var locationID *uint
if raw := c.Query("location_id"); raw != "" {
locationValue, err := strconv.Atoi(raw)
if err != nil || locationValue <= 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid location_id")
}
locationUint := uint(locationValue)
locationID = &locationUint
}
query := &validation.Query{
Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10),
Search: c.Query("search", ""),
ProjectStatus: projectStatus,
LocationID: locationID,
Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10),
Search: c.Query("search", ""),
}
if query.Page < 1 || query.Limit < 1 {
@@ -181,7 +160,7 @@ func (u *ClosingController) GetPenjualan(c *fiber.Ctx) error {
Code: fiber.StatusOK,
Status: "success",
Message: "Get closing penjualan successfully",
Data: dto.ToPenjualanRealisasiResponseDTO(result),
Data: dto.ToPenjualanRealisasiResponseDTO(uint(projectFlockID), result),
})
}
@@ -211,7 +190,7 @@ func (u *ClosingController) GetPenjualanByProjectFlockKandang(c *fiber.Ctx) erro
Code: fiber.StatusOK,
Status: "success",
Message: "Get closing penjualan by project flock kandang successfully",
Data: dto.ToPenjualanRealisasiResponseDTO(result),
Data: dto.ToPenjualanRealisasiResponseDTO(uint(projectFlockID), result),
})
}
@@ -257,10 +236,9 @@ func (u *ClosingController) GetClosingSapronak(c *fiber.Ctx) error {
}
query := &validation.ClosingSapronakQuery{
Type: strings.ToLower(c.Query("type")),
Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10),
Search: c.Query("search"),
Type: strings.ToLower(c.Query("type")),
Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10),
}
if raw := c.Query("kandang_id"); raw != "" {
kandangInt, convErr := strconv.Atoi(raw)
@@ -299,45 +277,6 @@ func (u *ClosingController) GetClosingSapronak(c *fiber.Ctx) error {
})
}
func (u *ClosingController) GetClosingSapronakSummary(c *fiber.Ctx) error {
param := c.Params("projectFlockId")
id, err := strconv.Atoi(param)
if err != nil || id <= 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid projectFlockId")
}
query := &validation.ClosingSapronakQuery{
Type: strings.ToLower(c.Query("type")),
Search: c.Query("search"),
}
if raw := c.Query("kandang_id"); raw != "" {
kandangInt, convErr := strconv.Atoi(raw)
if convErr != nil || kandangInt <= 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid kandang_id")
}
kandangUint := uint(kandangInt)
query.KandangID = &kandangUint
}
if query.Type != validation.SapronakTypeIncoming && query.Type != validation.SapronakTypeOutgoing {
return fiber.NewError(fiber.StatusBadRequest, "type must be either incoming or outgoing")
}
result, err := u.ClosingService.GetClosingSapronakSummary(c, uint(id), query)
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Retrieved closing report (sapronak summary) successfully",
Data: result,
})
}
func (u *ClosingController) GetSapronakByProject(c *fiber.Ctx) error {
param := c.Params("project_flock_id")
flag := c.Query("flag", "")
+20 -20
View File
@@ -98,26 +98,26 @@ type ClosingEggSalesDTO struct {
}
type ClosingPerformanceDTO struct {
Depletion float64 `json:"depletion"`
Age float64 `json:"age_day"`
MortalityStd float64 `json:"mor_std"`
MortalityAct float64 `json:"mor_act"`
DeffMortality float64 `json:"mor_diff"`
FcrStd float64 `json:"fcr_std"`
FcrAct float64 `json:"fcr_act"`
DeffFcr float64 `json:"fcr_diff"`
AwgAct float64 `json:"awg_act"`
AwgStd float64 `json:"awg_std"`
FeedIntake float64 `json:"feed_intake"`
FeedIntakeStd float64 `json:"feed_intake_std"`
HenDayAct float64 `json:"hen_day_act,omitempty"`
HendayStd float64 `json:"hen_day_std"`
EggMass float64 `json:"egg_mass,omitempty"`
EggMassStd float64 `json:"egg_mass_std"`
EggWeight float64 `json:"egg_weight,omitempty"`
EggWeightStd float64 `json:"egg_weight_std"`
HenHouseAct float64 `json:"hen_housed_act,omitempty"`
HenHouseStd float64 `json:"hen_housed_std"`
Depletion float64 `json:"depletion"`
Age float64 `json:"age_day"`
MortalityStd float64 `json:"mor_std"`
MortalityAct float64 `json:"mor_act"`
DeffMortality float64 `json:"mor_diff"`
FcrStd float64 `json:"fcr_std"`
FcrAct float64 `json:"fcr_act"`
DeffFcr float64 `json:"fcr_diff"`
AwgAct float64 `json:"awg_act"`
AwgStd float64 `json:"awg_std"`
FeedIntake float64 `json:"feed_intake"`
FeedIntakeStd float64 `json:"feed_intake_std"`
HenDayAct *float64 `json:"hen_day_act,omitempty"`
HendayStd float64 `json:"hen_day_std"`
EggMass *float64 `json:"egg_mass,omitempty"`
EggMassStd float64 `json:"egg_mass_std"`
EggWeight *float64 `json:"egg_weight,omitempty"`
EggWeightStd float64 `json:"egg_weight_std"`
HenHouseAct *float64 `json:"hen_housed_act,omitempty"`
HenHouseStd float64 `json:"hen_housed_std"`
}
type ClosingSalesGroupDTO struct {
@@ -1,12 +1,8 @@
package dto
import (
"strings"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
)
// === CLOSING KEUANGAN CODES ===
// Closing HPP Codes
type ClosingHPPCode string
const (
@@ -18,30 +14,36 @@ const (
HPPCodeEkspedisi ClosingHPPCode = "EKSPEDISI"
)
// Closing Profit Loss Codes
type ClosingProfitLossCode string
const (
PLCodeSales ClosingProfitLossCode = "SALES"
PLCodeSapronak ClosingProfitLossCode = "SAPRONAK"
PLCodeOverhead ClosingProfitLossCode = "OVERHEAD"
PLCodeSales ClosingProfitLossCode = "SALES"
PLCodeSapronak ClosingProfitLossCode = "SAPRONAK"
PLCodeOverhead ClosingProfitLossCode = "OVERHEAD"
PLCodeEkspedisi ClosingProfitLossCode = "EKSPEDISI"
)
// === NEW CLOSING KEUANGAN DTO ===
// FinancialMetrics represents financial metrics with per unit and total amounts
type FinancialMetrics struct {
RpPerBird float64 `json:"rp_per_bird"`
RpPerKg float64 `json:"rp_per_kg"`
Amount float64 `json:"amount"`
}
// HPPItem represents an item in HPP section
type HPPItem struct {
ID uint `json:"id"`
Category string `json:"category"`
Code string `json:"code"`
Category string `json:"category"` // "purchase" or "overhead"
Code string `json:"code"` // "PAKAN", "OVK", "DOC", "EKSPEDISI"
Label string `json:"label"`
Budgeting FinancialMetrics `json:"budgeting"`
Realization FinancialMetrics `json:"realization"`
}
// HPPSummary represents summary for HPP section
type HPPSummary struct {
Label string `json:"label"`
Budgeting FinancialMetrics `json:"budgeting"`
@@ -50,41 +52,52 @@ type HPPSummary struct {
EggRealization *FinancialMetrics `json:"egg_realization,omitempty"`
}
// HPPSection represents HPP data section
type HPPSection struct {
Items []HPPItem `json:"items"`
Items []HPPItem `json:"items"`
Summary HPPSummary `json:"summary"`
}
// ProfitLossItem represents an item in Profit & Loss section
type ProfitLossItem struct {
Code string `json:"code"`
Code string `json:"code"` // "SALES", "PURCHASE_DOC", "OVERHEAD", "EKSPEDISI"
Label string `json:"label"`
Type string `json:"type"`
Type string `json:"type"` // "income", "purchase", "overhead"
RpPerBird float64 `json:"rp_per_bird"`
RpPerKg float64 `json:"rp_per_kg"`
Amount float64 `json:"amount"`
}
// ProfitLossSummary represents summary for Profit & Loss section
type ProfitLossSummary struct {
GrossProfit FinancialMetrics `json:"gross_profit"`
SubTotal FinancialMetrics `json:"sub_total"`
NetProfit FinancialMetrics `json:"net_profit"`
}
// ProfitLossSection represents Profit & Loss data section
type ProfitLossSection struct {
Items []ProfitLossItem `json:"items"`
Summary ProfitLossSummary `json:"summary"`
Items []ProfitLossItem `json:"items"`
Summary ProfitLossSummary `json:"summary"`
}
// ClosingKeuanganData represents the main data structure
type ClosingKeuanganData struct {
HPP HPPSection `json:"hpp"`
HPP HPPSection `json:"hpp"`
ProfitLoss ProfitLossSection `json:"profit_loss"`
}
type MetricsCalculator struct {
TotalPopulation float64
ActualPopulation float64
TotalWeightProduced float64
// ClosingKeuanganResponse represents the full API response
type ClosingKeuanganResponse struct {
Code int `json:"code"`
Status string `json:"status"`
Message string `json:"message"`
Data ClosingKeuanganData `json:"data"`
}
// === MAPPER FUNCTIONS ===
// ToFinancialMetrics creates FinancialMetrics from values
func ToFinancialMetrics(rpPerBird, rpPerKg, amount float64) FinancialMetrics {
return FinancialMetrics{
RpPerBird: rpPerBird,
@@ -93,6 +106,7 @@ func ToFinancialMetrics(rpPerBird, rpPerKg, amount float64) FinancialMetrics {
}
}
// ToHPPItem creates HPP item
func ToHPPItem(id uint, category, code, label string, budgeting, realization FinancialMetrics) HPPItem {
return HPPItem{
ID: id,
@@ -104,6 +118,7 @@ func ToHPPItem(id uint, category, code, label string, budgeting, realization Fin
}
}
// ToHPPSummary creates HPP summary
func ToHPPSummary(label string, budgeting, realization FinancialMetrics, eggBudgeting, eggRealization *FinancialMetrics) HPPSummary {
return HPPSummary{
Label: label,
@@ -114,6 +129,7 @@ func ToHPPSummary(label string, budgeting, realization FinancialMetrics, eggBudg
}
}
// ToHPPSection creates HPP section
func ToHPPSection(items []HPPItem, summary HPPSummary) HPPSection {
return HPPSection{
Items: items,
@@ -121,6 +137,7 @@ func ToHPPSection(items []HPPItem, summary HPPSummary) HPPSection {
}
}
// ToProfitLossItem creates Profit & Loss item
func ToProfitLossItem(code, label, itemType string, rpPerBird, rpPerKg, amount float64) ProfitLossItem {
return ProfitLossItem{
Code: code,
@@ -132,6 +149,7 @@ func ToProfitLossItem(code, label, itemType string, rpPerBird, rpPerKg, amount f
}
}
// ToProfitLossSummary creates Profit & Loss summary
func ToProfitLossSummary(grossProfit, subTotal, netProfit FinancialMetrics) ProfitLossSummary {
return ProfitLossSummary{
GrossProfit: grossProfit,
@@ -140,6 +158,7 @@ func ToProfitLossSummary(grossProfit, subTotal, netProfit FinancialMetrics) Prof
}
}
// ToProfitLossSection creates Profit & Loss section
func ToProfitLossSection(items []ProfitLossItem, summary ProfitLossSummary) ProfitLossSection {
return ProfitLossSection{
Items: items,
@@ -147,6 +166,7 @@ func ToProfitLossSection(items []ProfitLossItem, summary ProfitLossSummary) Prof
}
}
// ToClosingKeuanganData creates complete closing keuangan data
func ToClosingKeuanganData(hpp HPPSection, profitLoss ProfitLossSection) ClosingKeuanganData {
return ClosingKeuanganData{
HPP: hpp,
@@ -154,72 +174,12 @@ func ToClosingKeuanganData(hpp HPPSection, profitLoss ProfitLossSection) Closing
}
}
func (mc *MetricsCalculator) CalculateMetrics(amount float64) (rpPerBird, rpPerKg float64) {
if mc.ActualPopulation > 0 {
rpPerBird = amount / mc.ActualPopulation
// ToSuccessClosingKeuanganResponse creates success response
func ToSuccessClosingKeuanganResponse(data ClosingKeuanganData) ClosingKeuanganResponse {
return ClosingKeuanganResponse{
Code: 200,
Status: "success",
Message: "Get closing keuangan successfully",
Data: data,
}
if mc.TotalWeightProduced > 0 {
rpPerKg = amount / mc.TotalWeightProduced
}
return
}
func (mc *MetricsCalculator) CalculateProfitLossMetrics(amount float64) (rpPerBird, rpPerKg float64) {
if mc.TotalPopulation > 0 {
rpPerBird = amount / mc.TotalPopulation
}
if mc.TotalWeightProduced > 0 {
rpPerKg = amount / mc.TotalWeightProduced
}
return
}
type ProductFilter struct {
ProjectFlockCategory string
}
func (pf *ProductFilter) IsEggProduct(product entity.Product) bool {
for _, flag := range product.Flags {
flagName := strings.ToUpper(flag.Name)
if flagName == string(utils.FlagTelur) ||
flagName == string(utils.FlagTelurUtuh) ||
flagName == string(utils.FlagTelurPecah) ||
flagName == string(utils.FlagTelurPutih) ||
flagName == string(utils.FlagTelurRetak) {
return true
}
}
return false
}
func (pf *ProductFilter) IsChickenProduct(product entity.Product) bool {
for _, flag := range product.Flags {
flagName := strings.ToUpper(flag.Name)
if flagName == string(utils.FlagAyamAfkir) ||
flagName == string(utils.FlagAyamCulling) ||
flagName == string(utils.FlagAyamMati) {
return true
}
}
return false
}
func (pf *ProductFilter) ShouldIncludeProduct(product entity.Product) bool {
if pf.ProjectFlockCategory == string(utils.ProjectFlockCategoryLaying) {
return pf.IsEggProduct(product)
}
return pf.IsChickenProduct(product) || (!pf.IsEggProduct(product) && !pf.IsChickenProduct(product))
}
func (pf *ProductFilter) FilterDeliveryProducts(deliveries []entity.MarketingDeliveryProduct) []entity.MarketingDeliveryProduct {
filtered := make([]entity.MarketingDeliveryProduct, 0)
for _, delivery := range deliveries {
if delivery.MarketingProduct.ProductWarehouse.Product.Id == 0 {
continue
}
if pf.ShouldIncludeProduct(delivery.MarketingProduct.ProductWarehouse.Product) {
filtered = append(filtered, delivery)
}
}
return filtered
}
@@ -8,54 +8,34 @@ import (
customerDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/dto"
kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto"
productDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/dto"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
)
// === Response DTO ===
type SalesDTO struct {
Id uint `json:"id"`
RealizationDate time.Time `json:"realization_date"`
Age int `json:"age"`
Week int `json:"week"`
DoNumber string `json:"do_number"`
Product *productDTO.ProductRelationDTO `json:"product,omitempty"`
Customer *customerDTO.CustomerRelationDTO `json:"customer,omitempty"`
Qty float64 `json:"qty"`
Weight float64 `json:"weight"`
AvgWeight float64 `json:"avg_weight"`
SalesPrice float64 `json:"sales_price"`
TotalSalesPrice float64 `json:"total_sales_price"`
ActualPrice float64 `json:"actual_price"`
TotalActualPrice float64 `json:"total_actual_price"`
Kandang *kandangDTO.KandangRelationDTO `json:"kandang,omitempty"`
}
type SummaryDTO struct {
TotalSalesPrice float64 `json:"total_sales_price"`
AvgSalesPrice float64 `json:"avg_sales_price"`
TotalActualPrice float64 `json:"total_actual_price"`
AvgActualPrice float64 `json:"avg_actual_price"`
Id uint `json:"id"`
RealizationDate time.Time `json:"realization_date"`
Age int `json:"age"`
DoNumber string `json:"do_number"`
Product *productDTO.ProductRelationDTO `json:"product,omitempty"`
Customer *customerDTO.CustomerRelationDTO `json:"customer,omitempty"`
Qty float64 `json:"qty"`
Weight float64 `json:"weight"`
AvgWeight float64 `json:"avg_weight"`
Price float64 `json:"price"`
TotalPrice float64 `json:"total_price"`
Kandang *kandangDTO.KandangRelationDTO `json:"kandang,omitempty"`
PaymentStatus string `json:"payment_status"`
}
type PenjualanRealisasiResponseDTO struct {
Sales []SalesDTO `json:"sales"`
Summary SummaryDTO `json:"summary"`
Sales []SalesDTO `json:"sales"`
}
// === Mapper Functions ===
func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO {
productFlags := make([]string, len(e.MarketingProduct.ProductWarehouse.Product.Flags))
for i, f := range e.MarketingProduct.ProductWarehouse.Product.Flags {
productFlags[i] = f.Name
}
var category string
if e.MarketingProduct.ProductWarehouse.ProjectFlockKandang != nil {
category = e.MarketingProduct.ProductWarehouse.ProjectFlockKandang.ProjectFlock.Category
}
ageInDay, ageInWeeks := calculateAgeFromChickin(e.MarketingProduct.ProductWarehouse.ProjectFlockKandang, e.DeliveryDate, productFlags, category)
age := calculateAgeFromChickin(e.MarketingProduct.ProductWarehouse.ProjectFlockKandang, e.DeliveryDate)
var product *productDTO.ProductRelationDTO
if e.MarketingProduct.ProductWarehouse.Product.Id != 0 {
@@ -83,42 +63,19 @@ func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO {
doNumber := deliveryOrdersDTO.GenerateDeliveryOrderNumber(e.MarketingProduct.Marketing.SoNumber, e.DeliveryDate, e.MarketingProduct.ProductWarehouse.Warehouse.Id)
return SalesDTO{
Id: e.Id,
RealizationDate: realizationDate,
Age: ageInDay,
Week: ageInWeeks,
DoNumber: doNumber,
Product: product,
Customer: customer,
Qty: e.UsageQty,
Weight: e.TotalWeight,
AvgWeight: e.AvgWeight,
SalesPrice: e.MarketingProduct.UnitPrice,
TotalSalesPrice: e.MarketingProduct.TotalPrice,
ActualPrice: e.UnitPrice,
TotalActualPrice: e.TotalPrice,
Kandang: kandang,
}
}
func ToSummaryDto(e []entity.MarketingDeliveryProduct) SummaryDTO {
var totalSalesPrice, totalActualPrice, sumSales, sumActual float64
count := len(e)
for _, item := range e {
totalSalesPrice += item.MarketingProduct.TotalPrice
totalActualPrice += item.TotalPrice
sumSales += item.MarketingProduct.UnitPrice
sumActual += item.UnitPrice
}
return SummaryDTO{
TotalSalesPrice: totalSalesPrice,
TotalActualPrice: totalActualPrice,
AvgSalesPrice: sumSales / float64(count),
AvgActualPrice: sumActual / float64(count),
Id: e.Id,
RealizationDate: realizationDate,
Age: age,
DoNumber: doNumber,
Product: product,
Customer: customer,
Qty: e.UsageQty,
Weight: e.TotalWeight,
AvgWeight: e.AvgWeight,
Price: e.UnitPrice,
TotalPrice: e.TotalPrice,
Kandang: kandang,
PaymentStatus: "Paid",
}
}
@@ -130,36 +87,29 @@ func ToSalesDTOs(e []entity.MarketingDeliveryProduct) []SalesDTO {
return result
}
func ToPenjualanRealisasiResponseDTO(e []entity.MarketingDeliveryProduct) PenjualanRealisasiResponseDTO {
func ToPenjualanRealisasiResponseDTO(projectFlockID uint, e []entity.MarketingDeliveryProduct) PenjualanRealisasiResponseDTO {
return PenjualanRealisasiResponseDTO{
Sales: ToSalesDTOs(e),
Summary: ToSummaryDto(e),
Sales: ToSalesDTOs(e),
}
}
func calculateAgeFromChickin(projectFlockKandang *entity.ProjectFlockKandang, deliveryDate *time.Time, productFlags []string, category string) (int, int) {
if projectFlockKandang == nil || deliveryDate == nil || len(projectFlockKandang.Chickins) == 0 {
return 0, 0
}
for _, flag := range productFlags {
if flag == string(utils.FlagOVK) ||
flag == string(utils.FlagPakan) ||
flag == string(utils.FlagPreStarter) ||
flag == string(utils.FlagStarter) ||
flag == string(utils.FlagFinisher) ||
flag == string(utils.FlagObat) ||
flag == string(utils.FlagVitamin) ||
flag == string(utils.FlagKimia) ||
flag == string(utils.FlagEkspedisi) ||
flag == string(utils.FlagTelur) ||
flag == string(utils.FlagTelurUtuh) ||
flag == string(utils.FlagTelurPecah) ||
flag == string(utils.FlagTelurPutih) ||
flag == string(utils.FlagTelurRetak) {
return 0, 0
func extractPeriodFromRealisasi(realisasi []entity.MarketingDeliveryProduct) int {
if len(realisasi) > 0 {
for _, item := range realisasi {
if item.MarketingProduct.ProductWarehouse.ProjectFlockKandang != nil {
return item.MarketingProduct.ProductWarehouse.ProjectFlockKandang.Period
}
}
}
return 0
}
func calculateAgeFromChickin(projectFlockKandang *entity.ProjectFlockKandang, deliveryDate *time.Time) int {
if projectFlockKandang == nil || deliveryDate == nil || len(projectFlockKandang.Chickins) == 0 {
return 0
}
earliestChickinDate := projectFlockKandang.Chickins[0].ChickInDate
for _, chickin := range projectFlockKandang.Chickins {
@@ -168,20 +118,7 @@ func calculateAgeFromChickin(projectFlockKandang *entity.ProjectFlockKandang, de
}
}
diff := deliveryDate.Sub(earliestChickinDate)
ageInDays := int(diff.Hours() / 24)
var ageInWeeks int
if ageInDays <= 0 {
ageInWeeks = 0
} else {
if category == string(utils.ProjectFlockCategoryLaying) {
ageInDays = ageInDays + 119
ageInWeeks = ((ageInDays - 1) / 7) + 1
} else {
ageInWeeks = ((ageInDays - 1) / 7) + 1
}
}
return ageInDays, ageInWeeks
ageInDays := int(deliveryDate.Sub(earliestChickinDate).Hours() / 24)
ageInWeeks := ageInDays / 7
return ageInWeeks
}
@@ -114,17 +114,6 @@ type ClosingSapronakDTO struct {
OutgoingSapronak []ClosingSapronakItemDTO `json:"outgoing_sapronak"`
}
type ClosingSapronakSummaryItemDTO struct {
Category string `json:"category"`
TotalQty int64 `json:"total_qty"`
Uom UomSummaryDTO `json:"uom"`
}
type UomSummaryDTO struct {
ID uint `json:"id"`
Name string `json:"name"`
}
// === Mapper Functions for Aggregated Sapronak Response ===
func ToSapronakProjectAggregatedFromReports(reports []SapronakReportDTO, flag string) SapronakProjectAggregatedDTO {
@@ -196,11 +185,7 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
}
for idx, item := range group.Items {
refKey := strings.TrimSpace(item.NoReferensi)
productKey := strings.ToUpper(flagKey + "|" + item.ProductName + "|" + refKey)
if refKey == "" {
productKey = strings.ToUpper(flagKey + "|" + item.ProductName + "|" + formatDate(item.Tanggal))
}
productKey := strings.ToUpper(flagKey + "|" + item.ProductName)
baseRow := SapronakCategoryRowDTO{
ID: idx + 1,
Date: formatDate(item.Tanggal),
@@ -216,51 +201,18 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
switch strings.ToLower(item.JenisTransaksi) {
case "pembelian", "adjustment masuk", "mutasi masuk":
row.QtyIn += item.QtyMasuk
if item.Tanggal != nil {
row.Date = formatDate(item.Tanggal)
}
if row.UnitPrice == 0 {
if item.QtyMasuk > 0 && item.Nilai > 0 {
row.UnitPrice = item.Nilai / item.QtyMasuk
} else if item.Harga > 0 {
row.UnitPrice = item.Harga
}
}
if strings.ToLower(item.JenisTransaksi) == "mutasi masuk" {
ref := strings.ToUpper(strings.TrimSpace(item.NoReferensi))
if strings.HasPrefix(ref, "TL-") {
row.Notes = "TRANSFER LAYING"
} else if strings.HasPrefix(ref, "ST-") {
row.Notes = "TRANSFER STOCK"
}
}
row.TotalAmount += item.Nilai
case "pemakaian", "adjustment keluar":
price := row.UnitPrice
if price == 0 {
price = item.Harga
}
row.QtyUsed += item.QtyKeluar
row.TotalAmount += item.QtyKeluar * price
case "mutasi keluar", "penjualan":
price := row.UnitPrice
if price == 0 {
price = item.Harga
}
case "mutasi keluar":
row.QtyOut += item.QtyKeluar
if strings.ToLower(item.JenisTransaksi) == "mutasi keluar" {
ref := strings.ToUpper(strings.TrimSpace(item.NoReferensi))
if strings.HasPrefix(ref, "TL-") {
row.Notes = "TRANSFER LAYING"
} else if strings.HasPrefix(ref, "ST-") {
row.Notes = "TRANSFER STOCK"
}
}
default:
row.QtyIn += item.QtyMasuk
row.TotalAmount += item.Nilai
if row.QtyIn > 0 {
row.UnitPrice = row.TotalAmount / row.QtyIn
}
}
if row.QtyIn > 0 {
row.UnitPrice = row.TotalAmount / row.QtyIn
}
}
@@ -281,8 +233,8 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
total += r.TotalAmount
}
avg := 0.0
if qtyUsed > 0 {
avg = total / qtyUsed
if qtyIn > 0 {
avg = total / qtyIn
}
cat.Total = SapronakCategoryTotalDTO{
Label: label,
+2 -3
View File
@@ -25,6 +25,7 @@ type ClosingModule struct{}
func (ClosingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) {
closingRepo := rClosing.NewClosingRepository(db)
closingKeuanganRepo := rClosing.NewClosingKeuanganRepository(db)
userRepo := rUser.NewUserRepository(db)
projectFlockRepo := rProjectFlock.NewProjectflockRepository(db)
projectFlockKandangRepo := rProjectFlock.NewProjectFlockKandangRepository(db)
@@ -39,11 +40,9 @@ func (ClosingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *
purchaseRepo := rPurchase.NewPurchaseRepository(db)
approvalRepo := commonRepo.NewApprovalRepository(db)
approvalService := commonSvc.NewApprovalService(approvalRepo)
hppCostRepo := commonRepo.NewHppCostRepository(db)
hppService := commonSvc.NewHppService(hppCostRepo)
closingService := sClosing.NewClosingService(closingRepo, projectFlockRepo, projectFlockKandangRepo, marketingRepo, marketingDeliveryProductRepo, approvalService, expenseRealizationRepo, projectBudgetRepo, chickinRepo, purchaseRepo, recordingRepo, standardGrowthDetailRepo, productionStandardDetailRepo, validate)
closingKeuanganService := sClosing.NewClosingKeuanganService(projectFlockRepo, projectFlockKandangRepo, marketingDeliveryProductRepo, expenseRealizationRepo, projectBudgetRepo, chickinRepo, recordingRepo, hppService, hppCostRepo)
closingKeuanganService := sClosing.NewClosingKeuanganService(closingKeuanganRepo, projectFlockRepo, projectFlockKandangRepo, marketingDeliveryProductRepo, expenseRealizationRepo, projectBudgetRepo, chickinRepo, recordingRepo)
sapronakService := sClosing.NewSapronakService(closingRepo, projectFlockKandangRepo, validate)
userService := sUser.NewUserService(userRepo, validate)
@@ -17,7 +17,6 @@ import (
type ClosingRepository interface {
repository.BaseRepository[entity.ProjectFlock]
GetSapronak(ctx context.Context, params SapronakQueryParams) ([]SapronakRow, int64, error)
GetSapronakSummary(ctx context.Context, params SapronakQueryParams) ([]SapronakSummaryRow, error)
SumFeedPurchaseAndUsedByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) (float64, float64, error)
SumProjectChickinUsageByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) (float64, error)
SumClaimCullingByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) (float64, error)
@@ -32,10 +31,8 @@ type ClosingRepository interface {
FetchSapronakUsageDetails(ctx context.Context, pfkID uint) (map[uint][]SapronakDetailRow, error)
FetchSapronakChickinUsage(ctx context.Context, pfkID uint) ([]SapronakUsageRow, error)
FetchSapronakChickinUsageDetails(ctx context.Context, pfkID uint) (map[uint][]SapronakDetailRow, error)
FetchSapronakUsageAllocatedDetails(ctx context.Context, projectFlockKandangID uint) (map[uint][]SapronakDetailRow, error)
FetchSapronakAdjustments(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error)
FetchSapronakTransfers(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error)
FetchSapronakSales(ctx context.Context, projectFlockKandangID uint) (map[uint][]SapronakDetailRow, error)
GetProductsWithFlagsByIDs(ctx context.Context, productIDs []uint) ([]entity.Product, error)
}
@@ -62,18 +59,10 @@ type SapronakRow struct {
DestinationWarehouse string `gorm:"column:destination_warehouse"`
Destination string `gorm:"column:destination"`
Quantity float64 `gorm:"column:quantity"`
UnitID uint `gorm:"column:unit_id"`
Unit string `gorm:"column:unit"`
Notes string `gorm:"column:notes"`
}
type SapronakSummaryRow struct {
Category string `gorm:"column:category"`
TotalQty int64 `gorm:"column:total_qty"`
UomID uint `gorm:"column:uom_id"`
UomName string `gorm:"column:uom_name"`
}
type ExpeditionHPPRow struct {
SupplierName string `gorm:"column:supplier_name"`
TotalAmount float64 `gorm:"column:total_amount"`
@@ -85,7 +74,6 @@ type SapronakQueryParams struct {
ProjectFlockKandangIDs []uint
Limit int
Offset int
Search string
}
func (r *ClosingRepositoryImpl) GetSapronak(ctx context.Context, params SapronakQueryParams) ([]SapronakRow, int64, error) {
@@ -121,36 +109,14 @@ func (r *ClosingRepositoryImpl) GetSapronak(ctx context.Context, params Sapronak
unionSQL := strings.Join(unionParts, " UNION ALL ")
search := strings.TrimSpace(params.Search)
searchClause := ""
var searchArgs []any
if search != "" {
searchClause = `
WHERE (
reference_number ILIKE ?
OR product_name ILIKE ?
OR product_category ILIKE ?
OR source_warehouse ILIKE ?
OR destination_warehouse ILIKE ?
OR CAST(quantity AS TEXT) ILIKE ?
OR unit ILIKE ?
OR notes ILIKE ?
OR transaction_type ILIKE ?
)`
like := "%" + search + "%"
searchArgs = append(searchArgs, like, like, like, like, like, like, like, like, like)
}
var totalResults int64
countSQL := fmt.Sprintf("SELECT COUNT(*) FROM (%s) AS combined%s", unionSQL, searchClause)
countArgs := append(append([]any{}, args...), searchArgs...)
if err := db.Raw(countSQL, countArgs...).Scan(&totalResults).Error; err != nil {
countSQL := fmt.Sprintf("SELECT COUNT(*) FROM (%s) AS combined", unionSQL)
if err := db.Raw(countSQL, args...).Scan(&totalResults).Error; err != nil {
return nil, 0, err
}
dataArgs := append(append([]any{}, args...), searchArgs...)
dataArgs = append(dataArgs, params.Limit, params.Offset)
dataSQL := fmt.Sprintf("SELECT * FROM (%s) AS combined%s ORDER BY sort_date ASC, id ASC LIMIT ? OFFSET ?", unionSQL, searchClause)
dataArgs := append(append([]any{}, args...), params.Limit, params.Offset)
dataSQL := fmt.Sprintf("SELECT * FROM (%s) AS combined ORDER BY sort_date ASC, id ASC LIMIT ? OFFSET ?", unionSQL)
var rows []SapronakRow
if err := db.Raw(dataSQL, dataArgs...).Scan(&rows).Error; err != nil {
@@ -160,79 +126,6 @@ func (r *ClosingRepositoryImpl) GetSapronak(ctx context.Context, params Sapronak
return rows, totalResults, nil
}
func (r *ClosingRepositoryImpl) GetSapronakSummary(ctx context.Context, params SapronakQueryParams) ([]SapronakSummaryRow, error) {
db := r.DB().WithContext(ctx)
var (
unionParts []string
args []any
)
switch params.Type {
case validation.SapronakTypeIncoming:
if len(params.WarehouseIDs) == 0 {
return []SapronakSummaryRow{}, nil
}
unionParts = append(unionParts, sapronakIncomingPurchasesSQL, sapronakIncomingTransfersSQL)
args = append(args, params.WarehouseIDs, params.WarehouseIDs)
case validation.SapronakTypeOutgoing:
if len(params.WarehouseIDs) > 0 {
unionParts = append(unionParts, sapronakOutgoingTransfersSQL)
args = append(args, params.WarehouseIDs)
}
if len(params.ProjectFlockKandangIDs) > 0 {
unionParts = append(unionParts, sapronakOutgoingMarketingsSQL)
args = append(args, params.ProjectFlockKandangIDs)
}
if len(unionParts) == 0 {
return []SapronakSummaryRow{}, nil
}
default:
return nil, fmt.Errorf("invalid sapronak type: %s", params.Type)
}
unionSQL := strings.Join(unionParts, " UNION ALL ")
search := strings.TrimSpace(params.Search)
searchClause := ""
var searchArgs []any
if search != "" {
searchClause = `
WHERE (
reference_number ILIKE ?
OR product_name ILIKE ?
OR product_category ILIKE ?
OR source_warehouse ILIKE ?
OR destination_warehouse ILIKE ?
OR CAST(quantity AS TEXT) ILIKE ?
OR unit ILIKE ?
OR notes ILIKE ?
OR transaction_type ILIKE ?
)`
like := "%" + search + "%"
searchArgs = append(searchArgs, like, like, like, like, like, like, like, like, like)
}
querySQL := fmt.Sprintf(`
SELECT
product_category AS category,
CAST(COALESCE(SUM(quantity), 0) AS BIGINT) AS total_qty,
unit_id AS uom_id,
unit AS uom_name
FROM (%s) AS combined%s
GROUP BY product_category, unit_id, unit
ORDER BY product_category ASC, unit ASC
`, unionSQL, searchClause)
queryArgs := append(append([]any{}, args...), searchArgs...)
var rows []SapronakSummaryRow
if err := db.Raw(querySQL, queryArgs...).Scan(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
func (r *ClosingRepositoryImpl) SumFeedPurchaseAndUsedByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) (float64, float64, error) {
if len(projectFlockKandangIDs) == 0 {
return 0, 0, nil
@@ -486,7 +379,6 @@ SELECT
w.name AS destination_warehouse,
'' AS destination,
pi.total_qty AS quantity,
u.id AS unit_id,
u.name AS unit,
COALESCE(p.notes, '') AS notes
FROM purchase_items pi
@@ -535,7 +427,6 @@ SELECT
COALESCE(tw.name, '') AS destination_warehouse,
'' AS destination,
std.usage_qty AS quantity,
u.id AS unit_id,
u.name AS unit,
'Stock Refill' AS notes
FROM stock_transfer_details std
@@ -585,7 +476,6 @@ SELECT
COALESCE(tw.name, '') AS destination_warehouse,
'' AS destination,
std.usage_qty AS quantity,
u.id AS unit_id,
u.name AS unit,
'Transfer to other unit' AS notes
FROM stock_transfer_details std
@@ -632,15 +522,13 @@ SELECT
WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id
), '') AS product_sub_category,
w.name AS source_warehouse,
COALESCE(c.name, '') AS destination_warehouse,
'RETAIL CUSTOMER' AS destination_warehouse,
'' AS destination,
mp.qty AS quantity,
u.id AS unit_id,
u.name AS unit,
m.notes AS notes
FROM marketing_products mp
JOIN marketings m ON m.id = mp.marketing_id
LEFT JOIN customers c ON c.id = m.customer_id
JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id
JOIN products prod ON prod.id = pw.product_id
JOIN uoms u ON u.id = prod.uom_id
@@ -709,23 +597,6 @@ var (
sapronakFlagsChickin = sapronakFlags(utils.FlagDOC, utils.FlagPullet)
)
func (r *ClosingRepositoryImpl) joinSapronakProductFlag(db *gorm.DB, productAlias string) *gorm.DB {
subquery := r.DB().
Table("flags").
Select("DISTINCT ON (flagable_id) flagable_id, name").
Where("flagable_type = ?", entity.FlagableTypeProduct).
Where("name IN ?", sapronakFlagsAll).
Order(fmt.Sprintf(
"flagable_id, CASE WHEN name = '%s' THEN 1 WHEN name = '%s' THEN 2 WHEN name = '%s' THEN 3 WHEN name = '%s' THEN 4 ELSE 5 END",
utils.FlagDOC,
utils.FlagPullet,
utils.FlagPakan,
utils.FlagOVK,
))
return db.Joins("JOIN (?) f ON f.flagable_id = "+productAlias+".id", subquery)
}
func groupSapronakDetails(rows []SapronakDetailRow) map[uint][]SapronakDetailRow {
m := make(map[uint][]SapronakDetailRow)
for _, row := range rows {
@@ -762,12 +633,11 @@ func (r *ClosingRepositoryImpl) usageQuery(
COALESCE(p.product_price, 0) AS default_price
`)
db = applyJoins(db, joins...)
db = db.
return db.
Joins("JOIN product_warehouses pw ON "+pwJoinCond).
Joins("JOIN products p ON p.id = pw.product_id").
Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct).
Where(where, args...)
db = r.joinSapronakProductFlag(db, "p")
return db
}
func (r *ClosingRepositoryImpl) fetchSapronakUsage(
@@ -798,10 +668,10 @@ func (r *ClosingRepositoryImpl) detailQuery(
db := r.withCtx(ctx).
Table(table).
Joins("JOIN product_warehouses pw ON "+pwJoinCond).
Joins("JOIN products p ON p.id = pw.product_id")
Joins("JOIN products p ON p.id = pw.product_id").
Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct)
db = applyJoins(db, joins...)
db = r.joinSapronakProductFlag(db, "p")
return db.Select(selectSQL).Where(where, args...)
}
@@ -891,84 +761,16 @@ func (r *ClosingRepositoryImpl) FetchSapronakChickinUsageDetails(ctx context.Con
)
}
func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.Context, projectFlockKandangID uint) (map[uint][]SapronakDetailRow, error) {
if projectFlockKandangID == 0 {
return map[uint][]SapronakDetailRow{}, nil
}
query := r.withCtx(ctx).
Table("stock_allocations AS sa").
Select(`
pw.product_id AS product_id,
p.name AS product_name,
f.name AS flag,
COALESCE(
pi.received_date,
st.transfer_date,
lt.transfer_date,
sl.created_at,
pc.chick_in_date,
r.record_datetime
) AS date,
COALESCE(
po.po_number,
st.movement_number,
lt.transfer_number,
CONCAT('ADJ-', ast.id),
CONCAT('CHICKIN-', pc.id),
CAST(r.id AS TEXT),
''
) AS reference,
0 AS qty_in,
COALESCE(SUM(sa.qty), 0) AS qty_out,
COALESCE(pi.price, p.product_price, 0) AS price
`).
Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id").
Joins("JOIN products p ON p.id = pw.product_id").
Joins("LEFT JOIN recording_stocks rs ON rs.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyRecordingStock.String()).
Joins("LEFT JOIN recordings r ON r.id = rs.recording_id").
Joins("LEFT JOIN project_chickins pc_used ON pc_used.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyProjectChickin.String()).
Joins("LEFT JOIN purchase_items pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyPurchaseItems.String()).
Joins("LEFT JOIN purchases po ON po.id = pi.purchase_id").
Joins("LEFT JOIN stock_transfer_details std ON std.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyStockTransferIn.String()).
Joins("LEFT JOIN stock_transfers st ON st.id = std.stock_transfer_id").
Joins("LEFT JOIN laying_transfer_targets ltt ON ltt.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyTransferToLayingIn.String()).
Joins("LEFT JOIN laying_transfers lt ON lt.id = ltt.laying_transfer_id").
Joins("LEFT JOIN adjustment_stocks ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyAdjustmentIn.String()).
Joins("LEFT JOIN stock_logs sl ON sl.id = ast.stock_log_id").
Joins("LEFT JOIN project_flock_populations pfp ON pfp.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyProjectFlockPopulation.String()).
Joins("LEFT JOIN project_chickins pc ON pc.id = pfp.project_chickin_id").
Where("sa.status = ?", entity.StockAllocationStatusActive).
Where("f.name IN ?", sapronakFlagsAll).
Where(`
(sa.usable_type = ? AND r.project_flock_kandangs_id = ?)
OR
(sa.usable_type = ? AND pc_used.project_flock_kandang_id = ?)
`,
fifo.UsableKeyRecordingStock.String(), projectFlockKandangID,
fifo.UsableKeyProjectChickin.String(), projectFlockKandangID,
)
query = r.joinSapronakProductFlag(query, "p").
Group(`
pw.product_id, p.name, f.name,
pi.received_date, st.transfer_date, lt.transfer_date, sl.created_at, pc.chick_in_date, r.record_datetime,
po.po_number, st.movement_number, lt.transfer_number, ast.id, pc.id, r.id,
pi.price, p.product_price
`)
return scanAndGroupDetails(query)
}
func (r *ClosingRepositoryImpl) incomingPurchaseBase(ctx context.Context, kandangID uint) *gorm.DB {
db := r.withCtx(ctx).
return r.withCtx(ctx).
Table("purchase_items AS pi").
Joins("JOIN purchases po ON po.id = pi.purchase_id AND po.deleted_at IS NULL").
Joins("JOIN products p ON p.id = pi.product_id").
Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct).
Joins("JOIN warehouses w ON w.id = pi.warehouse_id").
Where("w.kandang_id = ?", kandangID).
Where("f.name IN ?", sapronakFlagsAll).
Where("pi.received_date IS NOT NULL")
return r.joinSapronakProductFlag(db, "p")
}
func (r *ClosingRepositoryImpl) FetchSapronakIncoming(ctx context.Context, kandangID uint) ([]SapronakIncomingRow, error) {
@@ -1039,10 +841,10 @@ func (r *ClosingRepositoryImpl) fetchStockLogs(ctx context.Context, kandangID ui
`).
Joins("JOIN product_warehouses pw ON pw.id = sl.product_warehouse_id").
Joins("JOIN products p ON p.id = pw.product_id").
Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct).
Joins("JOIN warehouses w ON w.id = pw.warehouse_id")
db = applyJoins(db, joins...)
db = r.joinSapronakProductFlag(db, "p")
if err := db.
Where("sl.loggable_type = ?", logType).
@@ -1107,50 +909,17 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand
COALESCE(p.product_price, 0) AS price
`).
Joins("JOIN stock_transfers st ON st.id = std.stock_transfer_id").
Joins("LEFT JOIN warehouses fw ON fw.id = st.from_warehouse_id").
Joins("JOIN product_warehouses pw ON pw.id = std.dest_product_warehouse_id").
Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
Joins("JOIN products p ON p.id = std.product_id").
Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct).
Where("w.kandang_id = ?", kandangID).
Where("(fw.kandang_id IS NULL OR fw.kandang_id <> w.kandang_id)").
Where("f.name IN ?", sapronakFlagsAll)
incomingQuery = r.joinSapronakProductFlag(incomingQuery, "p")
incoming, err := scanAndGroupDetails(incomingQuery)
if err != nil {
return nil, nil, err
}
incomingLayingQuery := r.withCtx(ctx).
Table("laying_transfer_targets AS ltt").
Select(`
pw.product_id AS product_id,
p.name AS product_name,
f.name AS flag,
lt.transfer_date::timestamp AS date,
COALESCE(lt.transfer_number, '') AS reference,
COALESCE(ltt.total_qty, 0) AS qty_in,
0 AS qty_out,
COALESCE(p.product_price, 0) AS price
`).
Joins("JOIN laying_transfers lt ON lt.id = ltt.laying_transfer_id").
Joins("LEFT JOIN laying_transfer_sources lts ON lts.laying_transfer_id = lt.id").
Joins("LEFT JOIN product_warehouses pw_source ON pw_source.id = lts.product_warehouse_id").
Joins("LEFT JOIN warehouses w_source ON w_source.id = pw_source.warehouse_id").
Joins("JOIN product_warehouses pw ON pw.id = ltt.product_warehouse_id").
Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
Joins("JOIN products p ON p.id = pw.product_id").
Where("w.kandang_id = ?", kandangID).
Where("(w_source.kandang_id IS NULL OR w_source.kandang_id <> w.kandang_id)").
Where("f.name IN ?", sapronakFlagsAll)
incomingLayingQuery = r.joinSapronakProductFlag(incomingLayingQuery, "p")
incomingLaying, err := scanAndGroupDetails(incomingLayingQuery)
if err != nil {
return nil, nil, err
}
for pid, rows := range incomingLaying {
incoming[pid] = append(incoming[pid], rows...)
}
outgoingQuery := r.withCtx(ctx).
Table("stock_allocations AS sa").
Select(`
@@ -1167,125 +936,20 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand
Joins("JOIN stock_transfers st ON st.id = std.stock_transfer_id").
Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id").
Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
Joins("LEFT JOIN product_warehouses pw_dest ON pw_dest.id = std.dest_product_warehouse_id").
Joins("LEFT JOIN warehouses w_dest ON w_dest.id = pw_dest.warehouse_id").
Joins("JOIN products p ON p.id = std.product_id").
Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct).
Where("sa.status = ?", entity.StockAllocationStatusActive).
Where("w.kandang_id = ?", kandangID).
Where("(w_dest.kandang_id IS NULL OR w_dest.kandang_id <> w.kandang_id)").
Where("f.name IN ?", sapronakFlagsAll).
Group("std.id, std.product_id, p.name, f.name, st.transfer_date, st.movement_number, p.product_price")
outgoingQuery = r.joinSapronakProductFlag(outgoingQuery, "p")
outgoing, err := scanAndGroupDetails(outgoingQuery)
if err != nil {
return nil, nil, err
}
outgoingLayingQuery := r.withCtx(ctx).
Table("stock_allocations AS sa").
Select(`
pw.product_id AS product_id,
p.name AS product_name,
f.name AS flag,
lt.transfer_date::timestamp AS date,
COALESCE(lt.transfer_number, '') AS reference,
0 AS qty_in,
COALESCE(SUM(sa.qty), 0) AS qty_out,
COALESCE(p.product_price, 0) AS price
`).
Joins("JOIN laying_transfer_sources lts ON lts.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyTransferToLayingOut.String()).
Joins("JOIN laying_transfers lt ON lt.id = lts.laying_transfer_id").
Joins("LEFT JOIN laying_transfer_targets ltt ON ltt.laying_transfer_id = lt.id").
Joins("LEFT JOIN product_warehouses pw_dest ON pw_dest.id = ltt.product_warehouse_id").
Joins("LEFT JOIN warehouses w_dest ON w_dest.id = pw_dest.warehouse_id").
Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id").
Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
Joins("JOIN products p ON p.id = pw.product_id").
Where("sa.status = ?", entity.StockAllocationStatusActive).
Where("w.kandang_id = ?", kandangID).
Where("(w_dest.kandang_id IS NULL OR w_dest.kandang_id <> w.kandang_id)").
Where("f.name IN ?", sapronakFlagsAll).
Group("lts.id, pw.product_id, p.name, f.name, lt.transfer_date, lt.transfer_number, p.product_price")
outgoingLayingQuery = r.joinSapronakProductFlag(outgoingLayingQuery, "p")
outgoingLaying, err := scanAndGroupDetails(outgoingLayingQuery)
if err != nil {
return nil, nil, err
}
for pid, rows := range outgoingLaying {
outgoing[pid] = append(outgoing[pid], rows...)
}
return incoming, outgoing, nil
}
func (r *ClosingRepositoryImpl) FetchSapronakSales(ctx context.Context, projectFlockKandangID uint) (map[uint][]SapronakDetailRow, error) {
query := r.withCtx(ctx).
Table("stock_allocations AS sa").
Select(`
pw.product_id AS product_id,
p.name AS product_name,
f.name AS flag,
COALESCE(mdp.delivery_date, mdp.created_at) AS date,
COALESCE(m.so_number, '') AS reference,
0 AS qty_in,
COALESCE(SUM(sa.qty), 0) AS qty_out,
COALESCE(mdp.unit_price, mp.unit_price, 0) AS price
`).
Joins("JOIN marketing_delivery_products mdp ON mdp.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyMarketingDelivery.String()).
Joins("JOIN marketing_products mp ON mp.id = mdp.marketing_product_id").
Joins("JOIN marketings m ON m.id = mp.marketing_id").
Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id").
Joins("JOIN products p ON p.id = pw.product_id").
Where("sa.status = ?", entity.StockAllocationStatusActive).
Where("pw.project_flock_kandang_id = ?", projectFlockKandangID).
Where("f.name IN ?", sapronakFlagsAll).
Group("mdp.id, pw.product_id, p.name, f.name, mdp.delivery_date, mdp.created_at, m.so_number, mdp.unit_price, mp.unit_price")
query = r.joinSapronakProductFlag(query, "p")
sales, err := scanAndGroupDetails(query)
if err != nil {
return nil, err
}
nonFifoQuery := r.withCtx(ctx).
Table("marketing_delivery_products AS mdp").
Select(`
pw.product_id AS product_id,
p.name AS product_name,
f.name AS flag,
COALESCE(mdp.delivery_date, mdp.created_at) AS date,
COALESCE(m.so_number, '') AS reference,
0 AS qty_in,
COALESCE(mdp.usage_qty, 0) AS qty_out,
COALESCE(mdp.unit_price, mp.unit_price, 0) AS price
`).
Joins("JOIN marketing_products mp ON mp.id = mdp.marketing_product_id").
Joins("JOIN marketings m ON m.id = mp.marketing_id").
Joins("JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id").
Joins("JOIN products p ON p.id = pw.product_id").
Joins("LEFT JOIN stock_allocations sa ON sa.usable_id = mdp.id AND sa.usable_type = ? AND sa.status = ?",
fifo.UsableKeyMarketingDelivery.String(),
entity.StockAllocationStatusActive,
).
Where("mdp.usage_qty > 0").
Where("sa.id IS NULL").
Where("pw.project_flock_kandang_id = ?", projectFlockKandangID).
Where("f.name IN ?", sapronakFlagsAll).
Group("mdp.id, pw.product_id, p.name, f.name, mdp.delivery_date, mdp.created_at, m.so_number, mdp.unit_price, mp.unit_price")
nonFifoQuery = r.joinSapronakProductFlag(nonFifoQuery, "p")
nonFifoSales, err := scanAndGroupDetails(nonFifoQuery)
if err != nil {
return nil, err
}
for pid, rows := range nonFifoSales {
sales[pid] = append(sales[pid], rows...)
}
return sales, nil
}
func (r *ClosingRepositoryImpl) GetProductsWithFlagsByIDs(ctx context.Context, productIDs []uint) ([]entity.Product, error) {
if len(productIDs) == 0 {
return []entity.Product{}, nil
@@ -0,0 +1,365 @@
package repository
import (
"context"
"fmt"
"sort"
"strings"
"gitlab.com/mbugroup/lti-api.git/internal/common/repository"
"gorm.io/gorm"
)
// ClosingKeuanganRepository handles database operations for closing keuangan
type ClosingKeuanganRepository interface {
repository.BaseRepository[interface{}]
// All Product Usage
GetAllProductUsageByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint, flagFilters []string) ([]ProductUsageRow, error)
// Depletion per kandang
GetTotalDepletionByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error)
// Weight produced from uniformity per kandang
GetTotalWeightProducedFromUniformityByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error)
// DB returns the underlying GORM DB instance
DB() *gorm.DB
}
type ClosingKeuanganRepositoryImpl struct {
*repository.BaseRepositoryImpl[interface{}]
}
func NewClosingKeuanganRepository(db *gorm.DB) ClosingKeuanganRepository {
return &ClosingKeuanganRepositoryImpl{
BaseRepositoryImpl: repository.NewBaseRepository[interface{}](db),
}
}
// Result Rows
type ProductUsageRow struct {
ProductID uint `gorm:"column:product_id"`
ProductName string `gorm:"column:product_name"`
FlagNames string `gorm:"column:flag_names"`
TotalQty float64 `gorm:"column:total_qty"`
Price float64 `gorm:"column:price"`
TotalPengeluaran float64 `gorm:"column:total_pengeluaran"`
}
// GetAllProductUsageByProjectFlockKandangID gets all product usage for a project flock kandang
// Combines data from all usable types: recordings, chickins, marketing, transfers, adjustments
// flagFilters: optional filter to get only specific flags (e.g., ["PAKAN", "OVK"]), empty means get all
func (r *ClosingKeuanganRepositoryImpl) GetAllProductUsageByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint, flagFilters []string) ([]ProductUsageRow, error) {
if projectFlockKandangID == 0 {
return []ProductUsageRow{}, nil
}
type SubQueryResult struct {
ProductID uint `gorm:"column:product_id"`
ProductName string `gorm:"column:product_name"`
TotalQty float64 `gorm:"column:total_qty"`
Price float64 `gorm:"column:price"`
}
type AggregatedResult struct {
ProductID uint `gorm:"column:product_id"`
ProductName string `gorm:"column:product_name"`
TotalQty float64 `gorm:"column:total_qty"`
Price float64 `gorm:"column:price"`
PriceCount int `gorm:"-"` // For calculating average price
}
type FlagResult struct {
ProductID uint `gorm:"column:product_id"`
FlagNames string `gorm:"column:flag_names"`
}
var allResults []SubQueryResult
// Subquery 1: Recordings
var recordingsResults []SubQueryResult
err := r.DB().WithContext(ctx).
Table("recordings r").
Select("pw.product_id, p.name as product_name, "+
"COALESCE(SUM(CASE "+
"WHEN sa.stockable_type = 'PURCHASE_ITEMS' THEN COALESCE(sa.qty, 0) "+
"WHEN sa.stockable_type = 'STOCK_TRANSFER_IN' THEN COALESCE(std.usage_qty, 0) "+
"WHEN sa.stockable_type = 'TRANSFERTOLAYING_IN' THEN COALESCE(ltt.total_used, 0) "+
"WHEN sa.stockable_type = 'ADJUSTMENT_IN' THEN COALESCE(adjs.total_used, 0) "+
"WHEN sa.stockable_type = 'PROJECT_FLOCK_POPULATION' THEN COALESCE(pfp.total_used_qty, 0) "+
"ELSE 0 END), 0) as total_qty, "+
"COALESCE(AVG(CASE WHEN sa.stockable_type = 'PURCHASE_ITEMS' THEN COALESCE(pi.price, 0) END), 0) as price").
Joins("JOIN recording_stocks rs ON rs.recording_id = r.id").
Joins("JOIN product_warehouses pw ON pw.id = rs.product_warehouse_id").
Joins("JOIN products p ON p.id = pw.product_id").
Joins("LEFT JOIN stock_allocations sa ON sa.usable_type = 'RECORDING_STOCK' AND sa.usable_id = rs.id AND sa.status = 'ACTIVE'").
Joins("LEFT JOIN purchase_items pi ON pi.id = sa.stockable_id AND sa.stockable_type = 'PURCHASE_ITEMS'").
Joins("LEFT JOIN stock_transfer_details std ON std.id = sa.stockable_id AND sa.stockable_type = 'STOCK_TRANSFER_IN'").
Joins("LEFT JOIN laying_transfer_targets ltt ON ltt.id = sa.stockable_id AND sa.stockable_type = 'TRANSFERTOLAYING_IN'").
Joins("LEFT JOIN adjustment_stocks adjs ON adjs.id = sa.stockable_id AND sa.stockable_type = 'ADJUSTMENT_IN'").
Joins("LEFT JOIN project_flock_populations pfp ON pfp.id = sa.stockable_id AND sa.stockable_type = 'PROJECT_FLOCK_POPULATION'").
Where("r.project_flock_kandangs_id = ?", projectFlockKandangID).
Where("r.deleted_at IS NULL").
Group("pw.product_id, p.name").
Scan(&recordingsResults).Error
if err != nil {
return nil, fmt.Errorf("failed to get recordings product usage: %w", err)
}
fmt.Printf("[REPO] Recordings query: %d results for projectFlockKandangID=%d\n", len(recordingsResults), projectFlockKandangID)
allResults = append(allResults, recordingsResults...)
// Subquery 2: Chickins
var chickinsResults []SubQueryResult
err = r.DB().WithContext(ctx).
Table("project_chickins pc").
Select("pw.product_id, p.name as product_name, "+
"COALESCE(SUM(pc.usage_qty), 0) as total_qty, "+
"COALESCE(AVG(COALESCE(pi.price, 0)), 0) as price").
Joins("JOIN product_warehouses pw ON pw.id = pc.product_warehouse_id").
Joins("JOIN products p ON p.id = pw.product_id").
Joins("LEFT JOIN purchase_items pi ON pi.product_warehouse_id = pw.id").
Where("pc.project_flock_kandang_id = ?", projectFlockKandangID).
Where("pc.usage_qty > 0").
Group("pw.product_id, p.name").
Scan(&chickinsResults).Error
if err != nil {
return nil, fmt.Errorf("failed to get chickins product usage: %w", err)
}
fmt.Printf("[REPO] Chickins query: %d results for projectFlockKandangID=%d\n", len(chickinsResults), projectFlockKandangID)
allResults = append(allResults, chickinsResults...)
// Subquery 3: Marketing Delivery
var marketingResults []SubQueryResult
err = r.DB().WithContext(ctx).
Table("marketing_delivery_products mdp").
Select("pw.product_id, p.name as product_name, "+
"COALESCE(SUM(mdp.usage_qty), 0) as total_qty, "+
"COALESCE(AVG(COALESCE(pi.price, 0)), 0) as price").
Joins("JOIN marketing_products mp ON mp.id = mdp.marketing_product_id").
Joins("JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id").
Joins("JOIN products p ON p.id = pw.product_id").
Joins("LEFT JOIN purchase_items pi ON pi.product_warehouse_id = pw.id").
Where("pw.project_flock_kandang_id = ?", projectFlockKandangID).
Group("pw.product_id, p.name").
Scan(&marketingResults).Error
if err != nil {
return nil, fmt.Errorf("failed to get marketing product usage: %w", err)
}
fmt.Printf("[REPO] Marketing query: %d results for projectFlockKandangID=%d\n", len(marketingResults), projectFlockKandangID)
allResults = append(allResults, marketingResults...)
// Subquery 4: Laying Transfer Sources
var layingTransferResults []SubQueryResult
err = r.DB().WithContext(ctx).
Table("laying_transfer_sources lts").
Select("pw.product_id, p.name as product_name, "+
"COALESCE(SUM(lts.usage_qty), 0) as total_qty, "+
"COALESCE(AVG(COALESCE(pi.price, 0)), 0) as price").
Joins("JOIN laying_transfers lt ON lt.id = lts.laying_transfer_id").
Joins("JOIN product_warehouses pw ON pw.id = lts.product_warehouse_id").
Joins("JOIN products p ON p.id = pw.product_id").
Joins("LEFT JOIN purchase_items pi ON pi.product_warehouse_id = pw.id").
Where("pw.project_flock_kandang_id = ?", projectFlockKandangID).
Group("pw.product_id, p.name").
Scan(&layingTransferResults).Error
if err != nil {
return nil, fmt.Errorf("failed to get laying transfer product usage: %w", err)
}
fmt.Printf("[REPO] Laying Transfer query: %d results for projectFlockKandangID=%d\n", len(layingTransferResults), projectFlockKandangID)
allResults = append(allResults, layingTransferResults...)
// Subquery 5: Stock Transfer Details
var stockTransferResults []SubQueryResult
err = r.DB().WithContext(ctx).
Table("stock_transfer_details std").
Select("pw.product_id, p.name as product_name, "+
"COALESCE(SUM(std.usage_qty), 0) as total_qty, "+
"COALESCE(AVG(COALESCE(pi.price, 0)), 0) as price").
Joins("JOIN product_warehouses pw ON pw.id = std.source_product_warehouse_id").
Joins("JOIN products p ON p.id = std.product_id").
Joins("LEFT JOIN purchase_items pi ON pi.product_warehouse_id = pw.id").
Where("pw.project_flock_kandang_id = ?", projectFlockKandangID).
Group("pw.product_id, p.name").
Scan(&stockTransferResults).Error
if err != nil {
return nil, fmt.Errorf("failed to get stock transfer product usage: %w", err)
}
fmt.Printf("[REPO] Stock Transfer query: %d results for projectFlockKandangID=%d\n", len(stockTransferResults), projectFlockKandangID)
allResults = append(allResults, stockTransferResults...)
// Subquery 6: Adjustment Stocks
var adjustmentResults []SubQueryResult
err = r.DB().WithContext(ctx).
Table("adjustment_stocks ads").
Select("pw.product_id, p.name as product_name, "+
"COALESCE(SUM(ads.usage_qty), 0) as total_qty, "+
"COALESCE(AVG(COALESCE(pi.price, 0)), 0) as price").
Joins("JOIN product_warehouses pw ON pw.id = ads.product_warehouse_id").
Joins("JOIN products p ON p.id = pw.product_id").
Joins("LEFT JOIN purchase_items pi ON pi.product_warehouse_id = pw.id").
Where("pw.project_flock_kandang_id = ?", projectFlockKandangID).
Where("ads.usage_qty > 0").
Group("pw.product_id, p.name").
Scan(&adjustmentResults).Error
if err != nil {
return nil, fmt.Errorf("failed to get adjustment product usage: %w", err)
}
fmt.Printf("[REPO] Adjustment query: %d results for projectFlockKandangID=%d\n", len(adjustmentResults), projectFlockKandangID)
allResults = append(allResults, adjustmentResults...)
fmt.Printf("[REPO] Total raw results before aggregation: %d items\n", len(allResults))
// Aggregate results by product_id
aggregatedMap := make(map[uint]*AggregatedResult)
for _, result := range allResults {
key := result.ProductID
if existing, exists := aggregatedMap[key]; exists {
existing.TotalQty += result.TotalQty
existing.Price += result.Price
existing.PriceCount++
} else {
aggregatedMap[key] = &AggregatedResult{
ProductID: result.ProductID,
ProductName: result.ProductName,
TotalQty: result.TotalQty,
Price: result.Price,
PriceCount: 1,
}
}
}
fmt.Printf("[REPO] Aggregated to %d unique products\n", len(aggregatedMap))
// Get flags for all products
productIDs := make([]uint, 0, len(aggregatedMap))
for id := range aggregatedMap {
productIDs = append(productIDs, id)
}
var flagResults []FlagResult
if len(productIDs) > 0 {
err = r.DB().WithContext(ctx).
Table("products p").
Select("p.id as product_id, STRING_AGG(DISTINCT f.name, ', ') as flag_names").
Joins("LEFT JOIN flags f ON f.flagable_type = 'products' AND f.flagable_id = p.id").
Where("p.id IN ?", productIDs).
Group("p.id").
Scan(&flagResults).Error
if err != nil {
return nil, fmt.Errorf("failed to get product flags: %w", err)
}
}
fmt.Printf("[REPO] Fetched flags for %d products\n", len(flagResults))
// Build flag map
flagMap := make(map[uint]string)
for _, flag := range flagResults {
flagMap[flag.ProductID] = flag.FlagNames
}
// Combine results and calculate average price
results := make([]ProductUsageRow, 0, len(aggregatedMap))
for _, agg := range aggregatedMap {
avgPrice := float64(0)
if agg.PriceCount > 0 {
avgPrice = agg.Price / float64(agg.PriceCount)
}
flagNames := flagMap[agg.ProductID]
// Apply flag filters if provided
if len(flagFilters) > 0 {
// Check if any of the flagFilters exist in flagNames
matched := false
for _, filter := range flagFilters {
if containsIgnoreCase(flagNames, filter) {
matched = true
break
}
}
if !matched {
continue // Skip this product if no flag matches
}
}
results = append(results, ProductUsageRow{
ProductID: agg.ProductID,
ProductName: agg.ProductName,
FlagNames: flagNames,
TotalQty: agg.TotalQty,
Price: avgPrice,
TotalPengeluaran: agg.TotalQty * avgPrice,
})
}
fmt.Printf("[REPO] After filtering with flagFilters=%v: %d results\n", flagFilters, len(results))
for i, r := range results {
fmt.Printf("[REPO] Result[%d]: ProductID=%d, ProductName=%s, FlagNames=%s, TotalQty=%.2f, Price=%.2f, TotalPengeluaran=%.2f\n",
i, r.ProductID, r.ProductName, r.FlagNames, r.TotalQty, r.Price, r.TotalPengeluaran)
}
// Sort by product name
sort.Slice(results, func(i, j int) bool {
return results[i].ProductName < results[j].ProductName
})
fmt.Printf("[REPO] Final sorted results: %d items\n", len(results))
return results, nil
}
// GetTotalDepletionByProjectFlockKandangID gets total depletion for a specific kandang
func (r *ClosingKeuanganRepositoryImpl) GetTotalDepletionByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error) {
var result float64
err := r.DB().WithContext(ctx).
Table("recording_depletions").
Select("COALESCE(SUM(recording_depletions.qty), 0)").
Joins("JOIN recordings ON recordings.id = recording_depletions.recording_id").
Joins("JOIN project_flock_kandangs ON project_flock_kandangs.id = recordings.project_flock_kandangs_id").
Where("project_flock_kandangs.id = ?", projectFlockKandangID).
Scan(&result).Error
return result, err
}
// GetTotalWeightProducedFromUniformityByProjectFlockKandangID calculates total weight produced from uniformity data for a specific kandang
// Formula: (mean_up / 1.10) * chick_qty_of_weight / 1000
func (r *ClosingKeuanganRepositoryImpl) GetTotalWeightProducedFromUniformityByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error) {
if projectFlockKandangID == 0 {
return 0, nil
}
var uniformity struct {
MeanUp float64
ChickQtyOfWeight float64
}
err := r.DB().WithContext(ctx).
Table("project_flock_kandang_uniformity").
Select("mean_up, chick_qty_of_weight").
Where("project_flock_kandang_id = ?", projectFlockKandangID).
Order("id DESC").
Limit(1).
Scan(&uniformity).Error
if err != nil {
return 0, err
}
// Calculate weight: (mean_up / 1.10) * chick_qty_of_weight / 1000
totalWeight := (uniformity.MeanUp / 1.10) * uniformity.ChickQtyOfWeight / 1000
return totalWeight, nil
}
// containsIgnoreCase checks if a string contains a substring (case-insensitive)
func containsIgnoreCase(str, substr string) bool {
return strings.Contains(strings.ToUpper(str), strings.ToUpper(substr))
}
-1
View File
@@ -30,7 +30,6 @@ func ClosingRoutes(v1 fiber.Router, u user.UserService, s closing.ClosingService
route.Get("/:project_flock_id/:project_flock_kandang_id/perhitungan_sapronak", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetSapronakByKandang)
route.Get("/:project_flock_id/perhitungan_sapronak", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetSapronakByProject)
route.Get("/:projectFlockId/sapronak", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetClosingSapronak)
route.Get("/:projectFlockId/sapronak/summary", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetClosingSapronakSummary)
route.Get("/:project_flock_id/expedition-hpp", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetExpeditionHPP)
route.Get("/:project_flock_id/:project_flock_kandang_id/expedition-hpp", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetExpeditionHPPByKandang)
route.Get("/:projectFlockId/production-data", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetClosingDataProduksi)
@@ -40,7 +40,6 @@ type ClosingService interface {
GetClosingDataProduksi(ctx *fiber.Ctx, projectFlockID uint, kandangID *uint) (*dto.ClosingProductionReportDTO, error)
GetOverhead(ctx *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) (*dto.OverheadListDTO, error)
GetClosingSapronak(ctx *fiber.Ctx, projectFlockID uint, params *validation.ClosingSapronakQuery) ([]dto.ClosingSapronakItemDTO, int64, error)
GetClosingSapronakSummary(ctx *fiber.Ctx, projectFlockID uint, params *validation.ClosingSapronakQuery) ([]dto.ClosingSapronakSummaryItemDTO, error)
GetExpeditionHPP(ctx *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) (*dto.ExpeditionHPPDTO, error)
}
@@ -99,31 +98,9 @@ func (s closingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]dto.Cl
}
offset := (params.Page - 1) * params.Limit
statusFilter := ""
if params.ProjectStatus != nil {
switch *params.ProjectStatus {
case 1:
statusFilter = "Pengajuan"
case 2:
statusFilter = "Aktif"
}
}
closings, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
db = s.withClosingRelations(db)
if params.LocationID != nil {
db = db.Where("location_id = ?", *params.LocationID)
}
if statusFilter != "" {
latestApprovalSubQuery := s.Repository.DB().
WithContext(c.Context()).
Table("approvals").
Select("DISTINCT ON (approvable_id) approvable_id, step_name, id").
Where("approvable_type = ?", utils.ApprovalWorkflowProjectFlock.String()).
Order("approvable_id, id DESC")
db = db.Joins("JOIN (?) AS latest_approval ON latest_approval.approvable_id = project_flocks.id", latestApprovalSubQuery).
Where("LOWER(latest_approval.step_name) = LOWER(?)", statusFilter)
}
if params.Search != "" {
return db.Where("flock_name ILIKE ?", "%"+params.Search+"%")
}
@@ -162,12 +139,7 @@ func (s closingService) GetProjectFlockByID(c *fiber.Ctx, id uint) (*entity.Proj
func (s closingService) GetPenjualan(c *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) ([]entity.MarketingDeliveryProduct, error) {
projectFlock, err := s.ProjectFlockRepo.GetByID(c.Context(), projectFlockID, nil)
if err != nil {
return nil, err
}
realisasi, err := s.MarketingDeliveryProductRepo.GetClosingPenjualan(c.Context(), projectFlockID, projectFlockKandangID, projectFlock.Category)
realisasi, err := s.MarketingDeliveryProductRepo.GetClosingPenjualan(c.Context(), projectFlockID, projectFlockKandangID)
if err != nil {
return nil, err
}
@@ -381,7 +353,6 @@ func (s closingService) GetClosingSapronak(c *fiber.Ctx, projectFlockID uint, pa
ProjectFlockKandangIDs: projectFlockKandangIDs,
Limit: params.Limit,
Offset: offset,
Search: params.Search,
})
if err != nil {
s.Log.Errorf("Failed to fetch sapronak %s for project flock %d: %+v", params.Type, projectFlockID, err)
@@ -416,74 +387,6 @@ func (s closingService) GetClosingSapronak(c *fiber.Ctx, projectFlockID uint, pa
return items, totalResults, nil
}
func (s closingService) GetClosingSapronakSummary(c *fiber.Ctx, projectFlockID uint, params *validation.ClosingSapronakQuery) ([]dto.ClosingSapronakSummaryItemDTO, error) {
if projectFlockID == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project flock id")
}
if params == nil {
params = &validation.ClosingSapronakQuery{}
}
if err := s.Validate.Struct(params); err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, err.Error())
}
if params.Type != validation.SapronakTypeIncoming && params.Type != validation.SapronakTypeOutgoing {
return nil, fiber.NewError(fiber.StatusBadRequest, "type must be either incoming or outgoing")
}
if _, err := s.Repository.GetByID(c.Context(), projectFlockID, nil); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Project flock tidak ditemukan")
}
s.Log.Errorf("Failed get project flock %d for sapronak closing summary: %+v", projectFlockID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock")
}
warehouseIDs, err := s.getWarehouseIDsByProjectFlock(c.Context(), projectFlockID)
if err != nil {
s.Log.Errorf("Failed to fetch warehouses for project flock %d: %+v", projectFlockID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch warehouses for project flock")
}
var projectFlockKandangIDs []uint
if params.KandangID != nil && *params.KandangID > 0 {
projectFlockKandangIDs = []uint{*params.KandangID}
} else if params.Type == validation.SapronakTypeOutgoing {
projectFlockKandangIDs, err = s.getProjectFlockKandangIDs(c.Context(), projectFlockID)
if err != nil {
s.Log.Errorf("Failed to fetch project flock kandang IDs for project flock %d: %+v", projectFlockID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandang")
}
}
rows, err := s.Repository.GetSapronakSummary(c.Context(), repository.SapronakQueryParams{
Type: params.Type,
WarehouseIDs: warehouseIDs,
ProjectFlockKandangIDs: projectFlockKandangIDs,
Search: params.Search,
})
if err != nil {
s.Log.Errorf("Failed to fetch sapronak %s summary for project flock %d: %+v", params.Type, projectFlockID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch sapronak summary data")
}
items := make([]dto.ClosingSapronakSummaryItemDTO, 0, len(rows))
for _, row := range rows {
items = append(items, dto.ClosingSapronakSummaryItemDTO{
Category: row.Category,
TotalQty: row.TotalQty,
Uom: dto.UomSummaryDTO{
ID: row.UomID,
Name: row.UomName,
},
})
}
return items, nil
}
func (s closingService) getWarehouseIDsByProjectFlock(ctx context.Context, projectFlockID uint) ([]uint, error) {
var kandangIDs []uint
db := s.Repository.DB().WithContext(ctx)
@@ -748,10 +651,6 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to determine production week")
}
if !isGrowing && currentWeek != 0 {
currentWeek = currentWeek + 17
}
targetAverages, err := s.RecordingRepo.GetAverageTargetMetricsByProjectFlockKandangID(c.Context(), projectFlockKandangIDs[0], !isGrowing)
if err != nil {
s.Log.Errorf("Failed to calculate target metrics for project flock %d: %+v", projectFlockID, err)
@@ -961,19 +860,19 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint
if !isGrowing {
if targetAverages.HenDayCount > 0 {
henDayAct := targetAverages.HenDayAvg
performance.HenDayAct = henDayAct
performance.HenDayAct = &henDayAct
}
if targetAverages.HenHouseCount > 0 {
henHouseAct := targetAverages.HenHouseAvg
performance.HenHouseAct = henHouseAct
performance.HenHouseAct = &henHouseAct
}
if targetAverages.EggWeightCount > 0 {
eggWeight := targetAverages.EggWeightAvg
performance.EggWeight = eggWeight
performance.EggWeight = &eggWeight
}
if targetAverages.EggMassCount > 0 {
eggMass := targetAverages.EggMassAvg
performance.EggMass = eggMass
performance.EggMass = &eggMass
}
}
performance.DeffFcr = performance.FcrStd - performance.FcrAct
@@ -1131,3 +1030,4 @@ func closestFcrValues(standards []entity.FcrStandard, averageWeight float64) (fl
return closest.Mortality, closest.FcrNumber
}
@@ -2,19 +2,20 @@ package service
import (
"errors"
"strings"
"github.com/gofiber/fiber/v2"
"github.com/sirupsen/logrus"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/modules/closings/dto"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/repositories"
expenseRealizationRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories"
marketingDeliveryProductRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories"
chickinRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories"
projectflockRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
recordingRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"github.com/gofiber/fiber/v2"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
)
@@ -24,28 +25,9 @@ type ClosingKeuanganService interface {
GetClosingKeuanganByKandang(ctx *fiber.Ctx, projectFlockID uint, projectFlockKandangID uint) (*dto.ClosingKeuanganData, error)
}
// CostData holds all cost-related information
type CostData struct {
FeedCost float64
OvkCost float64
ChickenCost float64
ExpeditionCost float64
BudgetOperational float64
RealizationOperational float64
}
// ProductionData holds all production and sales related information
type ProductionData struct {
TotalPopulationIn float64
TotalDepletion float64
TotalWeightProduced float64
TotalEggWeightKg float64
TotalWeightSold float64
TotalSalesAmount float64
}
type closingKeuanganService struct {
Log *logrus.Logger
ClosingKeuanganRepo repository.ClosingKeuanganRepository
ProjectFlockRepo projectflockRepository.ProjectflockRepository
ProjectFlockKandangRepo projectflockRepository.ProjectFlockKandangRepository
MarketingDeliveryProductRepo marketingDeliveryProductRepository.MarketingDeliveryProductRepository
@@ -53,11 +35,10 @@ type closingKeuanganService struct {
ProjectBudgetRepo projectflockRepository.ProjectBudgetRepository
ChickinRepo chickinRepository.ProjectChickinRepository
RecordingRepo recordingRepository.RecordingRepository
HppSvc commonSvc.HppService
HppRepo commonRepo.HppCostRepository
}
func NewClosingKeuanganService(
closingKeuanganRepo repository.ClosingKeuanganRepository,
projectFlockRepo projectflockRepository.ProjectflockRepository,
projectFlockKandangRepo projectflockRepository.ProjectFlockKandangRepository,
marketingDeliveryProductRepo marketingDeliveryProductRepository.MarketingDeliveryProductRepository,
@@ -65,11 +46,10 @@ func NewClosingKeuanganService(
projectBudgetRepo projectflockRepository.ProjectBudgetRepository,
chickinRepo chickinRepository.ProjectChickinRepository,
recordingRepo recordingRepository.RecordingRepository,
hppSvc commonSvc.HppService,
hppRepo commonRepo.HppCostRepository,
) ClosingKeuanganService {
return &closingKeuanganService{
Log: utils.Log,
ClosingKeuanganRepo: closingKeuanganRepo,
ProjectFlockRepo: projectFlockRepo,
ProjectFlockKandangRepo: projectFlockKandangRepo,
MarketingDeliveryProductRepo: marketingDeliveryProductRepo,
@@ -77,8 +57,6 @@ func NewClosingKeuanganService(
ProjectBudgetRepo: projectBudgetRepo,
ChickinRepo: chickinRepo,
RecordingRepo: recordingRepo,
HppSvc: hppSvc,
HppRepo: hppRepo,
}
}
@@ -95,12 +73,30 @@ func (s closingKeuanganService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock")
}
projectFlockKandangs, err := s.ProjectFlockKandangRepo.GetByProjectFlockID(c.Context(), projectFlockID)
budgets, err := s.ProjectBudgetRepo.GetByProjectFlockID(c.Context(), projectFlockID)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch budgets")
}
// Preload Nonstock.Flags manually
var budgetIDs []uint
for _, b := range budgets {
budgetIDs = append(budgetIDs, b.Id)
}
if len(budgetIDs) > 0 {
err = s.ProjectBudgetRepo.DB().WithContext(c.Context()).
Preload("Nonstock.Flags").
Where("id IN ?", budgetIDs).
Find(&budgets).Error
}
// Get all kandang for this project flock
kandangs, err := s.ProjectFlockKandangRepo.GetByProjectFlockID(c.Context(), projectFlockID)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch kandangs")
}
return s.calculateClosingKeuangan(c, projectFlock, projectFlockKandangs)
return s.calculateClosingKeuangan(c, projectFlock, budgets, kandangs, projectFlockID)
}
func (s closingKeuanganService) GetClosingKeuanganByKandang(c *fiber.Ctx, projectFlockID uint, projectFlockKandangID uint) (*dto.ClosingKeuanganData, error) {
@@ -111,11 +107,12 @@ func (s closingKeuanganService) GetClosingKeuanganByKandang(c *fiber.Ctx, projec
return nil, err
}
projectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), projectFlockKandangID)
// Validate and fetch project flock kandang
kandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), projectFlockKandangID)
if err != nil {
return nil, fiber.NewError(fiber.StatusNotFound, "Project flock kandang not found")
}
if projectFlockKandang.ProjectFlockId != projectFlockID {
if kandang.ProjectFlockId != projectFlockID {
return nil, fiber.NewError(fiber.StatusBadRequest, "Project flock kandang does not belong to this project flock")
}
@@ -124,249 +121,417 @@ func (s closingKeuanganService) GetClosingKeuanganByKandang(c *fiber.Ctx, projec
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock")
}
projectFlockKandangs := []entity.ProjectFlockKandang{*projectFlockKandang}
return s.calculateClosingKeuangan(c, projectFlock, projectFlockKandangs)
}
func (s closingKeuanganService) calculateClosingKeuangan(c *fiber.Ctx, projectFlock *entity.ProjectFlock, projectFlockKandangs []entity.ProjectFlockKandang) (*dto.ClosingKeuanganData, error) {
var projectFlockKandangIDs []uint
for _, projectFlockKandang := range projectFlockKandangs {
projectFlockKandangIDs = append(projectFlockKandangIDs, projectFlockKandang.Id)
budgets, err := s.ProjectBudgetRepo.GetByProjectFlockID(c.Context(), projectFlockID)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch budgets")
}
isPerKandang := len(projectFlockKandangs) == 1
// Preload Nonstock.Flags manually
var budgetIDs []uint
for _, b := range budgets {
budgetIDs = append(budgetIDs, b.Id)
}
if len(budgetIDs) > 0 {
err = s.ProjectBudgetRepo.DB().WithContext(c.Context()).
Preload("Nonstock.Flags").
Where("id IN ?", budgetIDs).
Find(&budgets).Error
}
kandangs := []entity.ProjectFlockKandang{*kandang}
return s.calculateClosingKeuangan(c, projectFlock, budgets, kandangs, projectFlockID)
}
func (s closingKeuanganService) calculateClosingKeuangan(c *fiber.Ctx, projectFlock *entity.ProjectFlock, budgets []entity.ProjectBudget, kandangs []entity.ProjectFlockKandang, scopeID uint) (*dto.ClosingKeuanganData, error) {
// Define flag filters using constants
pakanFilters := []string{string(utils.FlagPakan), string(utils.FlagPreStarter), string(utils.FlagStarter), string(utils.FlagFinisher)}
ovkFilters := []string{string(utils.FlagOVK), string(utils.FlagObat), string(utils.FlagVitamin), string(utils.FlagKimia)}
ayamFilters := []string{string(utils.FlagDOC), string(utils.FlagPullet), string(utils.FlagLayer)}
allFilters := append(pakanFilters, ovkFilters...)
allFilters = append(allFilters, ayamFilters...)
var allProductUsageRows []repository.ProductUsageRow
// Get ALL product usage
for _, kandang := range kandangs {
rows, err := s.ClosingKeuanganRepo.GetAllProductUsageByProjectFlockKandangID(c.Context(), kandang.Id, allFilters)
if err == nil {
allProductUsageRows = append(allProductUsageRows, rows...)
}
}
// Classify into categories based on flag priority
var pakanProductUsageRows []repository.ProductUsageRow
var ovkProductUsageRows []repository.ProductUsageRow
var ayamProductUsageRows []repository.ProductUsageRow
for _, row := range allProductUsageRows {
// Parse flag names from comma-separated string
flagNames := strings.Split(row.FlagNames, ",")
hasPakanFlag := false
hasOvkFlag := false
hasAyamFlag := false
for _, flag := range flagNames {
flag = strings.TrimSpace(flag)
if containsItem(pakanFilters, flag) {
hasPakanFlag = true
}
if containsItem(ovkFilters, flag) {
hasOvkFlag = true
}
if containsItem(ayamFilters, flag) {
hasAyamFlag = true
}
}
// Priority: PAKAN > OVK > AYAM
if hasPakanFlag {
pakanProductUsageRows = append(pakanProductUsageRows, row)
} else if hasOvkFlag {
ovkProductUsageRows = append(ovkProductUsageRows, row)
} else if hasAyamFlag {
ayamProductUsageRows = append(ayamProductUsageRows, row)
} else {
continue
}
}
// Calculate total price for each category
var totalPakanPrice, totalOvkPrice, totalAyamPrice float64
for _, row := range pakanProductUsageRows {
totalPakanPrice += row.TotalPengeluaran
}
for _, row := range ovkProductUsageRows {
totalOvkPrice += row.TotalPengeluaran
}
for _, row := range ayamProductUsageRows {
totalAyamPrice += row.TotalPengeluaran
}
// Determine if this is per-kandang or per-project-flock scope
isPerKandang := len(kandangs) == 1
var projectFlockKandangID *uint
if isPerKandang {
kandangID := projectFlockKandangs[0].Id
kandangID := kandangs[0].Id
projectFlockKandangID = &kandangID
}
costs, err := s.calculateCosts(c, projectFlock, projectFlockKandangs, projectFlockKandangIDs, projectFlockKandangID)
if err != nil {
return nil, err
}
productionData, err := s.calculateProductionData(c, projectFlock, projectFlockKandangIDs, projectFlockKandangID)
if err != nil {
return nil, err
}
hppSection := s.buildHPPSection(c, projectFlock, projectFlockKandangs, costs, productionData)
profitLossSection := s.buildProfitLossSection(projectFlock, costs, productionData)
data := dto.ToClosingKeuanganData(hppSection, profitLossSection)
return &data, nil
}
func (s closingKeuanganService) calculateCosts(c *fiber.Ctx, projectFlock *entity.ProjectFlock, projectFlockKandangs []entity.ProjectFlockKandang, projectFlockKandangIDs []uint, projectFlockKandangID *uint) (*CostData, error) {
costs := &CostData{}
var err error
costs.FeedCost, err = s.HppRepo.GetFeedUsageCost(c.Context(), projectFlockKandangIDs, nil)
if err != nil {
costs.FeedCost = 0
}
costs.OvkCost, err = s.HppRepo.GetOvkUsageCost(c.Context(), projectFlockKandangIDs, nil)
if err != nil {
costs.OvkCost = 0
}
if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) {
for _, projectFlockKandang := range projectFlockKandangs {
depresiasiCost, err := s.HppSvc.GetDepresiasiTransfer(projectFlockKandang.Id, nil)
if err == nil {
costs.ChickenCost += depresiasiCost
}
pulletCost, err := s.HppRepo.GetPulletCost(c.Context(), projectFlockKandang.Id)
if err == nil {
costs.ChickenCost += pulletCost
}
}
// Fetch realizations
var realizations []entity.ExpenseRealization
if isPerKandang && projectFlockKandangID != nil {
realizations, err = s.ExpenseRealizationRepo.GetClosingOverhead(c.Context(), projectFlock.Id, projectFlockKandangID)
} else {
for _, projectFlockKandang := range projectFlockKandangs {
pulletCost, err := s.HppRepo.GetPulletCost(c.Context(), projectFlockKandang.Id)
if err == nil {
costs.ChickenCost += pulletCost
}
}
}
costs.ExpeditionCost, err = s.HppRepo.GetExpedisionCost(c.Context(), projectFlockKandangIDs)
if err != nil {
costs.ExpeditionCost = 0
}
if budgets, err := s.ProjectBudgetRepo.GetByProjectFlockID(c.Context(), projectFlock.Id); err == nil {
totalBudget := 0.0
for _, budget := range budgets {
totalBudget += budget.Price * budget.Qty
}
if projectFlockKandangID != nil {
allKandangs, errKandang := s.ProjectFlockKandangRepo.GetByProjectFlockID(c.Context(), projectFlock.Id)
if errKandang == nil && len(allKandangs) > 0 {
costs.BudgetOperational = totalBudget / float64(len(allKandangs))
}
} else {
costs.BudgetOperational = totalBudget
}
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
s.Log.Errorf("Failed to fetch budgets for project_flock_id=%d: %+v", projectFlock.Id, err)
}
if realizations, err := s.ExpenseRealizationRepo.GetClosingOverhead(c.Context(), projectFlock.Id, projectFlockKandangID); err == nil {
for _, realization := range realizations {
amount := realization.Price * realization.Qty
isEkspedisi := realization.ExpenseNonstock != nil &&
realization.ExpenseNonstock.Nonstock != nil &&
containsFlag(realization.ExpenseNonstock.Nonstock.Flags, "EKSPEDISI")
if !isEkspedisi {
costs.RealizationOperational += amount
}
}
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
s.Log.Errorf("Failed to fetch realizations for project_flock_id=%d: %+v", projectFlock.Id, err)
}
return costs, nil
}
func (s closingKeuanganService) calculateProductionData(c *fiber.Ctx, projectFlock *entity.ProjectFlock, projectFlockKandangIDs []uint, projectFlockKandangID *uint) (*ProductionData, error) {
data := &ProductionData{}
var err error
data.TotalPopulationIn, err = s.HppRepo.GetTotalPopulation(c.Context(), projectFlockKandangIDs)
if err != nil {
data.TotalPopulationIn = 0
}
if projectFlockKandangID != nil {
data.TotalDepletion, err = s.RecordingRepo.GetTotalDepletionByProjectFlockKandangID(c.Context(), *projectFlockKandangID)
} else {
data.TotalDepletion, err = s.RecordingRepo.GetTotalDepletionByProjectFlockID(c.Context(), projectFlock.Id)
realizations, err = s.ExpenseRealizationRepo.GetClosingOverhead(c.Context(), projectFlock.Id, nil)
}
if err != nil {
data.TotalDepletion = 0
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch realizations")
}
if projectFlockKandangID != nil {
data.TotalWeightProduced, err = s.RecordingRepo.GetTotalWeightProducedFromUniformityByProjectFlockKandangID(c.Context(), *projectFlockKandangID)
} else {
data.TotalWeightProduced, err = s.RecordingRepo.GetTotalWeightProducedFromUniformityByProjectFlockID(c.Context(), projectFlock.Id)
}
if err != nil {
data.TotalWeightProduced = 0
}
if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) {
_, data.TotalEggWeightKg, err = s.HppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(c.Context(), projectFlockKandangIDs, nil)
if err != nil {
data.TotalEggWeightKg = 0
}
}
var deliveryProducts []entity.MarketingDeliveryProduct
if projectFlockKandangID != nil {
deliveryProducts, err = s.MarketingDeliveryProductRepo.GetClosingPenjualan(c.Context(), projectFlock.Id, projectFlockKandangID, projectFlock.Category)
} else {
deliveryProducts, err = s.MarketingDeliveryProductRepo.GetClosingPenjualan(c.Context(), projectFlock.Id, nil, projectFlock.Category)
}
deliveryProducts, err := s.MarketingDeliveryProductRepo.GetDeliveryProductsByProjectFlockID(c.Context(), projectFlock.Id, func(db *gorm.DB) *gorm.DB {
db = db.Preload("MarketingProduct").
Preload("MarketingProduct.ProductWarehouse").
Preload("MarketingProduct.ProductWarehouse.Product")
return db
})
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data penjualan")
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch delivery products")
}
// Filter by kandang if scope is per-kandang (manual filtering after fetch)
if isPerKandang && projectFlockKandangID != nil {
filteredProducts := make([]entity.MarketingDeliveryProduct, 0)
for _, dp := range deliveryProducts {
pfKandangID := dp.MarketingProduct.ProductWarehouse.ProjectFlockKandangId
if pfKandangID != nil && *pfKandangID == *projectFlockKandangID {
filteredProducts = append(filteredProducts, dp)
}
}
deliveryProducts = filteredProducts
}
// Fetch chickins
var chickins []entity.ProjectChickin
if isPerKandang && projectFlockKandangID != nil {
chickins, err = s.ChickinRepo.GetByProjectFlockKandangID(c.Context(), *projectFlockKandangID)
} else {
chickins, err = s.ChickinRepo.GetByProjectFlockID(c.Context(), projectFlock.Id)
}
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch chickins")
}
// Get total depletion
var totalDepletion float64
if isPerKandang && projectFlockKandangID != nil {
totalDepletion, err = s.ClosingKeuanganRepo.GetTotalDepletionByProjectFlockKandangID(c.Context(), *projectFlockKandangID)
} else {
totalDepletion, err = s.RecordingRepo.GetTotalDepletionByProjectFlockID(c.Context(), projectFlock.Id)
}
if err != nil {
totalDepletion = 0
}
totalWeightProduced, _, err := s.RecordingRepo.GetProductionWeightAndQtyByProjectFlockID(c.Context(), projectFlock.Id)
if err != nil {
}
// Try to get actual weight from uniformity data
var totalWeightFromUniformity float64
if isPerKandang && projectFlockKandangID != nil {
totalWeightFromUniformity, err = s.ClosingKeuanganRepo.GetTotalWeightProducedFromUniformityByProjectFlockKandangID(c.Context(), *projectFlockKandangID)
} else {
totalWeightFromUniformity, err = s.RecordingRepo.GetTotalWeightProducedFromUniformityByProjectFlockID(c.Context(), projectFlock.Id)
}
if err != nil {
} else if totalWeightFromUniformity > 0 {
totalWeightProduced = totalWeightFromUniformity
}
// Fetch egg data only for Laying category
var totalEggWeightKg float64
if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) {
// TODO: Replace with actual method to get egg weight from RecordingRepo
// totalEggWeightKg, err = s.RecordingRepo.GetEggWeightByProjectFlockID(c.Context(), projectFlock.Id)
// For now, set to 0 as placeholder
totalEggWeightKg = 0
} else {
totalEggWeightKg = 0
}
// Build new DTO structure
// Calculate totals
var totalPopulation float64
for _, chickin := range chickins {
totalPopulation += chickin.UsageQty
}
// Calculate actual population (total population - depletion)
actualPopulation := totalPopulation - totalDepletion
// Calculate budget totals by category
calculateBudgetByFlag := func(flags []string) float64 {
var total float64
for _, budget := range budgets {
if budget.Nonstock != nil {
for _, nonstockFlag := range budget.Nonstock.Flags {
flagName := strings.ToUpper(nonstockFlag.Name)
for _, targetFlag := range flags {
if flagName == strings.ToUpper(targetFlag) {
total += budget.Price * budget.Qty
break
}
}
}
}
}
return total
}
// Budget per category
budgetPakan := calculateBudgetByFlag([]string{"PAKAN", "PRE-STARTER", "STARTER", "FINISHER"})
budgetOvk := calculateBudgetByFlag([]string{"OVK", "OBAT", "VITAMIN", "KIMIA"})
budgetAyam := calculateBudgetByFlag([]string{"DOC", "PULLET", "LAYER"})
budgetEkspedisi := calculateBudgetByFlag([]string{"EKSPEDISI"})
// Operational budget = total budget - pakan - ovk - ayam - ekspedisi
totalBudgetAmount := 0.0
for _, budget := range budgets {
totalBudgetAmount += budget.Price * budget.Qty
}
budgetOperational := totalBudgetAmount - budgetPakan - budgetOvk - budgetAyam - budgetEkspedisi
// Calculate realization totals
var totalRealizationAmount float64
var totalEkspedisiRealization float64
for _, realization := range realizations {
amount := realization.Price * realization.Qty
totalRealizationAmount += amount
// Check if this is ekspedisi (need to check nonstock flags)
if realization.ExpenseNonstock != nil && realization.ExpenseNonstock.Nonstock != nil {
for _, flag := range realization.ExpenseNonstock.Nonstock.Flags {
if flag.Name == "EKSPEDISI" {
totalEkspedisiRealization += amount
break
}
}
}
}
totalOperationalRealization := totalRealizationAmount - totalEkspedisiRealization
// Filter delivery products based on category
var filteredDeliveryProducts []entity.MarketingDeliveryProduct
for _, delivery := range deliveryProducts {
// Get product from delivery
if delivery.MarketingProduct.ProductWarehouse.Product.Id == 0 {
continue
}
data.TotalWeightSold += delivery.TotalWeight
data.TotalSalesAmount += delivery.TotalPrice
product := delivery.MarketingProduct.ProductWarehouse.Product
isEggProduct := false
isChickenProduct := false
// Check product flags
for _, flag := range product.Flags {
flagName := strings.ToUpper(flag.Name)
// Egg product flags
if flagName == "TELUR" || flagName == "TELURUTUH" || flagName == "TELURPECAH" ||
flagName == "TELURPUTIH" || flagName == "TELURRETAK" {
isEggProduct = true
}
// Chicken product flags
if flagName == "AYAMAFKIR" || flagName == "AYAMCULLING" || flagName == "AYAMMATI" {
isChickenProduct = true
}
}
// Filter based on project flock category
if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) {
// Laying: only egg products
if isEggProduct {
filteredDeliveryProducts = append(filteredDeliveryProducts, delivery)
}
} else {
// Growing/Contract Growing: only chicken products
if isChickenProduct || (!isEggProduct && !isChickenProduct) {
// Include if chicken product or if no specific flags (default to chicken)
filteredDeliveryProducts = append(filteredDeliveryProducts, delivery)
}
}
}
return data, nil
}
func (s closingKeuanganService) buildHPPSection(c *fiber.Ctx, projectFlock *entity.ProjectFlock, projectFlockKandangs []entity.ProjectFlockKandang, costs *CostData, production *ProductionData) dto.HPPSection {
actualPopulation := production.TotalPopulationIn - production.TotalDepletion
totalWeightProduced := production.TotalWeightProduced
totalEggWeightKg := production.TotalEggWeightKg
weightForCalculation := totalWeightProduced
if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) {
weightForCalculation = totalEggWeightKg
// Calculate total weight sold and sales amount from filtered products
var totalWeightSold float64
var totalSalesAmount float64
for _, delivery := range filteredDeliveryProducts {
totalWeightSold += delivery.TotalWeight
totalSalesAmount += delivery.TotalPrice
}
// Calculate metrics - always use kg ayam for rp_per_kg
calculateMetrics := func(amount float64) (rpPerBird, rpPerKg float64) {
if actualPopulation > 0 {
rpPerBird = amount / actualPopulation
rpPerBird = amount / actualPopulation // Use actual population
}
if weightForCalculation > 0 {
rpPerKg = amount / weightForCalculation
if totalWeightProduced > 0 {
rpPerKg = amount / totalWeightProduced
}
return
}
createHPPItem := func(id uint, category, code, label string, budgetAmount, realizationAmount float64) dto.HPPItem {
budgetRpPerBird, budgetRpPerKg := calculateMetrics(budgetAmount)
realizationRpPerBird, realizationRpPerKg := calculateMetrics(realizationAmount)
return dto.ToHPPItem(
id,
category,
code,
label,
dto.ToFinancialMetrics(budgetRpPerBird, budgetRpPerKg, budgetAmount),
dto.ToFinancialMetrics(realizationRpPerBird, realizationRpPerKg, realizationAmount),
)
// Calculate metrics for profit loss (use total population and total weight produced)
calculateProfitLossMetrics := func(amount float64) (rpPerBird, rpPerKg float64) {
if totalPopulation > 0 {
rpPerBird = amount / totalPopulation
}
if totalWeightProduced > 0 {
rpPerKg = amount / totalWeightProduced
}
return
}
// Build HPP Items using constants
hppItems := []dto.HPPItem{}
hppItems = append(hppItems, createHPPItem(1, "purchase", string(dto.HPPCodePakan), "Pembelian Pakan", costs.FeedCost, costs.FeedCost))
hppItems = append(hppItems, createHPPItem(2, "purchase", string(dto.HPPCodeOVK), "Pembelian OVK", costs.OvkCost, costs.OvkCost))
// PAKAN item
pakanBudgetRpPerBird, pakanBudgetRpPerKg := calculateMetrics(budgetPakan)
pakanRealizationRpPerBird, pakanRealizationRpPerKg := calculateMetrics(totalPakanPrice)
hppItems = append(hppItems, dto.ToHPPItem(
1,
"purchase",
string(dto.HPPCodePakan),
"Pembelian Pakan",
dto.ToFinancialMetrics(pakanBudgetRpPerBird, pakanBudgetRpPerKg, budgetPakan),
dto.ToFinancialMetrics(pakanRealizationRpPerBird, pakanRealizationRpPerKg, totalPakanPrice),
))
// OVK item
ovkBudgetRpPerBird, ovkBudgetRpPerKg := calculateMetrics(budgetOvk)
ovkRealizationRpPerBird, ovkRealizationRpPerKg := calculateMetrics(totalOvkPrice)
hppItems = append(hppItems, dto.ToHPPItem(
2,
"purchase",
string(dto.HPPCodeOVK),
"Pembelian OVK",
dto.ToFinancialMetrics(ovkBudgetRpPerBird, ovkBudgetRpPerKg, budgetOvk),
dto.ToFinancialMetrics(ovkRealizationRpPerBird, ovkRealizationRpPerKg, totalOvkPrice),
))
// DOC/DEPRESIASI item
docCode := string(dto.HPPCodeDOC)
docLabel := "Pembelian DOC"
if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) {
docCode = string(dto.HPPCodeDepresiasi)
docLabel = "Depresiasi"
}
hppItems = append(hppItems, createHPPItem(3, "purchase", docCode, docLabel, costs.ChickenCost, costs.ChickenCost))
hppItems = append(hppItems, createHPPItem(4, "overhead", string(dto.HPPCodeOverhead), "Pengeluaran Overhead", costs.BudgetOperational, costs.RealizationOperational))
hppItems = append(hppItems, createHPPItem(5, "overhead", string(dto.HPPCodeEkspedisi), "Beban Ekspedisi", costs.ExpeditionCost, costs.ExpeditionCost))
docBudgetRpPerBird, docBudgetRpPerKg := calculateMetrics(budgetAyam)
docRealizationRpPerBird, docRealizationRpPerKg := calculateMetrics(totalAyamPrice)
hppItems = append(hppItems, dto.ToHPPItem(
3,
"purchase",
docCode,
docLabel,
dto.ToFinancialMetrics(docBudgetRpPerBird, docBudgetRpPerKg, budgetAyam),
dto.ToFinancialMetrics(docRealizationRpPerBird, docRealizationRpPerKg, totalAyamPrice),
))
totalBudgetHpp := costs.FeedCost + costs.OvkCost + costs.ChickenCost + costs.BudgetOperational + costs.ExpeditionCost
totalRealizationHpp := costs.FeedCost + costs.OvkCost + costs.ChickenCost + costs.RealizationOperational + costs.ExpeditionCost
// OVERHEAD item
overheadBudgetRpPerBird, overheadBudgetRpPerKg := calculateMetrics(budgetOperational)
overheadRealizationRpPerBird, overheadRealizationRpPerKg := calculateMetrics(totalOperationalRealization)
hppItems = append(hppItems, dto.ToHPPItem(
4,
"overhead",
string(dto.HPPCodeOverhead),
"Pengeluaran Overhead",
dto.ToFinancialMetrics(overheadBudgetRpPerBird, overheadBudgetRpPerKg, budgetOperational),
dto.ToFinancialMetrics(overheadRealizationRpPerBird, overheadRealizationRpPerKg, totalOperationalRealization),
))
// EKSPEDISI item
ekspedisiBudgetRpPerBird, ekspedisiBudgetRpPerKg := calculateMetrics(budgetEkspedisi)
ekspedisiRealizationRpPerBird, ekspedisiRealizationRpPerKg := calculateMetrics(totalEkspedisiRealization)
hppItems = append(hppItems, dto.ToHPPItem(
5,
"overhead",
string(dto.HPPCodeEkspedisi),
"Beban Ekspedisi",
dto.ToFinancialMetrics(ekspedisiBudgetRpPerBird, ekspedisiBudgetRpPerKg, budgetEkspedisi),
dto.ToFinancialMetrics(ekspedisiRealizationRpPerBird, ekspedisiRealizationRpPerKg, totalEkspedisiRealization),
))
// HPP Summary
totalBudgetHpp := budgetPakan + budgetOvk + budgetAyam + budgetOperational + budgetEkspedisi
totalRealizationHpp := totalPakanPrice + totalOvkPrice + totalAyamPrice + totalOperationalRealization + totalEkspedisiRealization
hppBudgetRpPerBird, hppBudgetRpPerKg := calculateMetrics(totalBudgetHpp)
hppRealizationRpPerBird, hppRealizationRpPerKg := calculateMetrics(totalRealizationHpp)
var eggBudgeting, eggRealization *dto.FinancialMetrics
if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) {
accumulateEggMetrics := func(metrics **dto.FinancialMetrics, amount, rpPerKg float64) {
if *metrics == nil {
*metrics = &dto.FinancialMetrics{
RpPerBird: 0,
RpPerKg: rpPerKg,
Amount: amount,
}
} else {
(*metrics).Amount += amount
if totalEggWeightKg > 0 {
(*metrics).RpPerKg = (*metrics).Amount / totalEggWeightKg
}
}
if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) && totalEggWeightKg > 0 {
eggBudgetRpPerKg := totalBudgetHpp / totalEggWeightKg
eggRealizationRpPerKg := totalRealizationHpp / totalEggWeightKg
eggBudgeting = &dto.FinancialMetrics{
RpPerBird: 0,
RpPerKg: eggBudgetRpPerKg,
Amount: totalBudgetHpp,
}
for _, projectFlockKandang := range projectFlockKandangs {
hppResponse, err := s.HppSvc.CalculateHppCost(projectFlockKandang.Id, nil)
if err == nil {
accumulateEggMetrics(&eggBudgeting, hppResponse.Estimation.Total, hppResponse.Estimation.HargaKg)
accumulateEggMetrics(&eggRealization, hppResponse.Real.Total, hppResponse.Real.HargaKg)
}
eggRealization = &dto.FinancialMetrics{
RpPerBird: 0,
RpPerKg: eggRealizationRpPerKg,
Amount: totalRealizationHpp,
}
}
@@ -378,48 +543,12 @@ func (s closingKeuanganService) buildHPPSection(c *fiber.Ctx, projectFlock *enti
eggRealization,
)
return dto.ToHPPSection(hppItems, hppSummary)
}
func (s closingKeuanganService) buildProfitLossSection(projectFlock *entity.ProjectFlock, costs *CostData, production *ProductionData) dto.ProfitLossSection {
totalPopulationIn := production.TotalPopulationIn
totalWeightProduced := production.TotalWeightProduced
totalEggWeightKg := production.TotalEggWeightKg
totalSalesAmount := production.TotalSalesAmount
totalWeightSold := production.TotalWeightSold
weightForSales := totalWeightSold
weightForCalculation := totalWeightProduced
if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) {
weightForSales = totalWeightSold
weightForCalculation = totalEggWeightKg
}
calculateProfitLossMetrics := func(amount float64) (rpPerBird, rpPerKg float64) {
if totalPopulationIn > 0 {
rpPerBird = amount / totalPopulationIn
}
if weightForSales > 0 {
rpPerKg = amount / weightForSales
}
return
}
actualPopulation := production.TotalPopulationIn - production.TotalDepletion
calculateMetrics := func(amount float64) (rpPerBird, rpPerKg float64) {
if actualPopulation > 0 {
rpPerBird = amount / actualPopulation
}
if weightForCalculation > 0 {
rpPerKg = amount / weightForCalculation
}
return
}
hppSection := dto.ToHPPSection(hppItems, hppSummary)
// Build Profit Loss Items using constants
plItems := []dto.ProfitLossItem{}
// SALES item
salesRpPerBird, salesRpPerKg := calculateProfitLossMetrics(totalSalesAmount)
salesLabel := "Penjualan Ayam"
if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) {
@@ -434,13 +563,10 @@ func (s closingKeuanganService) buildProfitLossSection(projectFlock *entity.Proj
totalSalesAmount,
))
totalSapronakAmount := costs.ChickenCost + costs.FeedCost + costs.OvkCost
_, sapronakRpPerKg := calculateMetrics(totalSapronakAmount)
sapronakRpPerBird := 0.0
for _, amount := range []float64{costs.ChickenCost, costs.FeedCost, costs.OvkCost} {
rpPerBird, _ := calculateMetrics(amount)
sapronakRpPerBird += rpPerBird
}
// SAPRONAK item - combines DOC/Depresiasi + PAKAN + OVK
totalSapronakAmount := totalAyamPrice + totalPakanPrice + totalOvkPrice
sapronakRpPerBird := docRealizationRpPerBird + pakanRealizationRpPerBird + ovkRealizationRpPerBird
sapronakRpPerKg := docRealizationRpPerKg + pakanRealizationRpPerKg + ovkRealizationRpPerKg
sapronakLabel := "Pengeluaran Sapronak"
plItems = append(plItems, dto.ToProfitLossItem(
string(dto.PLCodeSapronak),
@@ -451,54 +577,62 @@ func (s closingKeuanganService) buildProfitLossSection(projectFlock *entity.Proj
totalSapronakAmount,
))
overheadRpPerBird, overheadRpPerKg := calculateProfitLossMetrics(costs.RealizationOperational)
// OVERHEAD item
overheadRpPerBird, overheadRpPerKg := calculateMetrics(totalOperationalRealization)
plItems = append(plItems, dto.ToProfitLossItem(
string(dto.PLCodeOverhead),
"Overhead",
"overhead",
overheadRpPerBird,
overheadRpPerKg,
costs.RealizationOperational,
totalOperationalRealization,
))
ekspedisiRpPerBird, ekspedisiRpPerKg := calculateProfitLossMetrics(costs.ExpeditionCost)
// EKSPEDISI item
plItems = append(plItems, dto.ToProfitLossItem(
string(dto.PLCodeEkspedisi),
"Ekspedisi",
"overhead",
ekspedisiRpPerBird,
ekspedisiRpPerKg,
costs.ExpeditionCost,
ekspedisiRealizationRpPerBird,
ekspedisiRealizationRpPerKg,
totalEkspedisiRealization,
))
costOfGoodsSold := costs.ChickenCost + costs.FeedCost + costs.OvkCost
// Profit Loss Summary
// Gross Profit = Sales - (DOC + PAKAN + OVK) only
// Gross Profit should NOT include overhead and ekspedisi
costOfGoodsSold := totalAyamPrice + totalPakanPrice + totalOvkPrice
costOfGoodsSoldRpPerBird := sapronakRpPerBird
costOfGoodsSoldRpPerKg := sapronakRpPerKg
grossProfit := totalSalesAmount - costOfGoodsSold
grossProfitRpPerBird := salesRpPerBird - costOfGoodsSoldRpPerBird
grossProfitRpPerKg := salesRpPerKg - costOfGoodsSoldRpPerKg
totalOperatingExpenses := costs.RealizationOperational + costs.ExpeditionCost
totalOperatingExpensesRpPerBird := overheadRpPerBird + ekspedisiRpPerBird
totalOperatingExpensesRpPerKg := overheadRpPerKg + ekspedisiRpPerKg
// Operating Expenses (Overhead + Ekspedisi)
totalOperatingExpenses := totalOperationalRealization + totalEkspedisiRealization
totalOperatingExpensesRpPerBird := overheadRpPerBird + ekspedisiRealizationRpPerBird
// Net Profit = Gross Profit - Operating Expenses
netProfit := grossProfit - totalOperatingExpenses
netProfitRpPerBird := grossProfitRpPerBird - totalOperatingExpensesRpPerBird
netProfitRpPerKg := grossProfitRpPerKg - totalOperatingExpensesRpPerKg
plSummary := dto.ToProfitLossSummary(
dto.ToFinancialMetrics(grossProfitRpPerBird, grossProfitRpPerKg, grossProfit),
dto.ToFinancialMetrics(totalOperatingExpensesRpPerBird, totalOperatingExpensesRpPerKg, totalOperatingExpenses),
dto.ToFinancialMetrics(netProfitRpPerBird, netProfitRpPerKg, netProfit),
dto.ToFinancialMetrics(grossProfitRpPerBird, 0, grossProfit),
dto.ToFinancialMetrics(totalOperatingExpensesRpPerBird, 0, totalOperatingExpenses),
dto.ToFinancialMetrics(netProfitRpPerBird, 0, netProfit),
)
return dto.ToProfitLossSection(plItems, plSummary)
profitLossSection := dto.ToProfitLossSection(plItems, plSummary)
// Build complete response
data := dto.ToClosingKeuanganData(hppSection, profitLossSection)
return &data, nil
}
func containsFlag(flags []entity.Flag, name string) bool {
for _, flag := range flags {
if flag.Name == name {
// containsItem checks if a string exists in a slice
func containsItem(slice []string, item string) bool {
for _, s := range slice {
if strings.EqualFold(s, item) {
return true
}
}
@@ -2,7 +2,6 @@ package service
import (
"context"
"fmt"
"strings"
"github.com/go-playground/validator/v10"
@@ -112,7 +111,7 @@ func (s sapronakService) computeSapronakReports(ctx context.Context, params *val
}
// We no longer filter by date for closing sapronak report; pass nil pointers.
items, groups, totalIncoming, totalUsage, err := s.buildSapronakItems(ctx, pfk, params.Flag)
items, groups, totalIncoming, totalUsage, err := s.buildSapronakItems(ctx, pfk, params.Flag)
if err != nil {
s.Log.Errorf("Failed to build sapronak items for pfk %d: %+v", pfk.Id, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to calculate sapronak report")
@@ -263,7 +262,6 @@ type sapronakDetailMaps struct {
AdjOutgoing map[uint][]dto.SapronakDetailDTO
TransferIn map[uint][]dto.SapronakDetailDTO
TransferOut map[uint][]dto.SapronakDetailDTO
SalesOut map[uint][]dto.SapronakDetailDTO
}
func buildSapronakDetails(
@@ -273,7 +271,6 @@ func buildSapronakDetails(
adjOutgoingRows map[uint][]repository.SapronakDetailRow,
transferInRows map[uint][]repository.SapronakDetailRow,
transferOutRows map[uint][]repository.SapronakDetailRow,
salesOutRows map[uint][]repository.SapronakDetailRow,
) sapronakDetailMaps {
result := sapronakDetailMaps{
Incoming: make(map[uint][]dto.SapronakDetailDTO),
@@ -282,7 +279,6 @@ func buildSapronakDetails(
AdjOutgoing: make(map[uint][]dto.SapronakDetailDTO),
TransferIn: make(map[uint][]dto.SapronakDetailDTO),
TransferOut: make(map[uint][]dto.SapronakDetailDTO),
SalesOut: make(map[uint][]dto.SapronakDetailDTO),
}
addRows := func(target map[uint][]dto.SapronakDetailDTO, src map[uint][]repository.SapronakDetailRow, jenis string, masuk bool) {
@@ -315,7 +311,6 @@ func buildSapronakDetails(
addRows(result.AdjOutgoing, adjOutgoingRows, "Adjustment Keluar", false)
addRows(result.TransferIn, transferInRows, "Mutasi Masuk", true)
addRows(result.TransferOut, transferOutRows, "Mutasi Keluar", false)
addRows(result.SalesOut, salesOutRows, "Penjualan", false)
return result
}
@@ -347,14 +342,6 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
if err != nil {
return nil, nil, 0, 0, err
}
usageAllocatedDetails, err := s.Repository.FetchSapronakUsageAllocatedDetails(ctx, pfk.Id)
if err != nil {
return nil, nil, 0, 0, err
}
if len(usageAllocatedDetails) > 0 {
usageDetailsRows = usageAllocatedDetails
chickinUsageDetailsRows = map[uint][]repository.SapronakDetailRow{}
}
adjIncomingRows, adjOutgoingRows, err := s.Repository.FetchSapronakAdjustments(ctx, pfk.KandangId)
if err != nil {
return nil, nil, 0, 0, err
@@ -363,10 +350,6 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
if err != nil {
return nil, nil, 0, 0, err
}
salesOutRows, err := s.Repository.FetchSapronakSales(ctx, pfk.Id)
if err != nil {
return nil, nil, 0, 0, err
}
filterFlag := strings.ToUpper(strings.TrimSpace(flagFilter))
matchesFlag := func(f string) bool {
@@ -379,34 +362,6 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
}
return candidate == filterFlag
}
dedupTransfers := func(src map[uint][]dto.SapronakDetailDTO) map[uint][]dto.SapronakDetailDTO {
result := make(map[uint][]dto.SapronakDetailDTO, len(src))
seen := make(map[string]struct{})
for pid, rows := range src {
for _, d := range rows {
dateKey := ""
if d.Tanggal != nil {
dateKey = d.Tanggal.Format("2006-01-02")
}
qtyKey := d.QtyMasuk
if qtyKey == 0 {
qtyKey = d.QtyKeluar
}
ref := strings.TrimSpace(d.NoReferensi)
key := fmt.Sprintf("%d|%s|%s|%.3f", pid, ref, dateKey, qtyKey)
if ref == "" {
key = fmt.Sprintf("%d|%s|%s|%.3f|%s", pid, ref, dateKey, qtyKey, strings.ToUpper(strings.TrimSpace(d.Flag)))
}
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
result[pid] = append(result[pid], d)
}
}
return result
}
// For project flocks with category GROWING, pullet usage from chickin
// should not be counted yet. Only when category is LAYING we allow
@@ -445,17 +400,13 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
usageDetailsRows[pid] = append(usageDetailsRows[pid], rows...)
}
detailMaps := buildSapronakDetails(incomingDetailsRows, usageDetailsRows, adjIncomingRows, adjOutgoingRows, transIncomingRows, transOutgoingRows, salesOutRows)
detailMaps := buildSapronakDetails(incomingDetailsRows, usageDetailsRows, adjIncomingRows, adjOutgoingRows, transIncomingRows, transOutgoingRows)
incomingDetails := detailMaps.Incoming
usageDetails := detailMaps.Usage
adjIncoming := detailMaps.AdjIncoming
adjOutgoing := detailMaps.AdjOutgoing
transIncoming := detailMaps.TransferIn
transOutgoing := detailMaps.TransferOut
salesOutgoing := detailMaps.SalesOut
transIncoming = dedupTransfers(transIncoming)
transOutgoing = dedupTransfers(transOutgoing)
ensureGroup := func(flag string) *dto.SapronakGroupDTO {
if g, ok := groupMap[flag]; ok {
@@ -732,25 +683,6 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
}
}
for productID, details := range salesOutgoing {
flag, name := resolveFlagName(productID, details)
if !matchesFlag(flag) {
continue
}
group := ensureGroup(flag)
for _, d := range details {
if d.Flag == "" {
d.Flag = flag
}
if d.ProductName == "" {
d.ProductName = name
}
group.Items = append(group.Items, d)
group.TotalKeluar += d.QtyKeluar
group.SaldoAkhir -= d.QtyKeluar
}
}
groups := make([]dto.SapronakGroupDTO, 0, len(groupMap))
for _, g := range groupMap {
groups = append(groups, *g)
@@ -9,11 +9,9 @@ type Update struct {
}
type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1,gt=0"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"`
Search string `query:"search" validate:"omitempty,max=50"`
ProjectStatus *int `query:"project_status" validate:"omitempty,oneof=1 2"`
LocationID *uint `query:"location_id" validate:"omitempty,gt=0"`
Page int `query:"page" validate:"omitempty,number,min=1,gt=0"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"`
Search string `query:"search" validate:"omitempty,max=50"`
}
const (
@@ -26,5 +24,4 @@ type ClosingSapronakQuery struct {
Page int `query:"page" validate:"omitempty,number,min=1,gt=0"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"`
KandangID *uint `query:"kandang_id" validate:"omitempty,gt=0"`
Search string `query:"search" validate:"omitempty,max=100"`
}
@@ -83,7 +83,7 @@ func (r *ConstantRepositoryImpl) GetConstants() map[string]interface{} {
"KANDANG",
},
"stock_log": map[string][]string{
"log_types": []string{"TRANSFER", "ADJUSTMENT", "MARKETING", "CHICKIN", "PURCHASE", "RECORDING"},
"log_types": []string{"TRANSFER", "ADJUSTMENT"},
"transaction_types": []string{"INCREASE", "DECREASE"},
},
"supplier_categories": []string{
@@ -52,7 +52,7 @@ type SummaryQuery struct {
type ReportQuery struct {
Page int `query:"page" validate:"required,number,min=1,gt=0"`
Limit int `query:"limit" validate:"required,number,min=1,gt=0"`
Limit int `query:"limit" validate:"required,number,min=1,max=100,gt=0"`
Month int `query:"bulan" validate:"required,number,min=1,max=12"`
Year int `query:"tahun" validate:"required,number,min=1900"`
AreaID *uint `query:"area_id" validate:"omitempty"`
+2 -5
View File
@@ -5,8 +5,6 @@ import (
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonService "gitlab.com/mbugroup/lti-api.git/internal/common/service"
rDashboard "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/repositories"
sDashboard "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/services"
@@ -18,12 +16,11 @@ type DashboardModule struct{}
func (DashboardModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) {
dashboardRepo := rDashboard.NewDashboardRepository(db)
hppCostRepo := commonRepo.NewHppCostRepository(db)
userRepo := rUser.NewUserRepository(db)
hppSvc := commonService.NewHppService(hppCostRepo)
dashboardService := sDashboard.NewDashboardService(dashboardRepo, validate, hppSvc)
dashboardService := sDashboard.NewDashboardService(dashboardRepo, validate)
userService := sUser.NewUserService(userRepo, validate)
DashboardRoutes(router, userService, dashboardService)
}
@@ -21,7 +21,6 @@ type DashboardRepository interface {
SumSellingPrice(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) (SellingPriceAggregate, error)
SumEggProductionWeightGrams(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) (float64, error)
SumEggProductionWeightKg(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) (float64, error)
ListProjectFlockKandangIDsByEggProduction(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]uint, error)
GetRecordingWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]RecordingWeeklyMetric, error)
GetUniformityWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]UniformityWeeklyMetric, error)
GetStandardWeeklyMetrics(ctx context.Context, weeks []int, filters *validation.DashboardFilter) ([]StandardWeeklyMetric, error)
@@ -285,7 +285,7 @@ func (r *DashboardRepositoryImpl) SumEggProductionWeightGrams(ctx context.Contex
db := r.DB().WithContext(ctx).
Table("recording_eggs AS re").
Select("COALESCE(SUM(re.weight * 1000), 0)").
Select("COALESCE(SUM(re.qty * re.weight), 0)").
Joins("JOIN recordings AS r ON r.id = re.recording_id").
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id").
Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id").
@@ -309,27 +309,6 @@ func (r *DashboardRepositoryImpl) SumEggProductionWeightKg(ctx context.Context,
return grams / 1000, nil
}
func (r *DashboardRepositoryImpl) ListProjectFlockKandangIDsByEggProduction(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]uint, error) {
var ids []uint
db := r.DB().WithContext(ctx).
Table("recording_eggs AS re").
Select("DISTINCT r.project_flock_kandangs_id").
Joins("JOIN recordings AS r ON r.id = re.recording_id").
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id").
Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id").
Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end).
Where("r.deleted_at IS NULL")
db = applyDashboardFilters(db, filters)
if err := db.Scan(&ids).Error; err != nil {
return nil, err
}
return ids, nil
}
func (r *DashboardRepositoryImpl) GetFeedUsageByUom(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]FeedUsageByUom, error) {
var rows []FeedUsageByUom
@@ -574,7 +553,7 @@ func (r *DashboardRepositoryImpl) GetComparisonWeeklyMetrics(ctx context.Context
var rows []ComparisonWeeklyMetric
db := r.DB().WithContext(ctx).
Table("recordings AS r").
Select(fmt.Sprintf(`(CASE WHEN r.day IS NULL OR r.day <= 0 THEN 1 ELSE ((r.day - 1) / 7 + 1) END) AS week,
Select(fmt.Sprintf(`((r.day - 1) / 7 + 1) AS week,
%s AS series_id,
COALESCE(AVG(%s), 0) AS value`, seriesExpr, metricExpr)).
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id").
@@ -582,7 +561,8 @@ func (r *DashboardRepositoryImpl) GetComparisonWeeklyMetrics(ctx context.Context
Joins("JOIN project_flocks AS pf ON pf.id = pfk.project_flock_id").
Joins("JOIN locations AS loc ON loc.id = k.location_id").
Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end).
Where("r.deleted_at IS NULL")
Where("r.deleted_at IS NULL").
Where("r.day IS NOT NULL AND r.day > 0")
db = applyDashboardFilters(db, filters)
@@ -668,7 +648,7 @@ func (r *DashboardRepositoryImpl) GetEggWeightWeeklyGrams(ctx context.Context, s
Table("recording_eggs AS re").
Select(`
((r.day - 1) / 7 + 1) AS week,
COALESCE(SUM(re.weight * 1000), 0) AS egg_weight_grams`).
COALESCE(SUM(re.qty * re.weight), 0) AS egg_weight_grams`).
Joins("JOIN recordings AS r ON r.id = re.recording_id").
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id").
Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id").
@@ -10,7 +10,6 @@ import (
"strings"
"time"
commonService "gitlab.com/mbugroup/lti-api.git/internal/common/service"
"gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/dto"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/validations"
@@ -28,15 +27,13 @@ type dashboardService struct {
Log *logrus.Logger
Validate *validator.Validate
Repository repository.DashboardRepository
HppSvc commonService.HppService
}
func NewDashboardService(repo repository.DashboardRepository, validate *validator.Validate, hppSvc commonService.HppService) DashboardService {
func NewDashboardService(repo repository.DashboardRepository, validate *validator.Validate) DashboardService {
return &dashboardService{
Log: utils.Log,
Validate: validate,
Repository: repo,
HppSvc: hppSvc,
}
}
@@ -595,13 +592,13 @@ func buildAggregateComparisonPercent(weeks []int, seriesRows []repository.Compar
count++
}
if count == 0 {
continue
}
if result[week] == nil {
result[week] = map[uint]float64{}
}
if count == 0 {
result[week][series.Id] = 0
continue
}
result[week][series.Id] = sum / count
}
}
@@ -849,21 +846,6 @@ func percentDelta(current, last float64) float64 {
}
func (s dashboardService) calculateHppGlobal(ctx context.Context, startDate, endExclusive, endDate time.Time, location *time.Location) (float64, float64, error) {
if s.HppSvc != nil {
currentHpp, err := s.hppGlobalForPeriod(ctx, startDate, endExclusive)
if err != nil {
return 0, 0, err
}
lastMonthStart, lastMonthEndExclusive := monthRange(endDate.AddDate(0, -1, 0), location)
lastHpp, err := s.hppGlobalForPeriod(ctx, lastMonthStart, lastMonthEndExclusive)
if err != nil {
return 0, 0, err
}
return currentHpp, lastHpp, nil
}
totalEggKg, err := s.Repository.SumEggProductionWeightKg(ctx, startDate, endExclusive, nil)
if err != nil {
return 0, 0, err
@@ -896,37 +878,6 @@ func (s dashboardService) calculateHppGlobal(ctx context.Context, startDate, end
return hppCurrent, hppLast, nil
}
func (s dashboardService) hppGlobalForPeriod(ctx context.Context, startDate, endExclusive time.Time) (float64, error) {
kandangIDs, err := s.Repository.ListProjectFlockKandangIDsByEggProduction(ctx, startDate, endExclusive, nil)
if err != nil {
return 0, err
}
if len(kandangIDs) == 0 {
return 0, nil
}
endOfPeriod := endExclusive.Add(-time.Nanosecond)
totalCost := 0.0
totalWeightKg := 0.0
for _, kandangID := range kandangIDs {
hppCost, err := s.HppSvc.CalculateHppCost(kandangID, &endOfPeriod)
if err != nil {
return 0, err
}
if hppCost == nil {
continue
}
totalCost += hppCost.Estimation.Total
totalWeightKg += hppCost.Estimation.Kg
}
if totalWeightKg <= 0 {
return 0, nil
}
return totalCost / totalWeightKg, nil
}
func (s dashboardService) calculateSellingPrice(ctx context.Context, endDate time.Time, location *time.Location) (float64, float64, error) {
startPrevMonth, endPrevMonthExclusive := monthRange(endDate.AddDate(0, -1, 0), location)
currentEndExclusive := endDate.AddDate(0, 0, 1)
@@ -70,8 +70,7 @@ func (r *ExpenseRealizationRepositoryImpl) GetClosingOverhead(ctx context.Contex
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 kandangs ON kandangs.id = expense_nonstocks.kandang_id").
Where("expenses.realization_date IS NOT NULL").
Where("expenses.category = ?", "BOP")
Where("expenses.realization_date IS NOT NULL")
if projectFlockKandangID != nil {
db = db.Where(`(
@@ -20,7 +20,7 @@ type InitialRelationDTO struct {
InitialBalanceType string `json:"initial_balance_type"`
InitialBalanceTypeLabel string `json:"initial_balance_type_label"`
Party Party `json:"party"`
Bank *bankDTO.BankRelationDTO `json:"bank"`
Bank bankDTO.BankRelationDTO `json:"bank,omitempty"`
Direction string `json:"direction"`
Nominal float64 `json:"nominal"`
Notes string `json:"notes"`
@@ -128,12 +128,11 @@ func partyFromInitial(e entity.Payment) Party {
return party
}
func bankFromInitial(e entity.Payment) *bankDTO.BankRelationDTO {
func bankFromInitial(e entity.Payment) bankDTO.BankRelationDTO {
if e.BankWarehouse.Id == 0 {
return nil
return bankDTO.BankRelationDTO{}
}
bank := bankDTO.ToBankRelationDTO(e.BankWarehouse)
return &bank
return bankDTO.ToBankRelationDTO(e.BankWarehouse)
}
func userFromInitial(e entity.Payment) userDTO.UserRelationDTO {
@@ -162,7 +161,7 @@ func initialBalanceLabel(balanceType string) string {
}
func initialBalanceTypeFromPayment(e entity.Payment) string {
if e.Nominal < 0 {
if strings.EqualFold(e.Direction, "OUT") || e.Nominal < 0 {
return "NEGATIVE"
}
return "POSITIVE"
@@ -82,7 +82,6 @@ func (s initialService) GetOne(c *fiber.Ctx, id uint) (*entity.Payment, error) {
}
func (s *initialService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Payment, error) {
normalizeOptionalBankId(&req.BankId)
if err := s.Validate.Struct(req); err != nil {
return nil, err
}
@@ -125,7 +124,7 @@ func (s *initialService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit
PaymentDate: time.Now(),
PaymentMethod: string(utils.PaymentMethodSaldo),
BankId: req.BankId,
Direction: directionForInitialType(party, balanceType),
Direction: directionForInitialType(balanceType),
Nominal: signedNominal(balanceType, req.Nominal),
Notes: req.Note,
CreatedBy: actorID,
@@ -165,7 +164,6 @@ func (s *initialService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit
}
func (s initialService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Payment, error) {
normalizeOptionalBankId(&req.BankId)
if err := s.Validate.Struct(req); err != nil {
return nil, err
}
@@ -188,8 +186,6 @@ func (s initialService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
requiresExisting := req.PartyType != nil || req.PartyId != nil || req.InitialBalanceType != nil || req.Nominal != nil
requiresVerification := requiresExisting || req.ReferenceNumber != nil || req.Note != nil || req.BankId != nil
var existing *entity.Payment
var resolvedPartyType string
var resolvedPartyId uint
if requiresVerification {
current, err := s.Repository.GetByID(c.Context(), id, nil)
if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -203,25 +199,26 @@ func (s initialService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
return nil, fiber.NewError(fiber.StatusNotFound, "Initial not found")
}
existing = current
resolvedPartyType = existing.PartyType
resolvedPartyId = existing.PartyId
}
if req.PartyType != nil || req.PartyId != nil {
partyType := existing.PartyType
partyId := existing.PartyId
if req.PartyType != nil {
normalized, err := normalizePartyType(*req.PartyType)
if err != nil {
return nil, err
}
resolvedPartyType = normalized
updateBody["party_type"] = resolvedPartyType
partyType = normalized
updateBody["party_type"] = partyType
}
if req.PartyId != nil {
resolvedPartyId = *req.PartyId
updateBody["party_id"] = resolvedPartyId
partyId = *req.PartyId
updateBody["party_id"] = partyId
}
if err := s.ensurePartyExists(c.Context(), resolvedPartyType, resolvedPartyId); err != nil {
if err := s.ensurePartyExists(c.Context(), partyType, partyId); err != nil {
return nil, err
}
}
@@ -241,11 +238,8 @@ func (s initialService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
nominal = *req.Nominal
}
updateBody["direction"] = directionForInitialType(resolvedPartyType, balanceType)
updateBody["direction"] = directionForInitialType(balanceType)
updateBody["nominal"] = signedNominal(balanceType, nominal)
} else if req.PartyType != nil {
balanceType := balanceTypeFromPayment(existing)
updateBody["direction"] = directionForInitialType(resolvedPartyType, balanceType)
}
if len(updateBody) == 0 {
@@ -268,7 +262,7 @@ func isInitialTransaction(transactionType string) bool {
}
func balanceTypeFromPayment(payment *entity.Payment) string {
if payment.Nominal < 0 {
if strings.EqualFold(payment.Direction, "OUT") || payment.Nominal < 0 {
return "NEGATIVE"
}
return "POSITIVE"
@@ -292,24 +286,11 @@ func normalizeInitialBalanceType(balanceType string) (string, error) {
}
}
func directionForInitialType(partyType string, balanceType string) string {
switch utils.PaymentParty(strings.ToUpper(strings.TrimSpace(partyType))) {
case utils.PaymentPartySupplier:
if strings.EqualFold(balanceType, "POSITIVE") {
return "OUT"
}
return "IN"
case utils.PaymentPartyCustomer:
if strings.EqualFold(balanceType, "NEGATIVE") {
return "OUT"
}
return "IN"
default:
if strings.EqualFold(balanceType, "NEGATIVE") {
return "OUT"
}
return "IN"
func directionForInitialType(balanceType string) string {
if strings.EqualFold(balanceType, "NEGATIVE") {
return "OUT"
}
return "IN"
}
func signedNominal(balanceType string, nominal float64) float64 {
@@ -354,12 +335,3 @@ func (s initialService) ensureBankExists(ctx context.Context, bankId *uint) erro
commonSvc.RelationCheck{Name: "Bank", ID: bankId, Exists: s.Repository.BankExists},
)
}
func normalizeOptionalBankId(bankId **uint) {
if bankId == nil || *bankId == nil {
return
}
if **bankId == 0 {
*bankId = nil
}
}
@@ -3,7 +3,7 @@ package validation
type Create struct {
PartyType string `json:"party_type" validate:"required_strict,max=50"`
PartyId uint `json:"party_id" validate:"required_strict,number,gt=0"`
BankId *uint `json:"bank_id,omitempty" validate:"omitempty,number,gt=0"`
BankId *uint `json:"bank_id" validate:"required_strict,number,gt=0"`
ReferenceNumber string `json:"reference_number" validate:"required_strict,max=100"`
InitialBalanceType string `json:"initial_balance_type" validate:"required_strict,oneof=NEGATIVE POSITIVE"`
Nominal float64 `json:"nominal" validate:"required_strict,gt=0"`
@@ -110,7 +110,7 @@ func (s *injectionService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
PaymentDate: adjustmentDate,
PaymentMethod: string(utils.PaymentMethodSaldo),
BankId: req.BankId,
Direction: directionForInjectionNominal(req.Nominal),
Direction: "IN",
Nominal: req.Nominal,
Notes: req.Notes,
CreatedBy: actorID,
@@ -186,7 +186,6 @@ func (s injectionService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
}
if req.Nominal != nil {
updateBody["nominal"] = *req.Nominal
updateBody["direction"] = directionForInjectionNominal(*req.Nominal)
}
if req.Notes != nil {
updateBody["notes"] = *req.Notes
@@ -211,13 +210,6 @@ func isInjectionTransaction(transactionType string) bool {
return strings.EqualFold(transactionType, string(utils.TransactionTypeInjection))
}
func directionForInjectionNominal(nominal float64) string {
if nominal < 0 {
return "OUT"
}
return "IN"
}
func (s injectionService) generateInjectionCode(ctx context.Context) (string, error) {
sequence, err := s.Repository.NextPaymentSequence(ctx)
if err != nil {
@@ -3,14 +3,14 @@ package validation
type Create struct {
BankId *uint `json:"bank_id" validate:"required_strict,number,gt=0"`
AdjustmentDate string `json:"adjustment_date" validate:"required_strict"`
Nominal float64 `json:"nominal" validate:"required_strict"`
Nominal float64 `json:"nominal" validate:"required_strict,gt=0"`
Notes string `json:"notes" validate:"required_strict,max=500"`
}
type Update struct {
BankId *uint `json:"bank_id,omitempty" validate:"omitempty,number,gt=0"`
AdjustmentDate *string `json:"adjustment_date,omitempty" validate:"omitempty"`
Nominal *float64 `json:"nominal,omitempty"`
Nominal *float64 `json:"nominal,omitempty" validate:"omitempty,gt=0"`
Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"`
}
@@ -3,7 +3,6 @@ package controller
import (
"math"
"strconv"
"strings"
"gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/dto"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/services"
@@ -24,46 +23,10 @@ func NewTransactionController(transactionService service.TransactionService) *Tr
}
func (u *TransactionController) GetAll(c *fiber.Ctx) error {
parseOptionalUint := func(key string) (*uint, error) {
raw := strings.TrimSpace(c.Query(key, ""))
if raw == "" {
return nil, nil
}
parsed, err := strconv.ParseUint(raw, 10, 64)
if err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid "+key)
}
if parsed == 0 {
return nil, nil
}
value := uint(parsed)
return &value, nil
}
bankId, err := parseOptionalUint("bank_id")
if err != nil {
return err
}
customerId, err := parseOptionalUint("customer_id")
if err != nil {
return err
}
supplierId, err := parseOptionalUint("supplier_id")
if err != nil {
return err
}
query := &validation.Query{
Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10),
Search: c.Query("search", ""),
TransactionType: c.Query("transaction_type", ""),
BankId: bankId,
CustomerId: customerId,
SupplierId: supplierId,
SortDate: c.Query("sort_date", ""),
StartDate: c.Query("start_date", ""),
EndDate: c.Query("end_date", ""),
Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10),
Search: c.Query("search", ""),
}
if query.Page < 1 || query.Limit < 1 {
@@ -21,7 +21,7 @@ type TransactionRelationDTO struct {
Party Party `json:"party"`
PaymentDate time.Time `json:"payment_date"`
PaymentMethod string `json:"payment_method"`
Bank *bankDTO.BankRelationDTO `json:"bank"`
Bank bankDTO.BankRelationDTO `json:"bank,omitempty"`
ExpenseAmount float64 `json:"expense_amount"`
IncomeAmount float64 `json:"income_amount"`
Nominal float64 `json:"nominal"`
@@ -37,7 +37,7 @@ type TransactionListDTO struct {
Party Party `json:"party"`
PaymentDate time.Time `json:"payment_date"`
PaymentMethod string `json:"payment_method"`
Bank *bankDTO.BankRelationDTO `json:"bank"`
Bank bankDTO.BankRelationDTO `json:"bank"`
ExpenseAmount float64 `json:"expense_amount"`
IncomeAmount float64 `json:"income_amount"`
Nominal float64 `json:"nominal"`
@@ -151,12 +151,11 @@ func partyFromPayment(e entity.Payment) Party {
return party
}
func bankFromPayment(e entity.Payment) *bankDTO.BankRelationDTO {
func bankFromPayment(e entity.Payment) bankDTO.BankRelationDTO {
if e.BankWarehouse.Id == 0 {
return nil
return bankDTO.BankRelationDTO{}
}
bank := bankDTO.ToBankRelationDTO(e.BankWarehouse)
return &bank
return bankDTO.ToBankRelationDTO(e.BankWarehouse)
}
func userFromPayment(e entity.Payment) userDTO.UserRelationDTO {
@@ -4,7 +4,6 @@ import (
"context"
"errors"
"strings"
"time"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
@@ -62,19 +61,13 @@ func (s transactionService) GetAll(c *fiber.Ctx, params *validation.Query) ([]en
return nil, 0, err
}
startDate, endDate, err := parseTransactionDateRange(params.StartDate, params.EndDate)
if err != nil {
return nil, 0, err
}
offset := (params.Page - 1) * params.Limit
transactions, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db)
if params.Search != "" {
like := "%" + strings.ToLower(strings.TrimSpace(params.Search)) + "%"
db = db.Where(
return db.Where(
`LOWER(payment_code) LIKE ? OR
LOWER(COALESCE(reference_number, '')) LIKE ? OR
LOWER(COALESCE(transaction_type, '')) LIKE ? OR
@@ -82,35 +75,7 @@ func (s transactionService) GetAll(c *fiber.Ctx, params *validation.Query) ([]en
like, like, like, like,
)
}
if strings.TrimSpace(params.TransactionType) != "" {
db = db.Where("transaction_type = ?", strings.ToUpper(strings.TrimSpace(params.TransactionType)))
}
if params.BankId != nil {
db = db.Where("bank_id = ?", *params.BankId)
}
if params.CustomerId != nil && params.SupplierId != nil {
db = db.Where(
"(party_type = ? AND party_id = ?) OR (party_type = ? AND party_id = ?)",
string(utils.PaymentPartyCustomer), *params.CustomerId,
string(utils.PaymentPartySupplier), *params.SupplierId,
)
} else if params.CustomerId != nil {
db = db.Where("party_type = ? AND party_id = ?", string(utils.PaymentPartyCustomer), *params.CustomerId)
} else if params.SupplierId != nil {
db = db.Where("party_type = ? AND party_id = ?", string(utils.PaymentPartySupplier), *params.SupplierId)
}
if startDate != nil {
db = db.Where("payment_date >= ?", *startDate)
}
if endDate != nil {
db = db.Where("payment_date < ?", *endDate)
}
return applyTransactionSort(db, params.SortDate)
return db.Order("payment_date DESC").Order("created_at DESC")
})
if err != nil {
@@ -208,47 +173,3 @@ func (s transactionService) approvalQueryModifier() func(*gorm.DB) *gorm.DB {
return db.Preload("ActionUser")
}
}
func parseTransactionDateRange(startDate, endDate string) (*time.Time, *time.Time, error) {
start := strings.TrimSpace(startDate)
end := strings.TrimSpace(endDate)
var startPtr *time.Time
var endPtr *time.Time
var endValue *time.Time
if start != "" {
parsed, err := utils.ParseDateString(start)
if err != nil {
return nil, nil, utils.BadRequest("start_date must use format YYYY-MM-DD")
}
startPtr = &parsed
}
if end != "" {
parsed, err := utils.ParseDateString(end)
if err != nil {
return nil, nil, utils.BadRequest("end_date must use format YYYY-MM-DD")
}
endValue = &parsed
nextDay := parsed.AddDate(0, 0, 1)
endPtr = &nextDay
}
if startPtr != nil && endValue != nil && startPtr.After(*endValue) {
return nil, nil, utils.BadRequest("start_date must be earlier than end_date")
}
return startPtr, endPtr, nil
}
func applyTransactionSort(db *gorm.DB, sortDate string) *gorm.DB {
switch strings.ToLower(strings.TrimSpace(sortDate)) {
case "created_at":
return db.Order("created_at DESC").Order("payment_date DESC")
case "payment_date":
return db.Order("payment_date DESC").Order("created_at DESC")
default:
return db.Order("payment_date DESC").Order("created_at DESC")
}
}
@@ -9,14 +9,7 @@ type Update struct {
}
type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1,gt=0"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"`
Search string `query:"search" validate:"omitempty,max=50"`
TransactionType string `query:"transaction_type" validate:"omitempty,max=50"`
BankId *uint `query:"bank_id" validate:"omitempty,number,gt=0"`
CustomerId *uint `query:"customer_id" validate:"omitempty,number,gt=0"`
SupplierId *uint `query:"supplier_id" validate:"omitempty,number,gt=0"`
SortDate string `query:"sort_date" validate:"omitempty,oneof=created_at payment_date"`
StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"`
EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"`
Page int `query:"page" validate:"omitempty,number,min=1,gt=0"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"`
Search string `query:"search" validate:"omitempty,max=50"`
}
@@ -100,24 +100,26 @@ func ToProductWarehouseDTO(e *entity.ProductWarehouse) *ProductWarehouseDTO {
}
}
func ToAdjustmentRelationDTO(e *entity.AdjustmentStock) AdjustmentRelationDTO {
func ToAdjustmentRelationDTO(e *entity.StockLog) AdjustmentRelationDTO {
return AdjustmentRelationDTO{
Id: e.Id,
Note: "",
Increase: e.TotalQty,
Decrease: e.UsageQty,
Note: e.Notes,
Increase: e.Increase,
Decrease: e.Decrease,
ProductWarehouseId: e.ProductWarehouseId,
ProductWarehouse: ToProductWarehouseDTO(e.ProductWarehouse),
}
}
func ToAdjustmentListDTO(e *entity.AdjustmentStock) AdjustmentListDTO {
func ToAdjustmentListDTO(e *entity.StockLog) AdjustmentListDTO {
var createdUser *userDTO.UserRelationDTO
// Get created user from StockLog
if e.StockLog != nil && e.StockLog.CreatedUser != nil {
mapped := userDTO.ToUserRelationDTO(*e.StockLog.CreatedUser)
createdUser = &mapped
if e.CreatedUser != nil {
createdUser = &userDTO.UserRelationDTO{
Id: e.CreatedUser.Id,
IdUser: e.CreatedUser.IdUser,
Email: e.CreatedUser.Email,
Name: e.CreatedUser.Name,
}
}
return AdjustmentListDTO{
@@ -127,8 +129,9 @@ func ToAdjustmentListDTO(e *entity.AdjustmentStock) AdjustmentListDTO {
}
}
func ToAdjustmentDetailDTO(e *entity.AdjustmentStock) AdjustmentDetailDTO {
func ToAdjustmentDetailDTO(e *entity.StockLog) AdjustmentDetailDTO {
return AdjustmentDetailDTO{
AdjustmentListDTO: ToAdjustmentListDTO(e),
// UpdatedAt: e.UpdatedAt,
}
}
@@ -9,7 +9,7 @@ import (
type AdjustmentStockRepository interface {
CreateOne(ctx context.Context, data *entity.AdjustmentStock, modifier func(*gorm.DB) *gorm.DB) error
GetByID(ctx context.Context, id uint, modifier func(*gorm.DB) *gorm.DB) (*entity.AdjustmentStock, error)
GetByStockLogID(ctx context.Context, stockLogID uint) (*entity.AdjustmentStock, error)
WithTx(tx *gorm.DB) AdjustmentStockRepository
DB() *gorm.DB
}
@@ -30,13 +30,11 @@ func (r *adjustmentStockRepositoryImpl) CreateOne(ctx context.Context, data *ent
return q.Create(data).Error
}
func (r *adjustmentStockRepositoryImpl) GetByID(ctx context.Context, id uint, modifier func(*gorm.DB) *gorm.DB) (*entity.AdjustmentStock, error) {
func (r *adjustmentStockRepositoryImpl) GetByStockLogID(ctx context.Context, stockLogID uint) (*entity.AdjustmentStock, error) {
var record entity.AdjustmentStock
q := r.db.WithContext(ctx)
if modifier != nil {
q = modifier(q)
}
err := q.First(&record, id).Error
err := r.db.WithContext(ctx).
Where("stock_log_id = ?", stockLogID).
First(&record).Error
if err != nil {
return nil, err
}
@@ -25,9 +25,9 @@ import (
)
type AdjustmentService interface {
Adjustment(ctx *fiber.Ctx, req *validation.Create) (*entity.AdjustmentStock, error)
GetOne(ctx *fiber.Ctx, id uint) (*entity.AdjustmentStock, error)
AdjustmentHistory(ctx *fiber.Ctx, query *validation.Query) ([]*entity.AdjustmentStock, int64, error)
Adjustment(ctx *fiber.Ctx, req *validation.Create) (*entity.StockLog, error)
GetOne(ctx *fiber.Ctx, id uint) (*entity.StockLog, error)
AdjustmentHistory(ctx *fiber.Ctx, query *validation.Query) ([]*entity.StockLog, int64, error)
}
type adjustmentService struct {
@@ -70,11 +70,13 @@ func (s *adjustmentService) withRelations(db *gorm.DB) *gorm.DB {
Preload("ProductWarehouse").
Preload("ProductWarehouse.Product").
Preload("ProductWarehouse.Warehouse").
Preload("StockLog.CreatedUser")
Preload("CreatedUser")
}
func (s *adjustmentService) GetOne(c *fiber.Ctx, id uint) (*entity.AdjustmentStock, error) {
adjustmentStock, err := s.AdjustmentStockRepository.GetByID(c.Context(), id, s.withRelations)
func (s *adjustmentService) GetOne(c *fiber.Ctx, id uint) (*entity.StockLog, error) {
stockLog, err := s.StockLogsRepository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
return s.withRelations(db).Preload("ProductWarehouse.Product.ProductCategory")
})
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Adjustment not found")
@@ -83,10 +85,14 @@ func (s *adjustmentService) GetOne(c *fiber.Ctx, id uint) (*entity.AdjustmentSto
return nil, err
}
return adjustmentStock, nil
if stockLog.LoggableType != string(utils.StockLogTypeAdjustment) {
return nil, fiber.NewError(fiber.StatusNotFound, "Adjustment not found")
}
return stockLog, nil
}
func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*entity.AdjustmentStock, error) {
func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*entity.StockLog, error) {
if err := s.Validate.Struct(req); err != nil {
return nil, err
}
@@ -105,13 +111,12 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
if req.Quantity <= 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "Quantity must be greater than zero")
}
transactionType := strings.ToUpper(req.TransactionType)
if transactionType != string(utils.StockLogTransactionTypeIncrease) && transactionType != string(utils.StockLogTransactionTypeDecrease) {
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid transaction type")
}
var createdAdjustmentStockId uint
var createdLogId uint
var projectFlockKandangID *uint
pfkID, err := s.getActiveProjectFlockKandangID(ctx, uint(req.WarehouseID))
@@ -146,8 +151,7 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
return nil, err
}
err = s.StockLogsRepository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
productWarehouse, err := s.ProductWarehouseRepo.FindByProductWarehouseAndPfk(ctx, uint(req.ProductID), uint(req.WarehouseID), projectFlockKandangID)
productWarehouse, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID(ctx, uint(req.ProductID), uint(req.WarehouseID))
if err != nil {
s.Log.Errorf("Failed to get product warehouse: %+v", err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get product warehouse")
@@ -162,50 +166,31 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
CreatedBy: actorID,
}
stockLogs, err := s.StockLogsRepository.GetByProductWarehouse(ctx, productWarehouse.Id, 1)
if err != nil {
s.Log.Errorf("Failed to get stock logs: %+v", err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
newLog.Stock = latestStockLog.Stock
} else {
newLog.Stock = 0
}
if transactionType == string(utils.StockLogTransactionTypeIncrease) {
afterQuantity += req.Quantity
newLog.Increase = req.Quantity
newLog.Stock += newLog.Increase
newLog.Increase = afterQuantity
} else {
if productWarehouse.Quantity < req.Quantity {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak mencukupi untuk pengurangan. Stok saat ini: %.2f, Jumlah yang akan dikurangi: %.2f", productWarehouse.Quantity, req.Quantity))
return fiber.NewError(fiber.StatusBadRequest, "Insufficient stock for adjustment")
}
afterQuantity -= req.Quantity
newLog.Decrease = req.Quantity
newLog.Stock -= newLog.Decrease
newLog.Decrease = afterQuantity
}
if err := s.StockLogsRepository.WithTx(tx).CreateOne(ctx, newLog, nil); err != nil {
s.Log.Errorf("Failed to create stock log: %+v", err)
return err
}
adjustmentStock := &entity.AdjustmentStock{
StockLogId: newLog.Id,
ProductWarehouseId: productWarehouse.Id,
}
if err := s.AdjustmentStockRepository.WithTx(tx).CreateOne(ctx, adjustmentStock, nil); err != nil {
s.Log.Errorf("Failed to create adjustment stock: %+v", err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to create adjustment stock record")
}
newLog.LoggableType = string(utils.StockLogTypeAdjustment)
newLog.LoggableId = adjustmentStock.Id
if err := s.StockLogsRepository.WithTx(tx).UpdateOne(ctx, newLog.Id, newLog, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to link stock log")
}
if transactionType == string(utils.StockLogTransactionTypeIncrease) {
note := fmt.Sprintf("Stock Adjustment IN #%d", newLog.Id)
@@ -227,7 +212,7 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
UsableID: adjustmentStock.Id,
ProductWarehouseID: uint(productWarehouse.Id),
Quantity: req.Quantity,
AllowPending: false,
AllowPending: false, // Don't allow pending for adjustment
Tx: tx,
})
if err != nil {
@@ -235,26 +220,24 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
}
}
// Update ProductWarehouse quantity (for backward compatibility/reporting)
productWarehouse.Quantity = afterQuantity
if err := s.ProductWarehouseRepo.WithTx(tx).UpdateOne(ctx, productWarehouse.Id, productWarehouse, nil); err != nil {
s.Log.Errorf("Failed to update product warehouse quantity: %+v", err)
return err
}
createdAdjustmentStockId = adjustmentStock.Id
createdLogId = newLog.Id
return nil
})
if err != nil {
s.Log.Errorf("Transaction failed in CreateOne: %+v", err)
var fiberErr *fiber.Error
if errors.As(err, &fiberErr) {
return nil, err
}
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to process adjustment transaction")
}
return s.GetOne(c, createdAdjustmentStockId)
return s.GetOne(c, createdLogId)
}
func (s *adjustmentService) getActiveProjectFlockKandangID(ctx context.Context, warehouseID uint) (uint, error) {
@@ -283,15 +266,13 @@ func (s *adjustmentService) getActiveProjectFlockKandangID(ctx context.Context,
return uint(projectFlockKandang.Id), nil
}
func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Query) ([]*entity.AdjustmentStock, int64, error) {
func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Query) ([]*entity.StockLog, int64, error) {
if err := s.Validate.Struct(query); err != nil {
return nil, 0, err
}
offset := (query.Page - 1) * query.Limit
var isProductsExist bool
isWarehousesExist, err := s.WarehouseRepo.IdExists(c.Context(), uint(query.WarehouseID))
if err != nil {
return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate warehouse")
}
@@ -299,8 +280,7 @@ func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Qu
return nil, 0, fiber.NewError(fiber.StatusNotFound, "Warehouse not found")
}
isProductsExist, err = s.ProductRepo.IdExists(c.Context(), uint(query.ProductID))
isProductsExist, err := s.ProductRepo.IdExists(c.Context(), uint(query.ProductID))
if err != nil {
s.Log.Errorf("Failed to check product existence: %+v", err)
return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product")
@@ -309,45 +289,28 @@ func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Qu
return nil, 0, fiber.NewError(fiber.StatusNotFound, "Product not found")
}
var adjustmentStocks []entity.AdjustmentStock
var total int64
stockLogs, total, err := s.StockLogsRepository.GetAll(c.Context(), offset, query.Limit, func(db *gorm.DB) *gorm.DB {
q := s.AdjustmentStockRepository.DB().WithContext(c.Context()).Model(&entity.AdjustmentStock{}).
Preload("ProductWarehouse").
Preload("ProductWarehouse.Product").
Preload("ProductWarehouse.Warehouse").
Preload("StockLog.CreatedUser")
db = s.withRelations(db)
if query.ProductID > 0 {
q = q.Joins("JOIN product_warehouses ON product_warehouses.id = adjustment_stocks.product_warehouse_id").
Where("product_warehouses.product_id = ?", query.ProductID)
}
db = db.Where("loggable_type = ?", string(utils.StockLogTypeAdjustment))
if query.WarehouseID > 0 {
q = q.Joins("JOIN product_warehouses ON product_warehouses.id = adjustment_stocks.product_warehouse_id").
Where("product_warehouses.warehouse_id = ?", query.WarehouseID)
}
if query.TransactionType != "" {
db = db.Where("transaction_type = ?", strings.ToUpper(query.TransactionType))
}
db = s.StockLogsRepository.ApplyProductWarehouseFilters(db, uint(query.ProductID), uint(query.WarehouseID))
if query.TransactionType != "" {
q = q.Joins("JOIN stock_logs ON stock_logs.loggable_type = ? AND stock_logs.loggable_id = adjustment_stocks.id", "ADJUSTMENT").
Where("stock_logs.transaction_type = ?", strings.ToUpper(query.TransactionType))
}
if err = q.Count(&total).Error; err != nil {
s.Log.Errorf("Failed to get adjustments: %+v", err)
return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to get adjustment history")
}
err = q.Offset(offset).Limit(query.Limit).Order("created_at DESC").Find(&adjustmentStocks).Error
return db.Order("created_at DESC")
})
if err != nil {
s.Log.Errorf("Failed to get adjustments: %+v", err)
return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to get adjustment history")
}
result := make([]*entity.AdjustmentStock, len(adjustmentStocks))
for i := range adjustmentStocks {
result[i] = &adjustmentStocks[i]
result := make([]*entity.StockLog, len(stockLogs))
for i, v := range stockLogs {
result[i] = &v
}
return result, total, nil
@@ -5,7 +5,6 @@ import (
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
productDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/dto"
warehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/dto"
)
// === DTO Structs ===
@@ -17,29 +16,60 @@ type ProductWarehouseRelationDTO struct {
Quantity float64 `json:"quantity"`
}
type ProductWarehousNestedDTO struct {
Id uint `json:"id"`
Product *productDTO.ProductRelationDTO `json:"product,omitempty"`
Warehouse *WarehouseRelationDTO `json:"warehouse,omitempty"`
}
type ProductWarehouseListDTO struct {
ProductWarehouseRelationDTO
Product *productDTO.ProductRelationDTO `json:"product,omitempty"`
Warehouse *warehouseDTO.WarehouseRelationDTO `json:"warehouse,omitempty"`
ProjectFlockKandang *ProjectFlockKandangRelationDTO `json:"project_flock_kandang,omitempty"`
CreatedUser *UserRelationDTO `json:"created_user,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Product *productDTO.ProductRelationDTO `json:"product,omitempty"`
Warehouse *WarehouseRelationDTO `json:"warehouse,omitempty"`
ProjectFlockKandang *ProjectFlockKandangRelationDTO `json:"project_flock_kandang,omitempty"`
CreatedUser *UserRelationDTO `json:"created_user,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type UserRelationDTO struct {
Id uint `json:"id"`
Username string `json:"username"`
}
type ProductWarehouseDetailDTO struct {
ProductWarehouseListDTO
}
type ProductWarehousNestedDTO struct {
Id uint `json:"id"`
Product *productDTO.ProductRelationDTO `json:"product,omitempty"`
Warehouse *warehouseDTO.WarehouseRelationDTO `json:"warehouse,omitempty"`
// Nested DTOs for relations
type ProductRelationDTO struct {
Id uint `json:"id"`
Name string `json:"name"`
Sku string `json:"sku"`
Flags []string `json:"flags"`
}
type UserRelationDTO struct {
Id uint `json:"id"`
Username string `json:"username"`
type WarehouseRelationDTO struct {
Id uint `json:"id"`
Name string `json:"name"`
Kandang *KandangRelationDTO `json:"kandang,omitempty"`
Location *LocationRelationDTO `json:"location,omitempty"`
Area *AreaRelationDTO `json:"area,omitempty"`
}
type KandangRelationDTO struct {
Id uint `json:"id"`
Name string `json:"name"`
}
type LocationRelationDTO struct {
Id uint `json:"id"`
Name string `json:"name"`
}
type AreaRelationDTO struct {
Id uint `json:"id"`
Name string `json:"name"`
}
type ProjectFlockKandangRelationDTO struct {
@@ -66,28 +96,65 @@ func ToProductWarehouseRelationDTO(e entity.ProductWarehouse) ProductWarehouseRe
}
}
func ToProductWarehouseNestedDTO(e entity.ProductWarehouse) ProductWarehousNestedDTO {
product := productDTO.ToProductRelationDTO(e.Product)
return ProductWarehousNestedDTO{
Id: e.Id,
Product: &product,
Warehouse: &WarehouseRelationDTO{
Id: e.Warehouse.Id,
Name: e.Warehouse.Name,
},
}
}
func ToProductWarehouseListDTO(e entity.ProductWarehouse) ProductWarehouseListDTO {
dto := ProductWarehouseListDTO{
ProductWarehouseRelationDTO: ToProductWarehouseRelationDTO(e),
// CreatedAt: e.CreatedAt,
// UpdatedAt: e.UpdatedAt,
}
// Map Product relation jika ada
if e.Product.Id != 0 {
product := productDTO.ToProductRelationDTO(e.Product)
// Create a copy with flock name appended if exists
// Tambahkan flock name ke product name jika ada project flock
if e.ProjectFlockKandang != nil && e.ProjectFlockKandang.ProjectFlock.Id != 0 {
productCopy := product
productCopy.Name = product.Name + " (" + e.ProjectFlockKandang.ProjectFlock.FlockName + ")"
dto.Product = &productCopy
} else {
dto.Product = &product
product.Name = product.Name + " (" + e.ProjectFlockKandang.ProjectFlock.FlockName + ")"
}
dto.Product = &product
}
// Map Warehouse relation jika ada
if e.Warehouse.Id != 0 {
warehouse := warehouseDTO.ToWarehouseRelationDTO(e.Warehouse)
warehouse := WarehouseRelationDTO{
Id: e.Warehouse.Id,
Name: e.Warehouse.Name,
}
// Map Kandang jika ada
if e.Warehouse.Kandang != nil && e.Warehouse.Kandang.Id != 0 {
warehouse.Kandang = &KandangRelationDTO{
Id: e.Warehouse.Kandang.Id,
Name: e.Warehouse.Kandang.Name,
}
}
// Map Location jika ada
if e.Warehouse.Location != nil && e.Warehouse.Location.Id != 0 {
warehouse.Location = &LocationRelationDTO{
Id: e.Warehouse.Location.Id,
Name: e.Warehouse.Location.Name,
}
}
if e.Warehouse.Area.Id != 0 {
warehouse.Area = &AreaRelationDTO{
Id: e.Warehouse.Area.Id,
Name: e.Warehouse.Area.Name,
}
}
dto.Warehouse = &warehouse
}
@@ -101,6 +168,7 @@ func ToProductWarehouseListDTO(e entity.ProductWarehouse) ProductWarehouseListDT
Period: e.ProjectFlockKandang.Period,
}
// Map ProjectFlock jika ada
if e.ProjectFlockKandang.ProjectFlock.Id != 0 {
pfkDTO.ProjectFlock = &ProjectFlockRelationDTO{
Id: e.ProjectFlockKandang.ProjectFlock.Id,
@@ -111,6 +179,15 @@ func ToProductWarehouseListDTO(e entity.ProductWarehouse) ProductWarehouseListDT
dto.ProjectFlockKandang = pfkDTO
}
// Map CreatedUser relation jika ada
// if e.CreatedUser.Id != 0 {
// user := UserRelationDTO{
// Id: e.CreatedUser.Id,
// Username: e.CreatedUser.Name,
// }
// dto.CreatedUser = &user
// }
return dto
}
@@ -128,13 +205,23 @@ func ToProductWarehouseDetailDTO(e entity.ProductWarehouse) ProductWarehouseDeta
}
}
func ToProductWarehouseNestedDTO(e entity.ProductWarehouse) ProductWarehousNestedDTO {
product := productDTO.ToProductRelationDTO(e.Product)
warehouse := warehouseDTO.ToWarehouseRelationDTO(e.Warehouse)
return ProductWarehousNestedDTO{
Id: e.Id,
Product: &product,
Warehouse: &warehouse,
func ToKandangRelationDTO(e entity.Kandang) KandangRelationDTO {
return KandangRelationDTO{
Id: e.Id,
Name: e.Name,
}
}
func ToLocationRelationDTO(e entity.Location) LocationRelationDTO {
return LocationRelationDTO{
Id: e.Id,
Name: e.Name,
}
}
func ToAreaRelationDTO(e entity.Area) AreaRelationDTO {
return AreaRelationDTO{
Id: e.Id,
Name: e.Name,
}
}
@@ -3,14 +3,15 @@ package service
import (
"errors"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"github.com/sirupsen/logrus"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
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"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
)
@@ -39,7 +40,6 @@ func (s productWarehouseService) withRelations(db *gorm.DB) *gorm.DB {
return db.
Preload("Product.Flags").
Preload("Product").
Preload("Product.Uom").
Preload("Warehouse").
Preload("Warehouse.Location").
Preload("Warehouse.Area").
@@ -40,6 +40,6 @@ func (r *StockTransferRepositoryImpl) GenerateMovementNumber(ctx context.Context
if err != nil {
return "", err
}
movementNumber := fmt.Sprintf("PND-LTI-%05d", seq)
movementNumber := fmt.Sprintf("ST-%05d", seq)
return movementNumber, nil
}
@@ -124,8 +124,7 @@ func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, e
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Transfer dengan ID %d tidak ditemukan", id))
}
s.Log.Errorf("Failed to fetch transfer by ID %d: %+v", id, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data transfer")
return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal mengambil data transfer dengan ID %d", id))
}
return transferPtr, nil
@@ -143,8 +142,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Produk dengan ID %d tidak ditemukan di gudang asal (ID: %d)", product.ProductID, req.SourceWarehouseID))
}
s.Log.Errorf("Failed to fetch product warehouse for product_id=%d, warehouse_id=%d: %+v", product.ProductID, req.SourceWarehouseID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengecek stok produk")
return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal mengecek stok produk %d di gudang asal", product.ProductID))
}
if sourcePW.Quantity < product.ProductQty {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok produk %d di gudang asal tidak mencukupi. Tersedia: %.2f, Diminta: %.2f", product.ProductID, sourcePW.Quantity, product.ProductQty))
@@ -165,15 +163,12 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
return nil, err
}
if destPfkID > 0 {
projectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), destPfkID)
if err != nil {
s.Log.Errorf("Failed to fetch project flock kandang by ID %d: %+v", destPfkID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data project flock")
}
if projectFlockKandang.ClosedAt != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Project flock untuk gudang tujuan sudah ditutup (closing) pada %s", projectFlockKandang.ClosedAt.Format("2006-01-02")))
}
projectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), destPfkID)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data project flock untuk gudang tujuan")
}
if projectFlockKandang.ClosedAt != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Project flock untuk gudang tujuan sudah ditutup (closing) pada %s", projectFlockKandang.ClosedAt.Format("2006-01-02")))
}
actorID, err := m.ActorIDFromContext(c)
@@ -201,8 +196,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Supplier dengan ID %d tidak ditemukan", delivery.SupplierID))
}
s.Log.Errorf("Failed to fetch supplier by ID %d: %+v", delivery.SupplierID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data supplier")
return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal mengambil data supplier dengan ID %d", delivery.SupplierID))
}
if supplier.Category != string(utils.SupplierCategoryBOP) {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Supplier '%s' (ID: %d) bukan kategori BOP. Kategori saat ini: %s", supplier.Name, delivery.SupplierID, supplier.Category))
@@ -211,8 +205,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
movementNumber, err := s.StockTransferRepo.GenerateMovementNumber(c.Context())
if err != nil {
s.Log.Errorf("Failed to generate movement number: %+v", err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat nomor transfer")
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat nomor movement transfer")
}
transferDate, _ := utils.ParseDateString(req.TransferDate)
@@ -235,7 +228,6 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
stockTransferDeliveryRepoTX := s.StockTransferDeliveryRepo.WithTx(tx)
stockTransferDeliveryItemRepoTX := s.StockTransferDeliveryItemRepo.WithTx(tx)
productWarehouseRepoTX := rProductWarehouse.NewProductWarehouseRepository(tx)
stocklogsRepoTx := s.StockLogsRepository.WithTx(tx)
if err := stockTransferRepoTX.CreateOne(c.Context(), entityTransfer, nil); err != nil {
return err
@@ -253,16 +245,14 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Produk %d tidak ditemukan di gudang asal (ID: %d)", product.ProductID, req.SourceWarehouseID))
}
s.Log.Errorf("Failed to fetch source product warehouse for product_id=%d, warehouse_id=%d: %+v", product.ProductID, req.SourceWarehouseID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data stok gudang asal")
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal mengambil data product warehouse untuk produk %d di gudang asal", product.ProductID))
}
destPW, err := productWarehouseRepoTX.GetProductWarehouseByProductAndWarehouseID(
c.Context(), uint(product.ProductID), uint(req.DestinationWarehouseID),
)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
s.Log.Errorf("Failed to fetch dest product warehouse for product_id=%d, warehouse_id=%d: %+v", product.ProductID, req.DestinationWarehouseID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data stok gudang tujuan")
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal mengambil data product warehouse untuk produk %d di gudang tujuan", product.ProductID))
}
if errors.Is(err, gorm.ErrRecordNotFound) {
ctx := c.Context()
@@ -271,6 +261,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
return err
}
// Set ProjectFlockKandangId hanya jika ada kandang
var pfkID *uint
if projectFlockKandangID > 0 {
pfkID = &projectFlockKandangID
@@ -283,8 +274,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
ProjectFlockKandangId: pfkID,
}
if err := productWarehouseRepoTX.CreateOne(c.Context(), destPW, nil); err != nil {
s.Log.Errorf("Failed to create product warehouse for product_id=%d, warehouse_id=%d: %+v", product.ProductID, req.DestinationWarehouseID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat data stok gudang tujuan")
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal membuat product warehouse untuk produk %d di gudang tujuan", product.ProductID))
}
}
@@ -374,9 +364,9 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
Files: documentFiles,
})
if err != nil {
s.Log.Errorf("Failed to upload document for delivery %d (delivery_id=%d, filename=%s): %+v",
deliveryIdx+1, delivery.Id, file.Filename, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengunggah dokumen")
s.Log.WithError(err).Errorf("Failed to upload document for delivery %d (delivery_id: %d, filename: %s)",
deliveryIdx+1, delivery.Id, file.Filename)
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to upload document for delivery %d: %v", deliveryIdx+1, err))
}
}
}
@@ -402,21 +392,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
"usage_qty": consumeResult.UsageQuantity,
"pending_qty": consumeResult.PendingQuantity,
}).Error; err != nil {
s.Log.Errorf("Failed to update tracking usage for detail_id=%d, product_id=%d: %+v", detail.Id, product.ProductID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memperbarui data tracking")
}
stockLogDecrease := &entity.StockLog{
ProductWarehouseId: uint(*detail.SourceProductWarehouseID),
CreatedBy: uint(actorID),
Increase: 0,
Decrease: product.ProductQty,
LoggableType: string(utils.StockLogTypeTransfer),
LoggableId: uint(detail.Id),
Notes: "",
}
if err := stocklogsRepoTx.CreateOne(c.Context(), stockLogDecrease, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok keluar")
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal mengupdate tracking usage untuk produk %d", product.ProductID))
}
note := fmt.Sprintf("Transfer #%s", entityTransfer.MovementNumber)
@@ -429,8 +405,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
Tx: tx,
})
if err != nil {
s.Log.Errorf("Failed to replenish stock for product_id=%d, pw_id=%d, qty=%.2f: %+v", product.ProductID, *detail.DestProductWarehouseID, product.ProductQty, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal menambah stok gudang tujuan")
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal menambah stok untuk produk %d di gudang tujuan. Error: %v", product.ProductID, err))
}
if err := tx.Model(&entity.StockTransferDetail{}).
@@ -438,21 +413,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
Updates(map[string]interface{}{
"total_qty": replenishResult.AddedQuantity,
}).Error; err != nil {
s.Log.Errorf("Failed to update tracking total for detail_id=%d, product_id=%d: %+v", detail.Id, product.ProductID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memperbarui data tracking")
}
stockLogIncrease := &entity.StockLog{
ProductWarehouseId: uint(*detail.DestProductWarehouseID),
CreatedBy: uint(actorID),
Increase: product.ProductQty,
Decrease: 0,
LoggableType: string(utils.StockLogTypeTransfer),
LoggableId: uint(detail.Id),
Notes: "",
}
if err := stocklogsRepoTx.CreateOne(c.Context(), stockLogIncrease, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok masuk")
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal mengupdate tracking total untuk produk %d", product.ProductID))
}
}
@@ -486,10 +447,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
})
if err != nil {
if fiberErr, ok := err.(*fiber.Error); ok {
return nil, fiberErr
}
return nil, fiber.NewError(fiber.StatusInternalServerError, "Internal server error")
return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal memproses transfer. Error: %v", err))
}
result, err := s.GetOne(c, uint(entityTransfer.Id))
@@ -499,8 +457,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
if len(expensePayloads) > 0 {
if err := s.notifyExpenseItemsDelivered(c, entityTransfer.Id, expensePayloads); err != nil {
s.Log.Errorf("Failed to sync expense for transfer_id=%d, movement_number=%s: %+v", entityTransfer.Id, entityTransfer.MovementNumber, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal sinkronisasi data expense. Silakan cek manual di module expense")
return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal sinkronisasi data expense untuk transfer %s. Silakan cek manual di module expense", entityTransfer.MovementNumber))
}
}
@@ -520,10 +477,10 @@ func (s *transferService) getActiveProjectFlockKandangID(ctx context.Context, wa
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Gudang dengan ID %d tidak ditemukan", warehouseID))
}
s.Log.Errorf("Failed to fetch warehouse by ID %d: %+v", warehouseID, err)
return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data gudang")
return 0, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal mengambil data gudang dengan ID %d", warehouseID))
}
// Jika warehouse tidak punya kandang_id, return 0 tanpa error
if warehouse.KandangId == nil || *warehouse.KandangId == 0 {
return 0, nil
}
@@ -533,8 +490,7 @@ func (s *transferService) getActiveProjectFlockKandangID(ctx context.Context, wa
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Tidak ada project flock aktif untuk kandang %d", *warehouse.KandangId))
}
s.Log.Errorf("Failed to fetch active project flock kandang for kandang_id=%d: %+v", *warehouse.KandangId, err)
return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data project flock")
return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data project flock kandang yang aktif")
}
return uint(projectFlockKandang.Id), nil
@@ -9,7 +9,6 @@ import (
approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto"
productwarehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/dto"
customerDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/dto"
warehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/dto"
userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto"
)
@@ -69,10 +68,10 @@ type DeliveryItemDTO struct {
}
type DeliveryGroupDTO struct {
DoNumber string `json:"do_number"`
DeliveryDate *time.Time `json:"delivery_date"`
Warehouse *warehouseDTO.WarehouseRelationDTO `json:"warehouse,omitempty"`
Deliveries []DeliveryItemDTO `json:"deliveries"`
DoNumber string `json:"do_number"`
DeliveryDate *time.Time `json:"delivery_date"`
Warehouse *productwarehouseDTO.WarehouseRelationDTO `json:"warehouse,omitempty"`
Deliveries []DeliveryItemDTO `json:"deliveries"`
}
type DeliveryMarketingProductDTO struct {
@@ -287,7 +286,7 @@ func groupDeliveryProducts(products []MarketingDeliveryProductDTO, soNumber stri
if !exists {
group = &DeliveryGroupDTO{
DeliveryDate: product.DeliveryDate,
Warehouse: &warehouseDTO.WarehouseRelationDTO{
Warehouse: &productwarehouseDTO.WarehouseRelationDTO{
Id: warehouseId,
Name: warehouseName,
},
+1 -3
View File
@@ -16,7 +16,6 @@ import (
rCustomer "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/repositories"
rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories"
rProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
rShared "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories"
rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
@@ -33,7 +32,6 @@ func (MarketingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
userRepo := rUser.NewUserRepository(db)
customerRepo := rCustomer.NewCustomerRepository(db)
productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db)
stockLogRepo := rShared.NewStockLogRepository(db)
stockAllocationRepo := commonRepo.NewStockAllocationRepository(db)
fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log)
@@ -65,7 +63,7 @@ func (MarketingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db)
salesOrdersService := service.NewSalesOrdersService(marketingRepo, customerRepo, productWarehouseRepo, userRepo, approvalSvc, warehouseRepo, projectFlockKandangRepo, validate)
deliveryOrdersService := service.NewDeliveryOrdersService(marketingRepo, marketingProductRepo, marketingDeliveryProductRepo, stockLogRepo, approvalSvc, fifoService, validate)
deliveryOrdersService := service.NewDeliveryOrdersService(marketingRepo, marketingProductRepo, marketingDeliveryProductRepo, approvalSvc, fifoService, validate)
userService := sUser.NewUserService(userRepo, validate)
RegisterRoutes(router, userService, salesOrdersService, deliveryOrdersService)
@@ -14,7 +14,7 @@ import (
type MarketingDeliveryProductRepository interface {
repository.BaseRepository[entity.MarketingDeliveryProduct]
GetDeliveryProductsByProjectFlockID(ctx context.Context, projectFlockID uint, callback func(*gorm.DB) *gorm.DB) ([]entity.MarketingDeliveryProduct, error)
GetClosingPenjualan(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint, category string) ([]entity.MarketingDeliveryProduct, error)
GetClosingPenjualan(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]entity.MarketingDeliveryProduct, error)
GetByMarketingId(ctx context.Context, marketingId uint) ([]entity.MarketingDeliveryProduct, error)
GetByMarketingProductID(ctx context.Context, marketingProductID uint) (*entity.MarketingDeliveryProduct, error)
GetAllWithFilters(ctx context.Context, offset, limit int, filters *validation.MarketingQuery) ([]entity.MarketingDeliveryProduct, int64, error)
@@ -54,14 +54,12 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetDeliveryProductsByProjectFlo
return deliveryProducts, nil
}
func (r *MarketingDeliveryProductRepositoryImpl) GetClosingPenjualan(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint, category string) ([]entity.MarketingDeliveryProduct, error) {
func (r *MarketingDeliveryProductRepositoryImpl) GetClosingPenjualan(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]entity.MarketingDeliveryProduct, error) {
var deliveryProducts []entity.MarketingDeliveryProduct
db := r.DB().WithContext(ctx).
Joins("JOIN marketing_products ON marketing_products.id = marketing_delivery_products.marketing_product_id").
Joins("JOIN product_warehouses ON product_warehouses.id = marketing_products.product_warehouse_id").
Joins("JOIN products ON products.id = product_warehouses.product_id").
Joins("JOIN flags ON flags.flagable_id = products.id AND flags.flagable_type = 'products'").
Joins("JOIN project_flock_kandangs ON project_flock_kandangs.id = product_warehouses.project_flock_kandang_id").
Where("project_flock_kandangs.project_flock_id = ?", projectFlockID).
Where("marketing_delivery_products.delivery_date IS NOT NULL").
@@ -71,25 +69,6 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetClosingPenjualan(ctx context
db = db.Where("product_warehouses.project_flock_kandang_id = ?", *projectFlockKandangID)
}
if category == string(utils.ProjectFlockCategoryLaying) {
db = db.Where("flags.name IN (?)", []string{
string(utils.FlagTelur),
string(utils.FlagTelurUtuh),
string(utils.FlagTelurPecah),
string(utils.FlagTelurPutih),
string(utils.FlagTelurRetak),
})
} else {
db = db.Where("flags.name IN (?)", []string{
string(utils.FlagDOC),
string(utils.FlagPullet),
string(utils.FlagLayer),
string(utils.FlagAyamAfkir),
string(utils.FlagAyamCulling),
string(utils.FlagAyamMati),
})
}
db = db.
Preload("MarketingProduct").
Preload("MarketingProduct.ProductWarehouse").
@@ -246,12 +225,8 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetAllWithFilters(ctx context.C
}
}
if filters.StartDate != "" || filters.EndDate != "" {
filterBy := filters.FilterBy
if filterBy == "" {
filterBy = "so_date"
}
if filterBy == "so_date" {
if filters.FilterBy != "" && (filters.StartDate != "" || filters.EndDate != "") {
if filters.FilterBy == "so_date" {
if filters.StartDate != "" {
if startDate, err := utils.ParseDateString(filters.StartDate); err == nil {
db = db.Where("marketings.so_date >= ?", startDate)
@@ -263,7 +238,7 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetAllWithFilters(ctx context.C
db = db.Where("marketings.so_date < ?", nextDate)
}
}
} else if filterBy == "realization_date" {
} else if filters.FilterBy == "realization_date" {
if filters.StartDate != "" {
if startDate, err := utils.ParseDateString(filters.StartDate); err == nil {
db = db.Where("marketing_delivery_products.delivery_date >= ?", startDate)
@@ -26,10 +26,7 @@ func NewMarketingProductRepository(db *gorm.DB) MarketingProductRepository {
func (r *MarketingProductRepositoryImpl) GetByMarketingID(ctx context.Context, marketingID uint) ([]entity.MarketingProduct, error) {
var products []entity.MarketingProduct
if err := r.DB().WithContext(ctx).
Preload("ProductWarehouse.Product.Flags").
Where("marketing_id = ?", marketingID).
Find(&products).Error; err != nil {
if err := r.DB().WithContext(ctx).Where("marketing_id = ?", marketingID).Find(&products).Error; err != nil {
return nil, err
}
if len(products) == 0 {
@@ -14,7 +14,6 @@ import (
"gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/dto"
marketingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/validations"
rShared "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"
@@ -35,7 +34,6 @@ type deliveryOrdersService struct {
MarketingRepo marketingRepo.MarketingRepository
MarketingProductRepo marketingRepo.MarketingProductRepository
MarketingDeliveryProductRepo marketingRepo.MarketingDeliveryProductRepository
StockLogRepo rShared.StockLogRepository
ApprovalSvc commonSvc.ApprovalService
FifoSvc commonSvc.FifoService
}
@@ -44,7 +42,6 @@ func NewDeliveryOrdersService(
marketingRepo marketingRepo.MarketingRepository,
marketingProductRepo marketingRepo.MarketingProductRepository,
marketingDeliveryProductRepo marketingRepo.MarketingDeliveryProductRepository,
stockLogRepo rShared.StockLogRepository,
approvalSvc commonSvc.ApprovalService,
fifoSvc commonSvc.FifoService,
validate *validator.Validate,
@@ -54,7 +51,6 @@ func NewDeliveryOrdersService(
MarketingRepo: marketingRepo,
MarketingProductRepo: marketingProductRepo,
MarketingDeliveryProductRepo: marketingDeliveryProductRepo,
StockLogRepo: stockLogRepo,
ApprovalSvc: approvalSvc,
FifoSvc: fifoSvc,
}
@@ -251,25 +247,9 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Delivery
itemDeliveryDate = &parsedDate
}
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 otomatis
totalWeight := requestedProduct.Qty * requestedProduct.AvgWeight
var totalPrice float64
if isPakanOrOVK {
totalPrice = requestedProduct.Qty * requestedProduct.UnitPrice
} else {
totalPrice = totalWeight * requestedProduct.UnitPrice
}
totalPrice := requestedProduct.UnitPrice * totalWeight
deliveryProduct.ProductWarehouseId = foundMarketingProduct.ProductWarehouseId
deliveryProduct.UnitPrice = requestedProduct.UnitPrice
@@ -281,7 +261,7 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Delivery
if requestedProduct.Qty > 0 {
if err := s.consumeDeliveryStock(c.Context(), dbTransaction, deliveryProduct, foundMarketingProduct, requestedProduct.Qty, actorID); err != nil {
if err := s.consumeDeliveryStock(c.Context(), dbTransaction, deliveryProduct, foundMarketingProduct, requestedProduct.Qty); err != nil {
return err
}
}
@@ -329,12 +309,7 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO
return nil, err
}
actorID, err := m.ActorIDFromContext(c)
if err != nil {
return nil, err
}
err = s.MarketingRepo.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error {
err := s.MarketingRepo.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error {
marketingProductRepositoryTx := marketingRepo.NewMarketingProductRepository(dbTransaction)
marketingDeliveryProductRepositoryTx := marketingRepo.NewMarketingDeliveryProductRepository(dbTransaction)
@@ -386,26 +361,9 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO
oldRequestedQty := deliveryProduct.UsageQty + deliveryProduct.PendingQty
// 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 otomatis
totalWeight := requestedProduct.Qty * requestedProduct.AvgWeight
var totalPrice float64
if isPakanOrOVK {
totalPrice = requestedProduct.Qty * requestedProduct.UnitPrice
} else {
totalPrice = totalWeight * requestedProduct.UnitPrice
}
totalPrice := requestedProduct.UnitPrice * totalWeight
deliveryProduct.ProductWarehouseId = foundMarketingProduct.ProductWarehouseId
deliveryProduct.UnitPrice = requestedProduct.UnitPrice
@@ -418,13 +376,13 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO
if requestedProduct.Qty != oldRequestedQty {
if oldRequestedQty > 0 {
if err := s.releaseDeliveryStock(c.Context(), dbTransaction, deliveryProduct, foundMarketingProduct, actorID); err != nil {
if err := s.releaseDeliveryStock(c.Context(), dbTransaction, deliveryProduct, foundMarketingProduct); err != nil {
return err
}
}
if requestedProduct.Qty > 0 {
if err := s.consumeDeliveryStock(c.Context(), dbTransaction, deliveryProduct, foundMarketingProduct, requestedProduct.Qty, actorID); err != nil {
if err := s.consumeDeliveryStock(c.Context(), dbTransaction, deliveryProduct, foundMarketingProduct, requestedProduct.Qty); err != nil {
return err
}
}
@@ -449,7 +407,7 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO
return s.getMarketingWithDeliveries(c, id)
}
func (s deliveryOrdersService) consumeDeliveryStock(ctx context.Context, tx *gorm.DB, deliveryProduct *entity.MarketingDeliveryProduct, marketingProduct *entity.MarketingProduct, requestedQty float64, actorID uint) error {
func (s deliveryOrdersService) consumeDeliveryStock(ctx context.Context, tx *gorm.DB, deliveryProduct *entity.MarketingDeliveryProduct, marketingProduct *entity.MarketingProduct, requestedQty float64) error {
if marketingProduct == nil || marketingProduct.ProductWarehouseId == 0 {
return fiber.NewError(fiber.StatusInternalServerError, "Product warehouse not found")
}
@@ -469,31 +427,6 @@ func (s deliveryOrdersService) consumeDeliveryStock(ctx context.Context, tx *gor
deliveryProductRepo := marketingRepo.NewMarketingDeliveryProductRepository(tx)
if err == nil && result.UsageQuantity > 0 {
if actorID > 0 {
decreaseLog := &entity.StockLog{
Decrease: result.UsageQuantity,
LoggableType: string(utils.StockLogTypeMarketing),
LoggableId: deliveryProduct.Id,
ProductWarehouseId: marketingProduct.ProductWarehouseId,
CreatedBy: actorID,
Notes: "",
}
stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, marketingProduct.ProductWarehouseId, 1)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
decreaseLog.Stock = latestStockLog.Stock
decreaseLog.Stock -= decreaseLog.Decrease
} else {
decreaseLog.Stock = 0
}
s.StockLogRepo.WithTx(tx).CreateOne(ctx, decreaseLog, nil)
}
}
if err != nil {
pwRepo := productWarehouseRepo.NewProductWarehouseRepository(tx)
pw, err2 := pwRepo.GetByID(ctx, marketingProduct.ProductWarehouseId, nil)
@@ -502,42 +435,12 @@ func (s deliveryOrdersService) consumeDeliveryStock(ctx context.Context, tx *gor
}
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 {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update delivery product")
}
if actorID > 0 {
decreaseLog := &entity.StockLog{
Decrease: requestedQty,
LoggableType: string(utils.StockLogTypeMarketing),
LoggableId: deliveryProduct.Id,
ProductWarehouseId: marketingProduct.ProductWarehouseId,
CreatedBy: actorID,
Notes: "",
}
stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, marketingProduct.ProductWarehouseId, 1)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
decreaseLog.Stock = latestStockLog.Stock
decreaseLog.Stock -= decreaseLog.Decrease
} else {
decreaseLog.Stock = 0
}
s.StockLogRepo.WithTx(tx).CreateOne(ctx, decreaseLog, nil)
}
return nil
}
@@ -548,7 +451,7 @@ func (s deliveryOrdersService) consumeDeliveryStock(ctx context.Context, tx *gor
return nil
}
func (s deliveryOrdersService) releaseDeliveryStock(ctx context.Context, tx *gorm.DB, deliveryProduct *entity.MarketingDeliveryProduct, marketingProduct *entity.MarketingProduct, actorID uint) error {
func (s deliveryOrdersService) releaseDeliveryStock(ctx context.Context, tx *gorm.DB, deliveryProduct *entity.MarketingDeliveryProduct, marketingProduct *entity.MarketingProduct) error {
if deliveryProduct == nil || deliveryProduct.Id == 0 {
return nil
}
@@ -575,29 +478,6 @@ func (s deliveryOrdersService) releaseDeliveryStock(ctx context.Context, tx *gor
return err
}
if actorID > 0 && currentUsage > 0 {
increaseLog := &entity.StockLog{
Increase: currentUsage,
LoggableType: string(utils.StockLogTypeMarketing),
LoggableId: deliveryProduct.Id,
ProductWarehouseId: marketingProduct.ProductWarehouseId,
CreatedBy: actorID,
Notes: "",
}
stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, marketingProduct.ProductWarehouseId, 1)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
increaseLog.Stock = latestStockLog.Stock
increaseLog.Stock += increaseLog.Increase
} else {
increaseLog.Stock = 0
}
s.StockLogRepo.WithTx(tx).CreateOne(ctx, increaseLog, nil)
}
if err := deliveryProductRepo.ResetFifoFields(ctx, deliveryProduct.Id); err != nil {
return err
}
@@ -292,32 +292,9 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u
for _, rp := range req.MarketingProducts {
if old, ok := oldByPW[rp.ProductWarehouseId]; ok {
// 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 otomatis
totalWeight := rp.Qty * rp.AvgWeight
var totalPrice float64
if isPakanOrOVK {
totalPrice = rp.Qty * rp.UnitPrice
} else {
totalPrice = totalWeight * rp.UnitPrice
}
totalPrice := rp.UnitPrice * totalWeight
updateBody := map[string]any{
"product_warehouse_id": rp.ProductWarehouseId,
@@ -615,34 +592,9 @@ 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 {
// 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
}
}
}
// Hitung total_weight dan total_price otomatis
totalWeight := rp.Qty * rp.AvgWeight
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
}
totalPrice := rp.UnitPrice * totalWeight
marketingProduct := &entity.MarketingProduct{
MarketingId: marketingId,
@@ -76,9 +76,6 @@ func (s *configChecklistService) CreateOne(c *fiber.Ctx, req *validation.Create)
if err := s.Validate.Struct(req); err != nil {
return nil, err
}
if req.PercentageThresholdBad > req.PercentageThresholdEnough {
return nil, fiber.NewError(fiber.StatusBadRequest, "percentage_threshold_bad cannot be greater than percentage_threshold_enough")
}
date, err := time.Parse("2006-01-02", req.Date)
if err != nil {
@@ -103,11 +100,6 @@ func (s configChecklistService) UpdateOne(c *fiber.Ctx, req *validation.Update,
if err := s.Validate.Struct(req); err != nil {
return nil, err
}
if req.PercentageThresholdBad != nil && req.PercentageThresholdEnough != nil {
if *req.PercentageThresholdBad > *req.PercentageThresholdEnough {
return nil, fiber.NewError(fiber.StatusBadRequest, "percentage_threshold_bad cannot be greater than percentage_threshold_enough")
}
}
updateBody := make(map[string]any)
@@ -156,22 +156,6 @@ func (s customerService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint
updateBody["type"] = typ
}
if req.Address != nil {
updateBody["address"] = *req.Address
}
if req.Phone != nil {
updateBody["phone"] = *req.Phone
}
if req.Email != nil {
updateBody["email"] = *req.Email
}
if req.AccountNumber != nil {
updateBody["account_number"] = *req.AccountNumber
}
if len(updateBody) == 0 {
return s.GetOne(c, id)
}
@@ -110,17 +110,6 @@ func (s *phaseActivityService) CreateOne(c *fiber.Ctx, req *validation.Create) (
return nil, fiber.NewError(fiber.StatusBadRequest, "time_type cannot be empty")
}
existing, err := s.Repository.First(c.Context(), func(db *gorm.DB) *gorm.DB {
return db.Where("phase_id = ? AND name = ? AND time_type = ?", phase.Id, name, timeType)
})
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
s.Log.Errorf("Failed to check phaseActivity uniqueness: %+v", err)
return nil, err
}
if existing != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "phase activity with same name and time_type already exists")
}
createBody := &entity.PhaseActivity{
PhaseId: phase.Id,
Name: name,
@@ -15,7 +15,7 @@ type Update struct {
type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1,gt=0"`
Limit int `query:"limit" validate:"omitempty,number,min=1,gt=0"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"`
Search string `query:"search" validate:"omitempty,max=50"`
PhaseIDs string `query:"phase_ids" validate:"omitempty"`
}
@@ -28,7 +28,6 @@ func (u *WarehouseController) GetAll(c *fiber.Ctx) error {
Limit: c.QueryInt("limit", 10),
Search: c.Query("search", ""),
AreaId: c.QueryInt("area_id", 0),
LocationId: c.QueryInt("location_id", 0),
ActiveProjectFlockOnly: c.QueryBool("active_project_flock", false),
}
@@ -58,9 +58,6 @@ func (s warehouseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
if params.AreaId != 0 {
db = db.Where("area_id = ?", params.AreaId)
}
if params.LocationId != 0 {
db = db.Where("location_id = ?", params.LocationId)
}
if params.ActiveProjectFlockOnly {
db = db.Where(`
EXISTS (
@@ -21,6 +21,5 @@ type Query struct {
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"`
Search string `query:"search" validate:"omitempty,max=50"`
AreaId int `query:"area_id" validate:"omitempty,number,gt=0"`
LocationId int `query:"location_id" validate:"omitempty,number,gt=0"`
ActiveProjectFlockOnly bool `query:"active_project_flock"`
}
@@ -645,17 +645,6 @@ func (s *chickinService) ConsumeChickinStocks(ctx context.Context, tx *gorm.DB,
CreatedBy: actorID,
Notes: fmt.Sprintf("Chickin #%d", chickin.Id),
}
stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, chickin.ProductWarehouseId, 1)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
decreaseLog.Stock = latestStockLog.Stock
decreaseLog.Stock -= decreaseLog.Decrease
} else {
decreaseLog.Stock = 0
}
s.StockLogRepo.CreateOne(ctx, decreaseLog, nil)
}
@@ -712,17 +701,6 @@ func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB,
CreatedBy: actorID,
Notes: fmt.Sprintf("Chickin #%d - Stock released", chickin.Id),
}
stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, chickin.ProductWarehouseId, 1)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
increaseLog.Stock = latestStockLog.Stock
increaseLog.Stock += increaseLog.Increase
} else {
increaseLog.Stock = 0
}
s.StockLogRepo.CreateOne(ctx, increaseLog, nil)
}
@@ -287,11 +287,6 @@ func (u *ProjectflockController) LookupProjectFlockKandang(c *fiber.Ctx) error {
} else {
dtoResult.AvailableQuantity = population
}
if chickinDate, err := u.ProjectflockService.GetProjectFlockKandangChickinDate(c, result.Id); err != nil {
return err
} else if chickinDate != nil {
dtoResult.ChickInDate = chickinDate
}
if warehouse, werr := u.ProjectflockService.GetWarehouseByKandangID(c, result.KandangId); werr != nil {
return werr
} else if warehouse != nil {
@@ -1,8 +1,6 @@
package dto
import (
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
areaDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/dto"
fcrDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/fcrs/dto"
@@ -40,7 +38,6 @@ type ProjectFlockKandangDTO struct {
ProjectFlock *ProjectFlockWithPivotDTO `json:"project_flock,omitempty"`
AvailableQuantity float64 `json:"available_quantity"`
Population *float64 `json:"population,omitempty"`
ChickInDate *time.Time `json:"chick_in_date,omitempty"`
}
func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangDTO {
@@ -31,7 +31,6 @@ func (r *ProjectBudgetRepositoryImpl) GetByProjectFlockID(ctx context.Context, p
Where("project_flock_id = ?", projectFlockID).
Preload("Nonstock").
Preload("Nonstock.Uom").
Preload("Nonstock.Flags").
Find(&budgets).Error
return budgets, err
}
@@ -85,7 +85,6 @@ func (r *projectFlockKandangRepositoryImpl) GetByProjectFlockID(ctx context.Cont
var records []entity.ProjectFlockKandang
if err := r.db.WithContext(ctx).
Where("project_flock_id = ?", projectFlockID).
Preload("Kandang").
Find(&records).Error; err != nil {
return nil, err
}
@@ -6,7 +6,6 @@ import (
"fmt"
"strconv"
"strings"
"time"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
@@ -43,7 +42,6 @@ type ProjectflockService interface {
DeleteOne(ctx *fiber.Ctx, id uint) error
GetProjectFlockKandangByProjectAndKandang(ctx *fiber.Ctx, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, float64, error)
GetProjectFlockKandangPopulation(ctx *fiber.Ctx, projectFlockKandangID uint) (float64, error)
GetProjectFlockKandangChickinDate(ctx *fiber.Ctx, projectFlockKandangID uint) (*time.Time, error)
GetPeriodSummary(ctx *fiber.Ctx, locationID uint) ([]KandangPeriodSummary, error)
GetProjectPeriods(ctx *fiber.Ctx, projectIDs []uint) (map[uint]int, error)
Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.ProjectFlock, error)
@@ -461,35 +459,6 @@ func (s projectflockService) GetProjectFlockKandangPopulation(ctx *fiber.Ctx, pr
return total, nil
}
func (s projectflockService) GetProjectFlockKandangChickinDate(ctx *fiber.Ctx, projectFlockKandangID uint) (*time.Time, error) {
if s.PopulationRepo == nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Project flock population repository is not configured")
}
if projectFlockKandangID == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is required")
}
populations, err := s.PopulationRepo.GetByProjectFlockKandangID(ctx.Context(), projectFlockKandangID)
if err != nil {
s.Log.Errorf("Failed to fetch populations for project flock kandang %d: %+v", projectFlockKandangID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandang chick in date")
}
var earliest *time.Time
for _, pop := range populations {
if pop.ProjectChickin == nil || pop.ProjectChickin.ChickInDate.IsZero() {
continue
}
chickinDate := pop.ProjectChickin.ChickInDate
if earliest == nil || chickinDate.Before(*earliest) {
copy := chickinDate
earliest = &copy
}
}
return earliest, nil
}
func (s projectflockService) GetProjectFlockKandangByParams(ctx *fiber.Ctx, idStr string, projectFlockIdStr string, kandangIdStr string) (*entity.ProjectFlockKandang, float64, error) {
idStr = strings.TrimSpace(idStr)
projectFlockIdStr = strings.TrimSpace(projectFlockIdStr)
@@ -3,8 +3,6 @@ package controller
import (
"math"
"strconv"
"strings"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/dto"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services"
@@ -28,9 +26,8 @@ func (u *RecordingController) GetAll(c *fiber.Ctx) error {
projectFlockID := c.QueryInt("project_flock_kandang_id", 0)
query := &validation.Query{
Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10),
Search: c.Query("search"),
Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10),
}
if projectFlockID > 0 {
query.ProjectFlockKandangId = uint(projectFlockID)
@@ -84,16 +81,7 @@ func (u *RecordingController) GetNextDay(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is required")
}
recordTime := time.Now().UTC()
if recordDate := strings.TrimSpace(c.Query("record_date")); recordDate != "" {
parsed, err := time.Parse("2006-01-02", recordDate)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "record_date must be in YYYY-MM-DD format")
}
recordTime = parsed.UTC()
}
nextDay, err := u.RecordingService.GetNextDay(c, uint(projectFlockID), recordTime)
nextDay, err := u.RecordingService.GetNextDay(c, uint(projectFlockID))
if err != nil {
return err
}
@@ -15,13 +15,13 @@ import (
// === DTO Structs ===
type RecordingProjectFlockDTO struct {
ProjectFlockKandangId uint `json:"project_flock_kandang_id"`
FlockName string `json:"flock_name"`
ProjectFlockCategory string `json:"project_flock_category"`
Period int `json:"period"`
ProductionStandart *RecordingProductionStandardDTO `json:"production_standart,omitempty"`
Fcr *RecordingFcrDTO `json:"fcr,omitempty"`
TotalChickQty float64 `json:"total_chick_qty"`
ProjectFlockKandangId uint `json:"project_flock_kandang_id"`
FlockName string `json:"flock_name"`
ProjectFlockCategory string `json:"project_flock_category"`
Period int `json:"period"`
ProductionStandart *RecordingProductionStandardDTO `json:"production_standart,omitempty"`
Fcr *RecordingFcrDTO `json:"fcr,omitempty"`
TotalChickQty float64 `json:"total_chick_qty"`
}
type RecordingProductionStandardDTO struct {
@@ -53,13 +53,6 @@ type RecordingLocationDTO struct {
Address string `json:"address"`
}
type RecordingKandangDTO struct {
Id uint `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Capacity float64 `json:"capacity"`
}
type RecordingWarehouseDTO struct {
Id uint `json:"id"`
Name string `json:"name"`
@@ -89,14 +82,12 @@ type RecordingListDTO struct {
CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Kandang *RecordingKandangDTO `json:"kandang,omitempty"`
Location *RecordingLocationDTO `json:"location,omitempty"`
Warehouse *RecordingWarehouseDTO `json:"warehouse,omitempty"`
}
type RecordingDetailDTO struct {
RecordingListDTO
ProductCategory string `json:"product_category"`
Warehouse *RecordingWarehouseDTO `json:"warehouse,omitempty"`
ProductCategory string `json:"product_category"`
Depletions []RecordingDepletionDTO `json:"depletions"`
Stocks []RecordingStockDTO `json:"stocks"`
Eggs []RecordingEggDTO `json:"eggs"`
@@ -142,11 +133,10 @@ func ToRecordingDetailDTO(e entity.Recording) RecordingDetailDTO {
return RecordingDetailDTO{
RecordingListDTO: listDTO,
ProductCategory: recordingProductCategory(e),
Warehouse: recordingWarehouseDTO(e),
Depletions: ToRecordingDepletionDTOs(e.Depletions),
Stocks: ToRecordingStockDTOs(e.Stocks),
Eggs: ToRecordingEggDTOs(e.Eggs),
ProductCategory: recordingProductCategory(e),
Depletions: ToRecordingDepletionDTOs(e.Depletions),
Stocks: ToRecordingStockDTOs(e.Stocks),
Eggs: ToRecordingEggDTOs(e.Eggs),
}
}
@@ -212,8 +202,7 @@ func toRecordingListDTO(e entity.Recording) RecordingListDTO {
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
CreatedUser: createdUser,
Kandang: recordingKandangDTO(e),
Location: recordingKandangLocationDTO(e),
Warehouse: recordingWarehouseDTO(e),
}
}
@@ -225,20 +214,20 @@ func toRecordingRelationDTO(e entity.Recording) RecordingRelationDTO {
}
return RecordingRelationDTO{
Id: e.Id,
ProjectFlock: toRecordingProjectFlockDTO(e),
RecordDatetime: e.RecordDatetime,
Day: intValue(e.Day),
TotalDepletionQty: floatValue(e.TotalDepletionQty),
CumDepletionRate: floatValue(e.CumDepletionRate),
CumIntake: intValue(e.CumIntake),
FcrValue: floatValue(e.FcrValue),
HenDay: floatValue(e.HenDay),
HenHouse: floatValue(e.HenHouse),
FeedIntake: floatValue(e.FeedIntake),
EggMass: floatValue(e.EggMass),
EggWeight: floatValue(e.EggWeight),
Approval: latestApproval,
Id: e.Id,
ProjectFlock: toRecordingProjectFlockDTO(e),
RecordDatetime: e.RecordDatetime,
Day: intValue(e.Day),
TotalDepletionQty: floatValue(e.TotalDepletionQty),
CumDepletionRate: floatValue(e.CumDepletionRate),
CumIntake: intValue(e.CumIntake),
FcrValue: floatValue(e.FcrValue),
HenDay: floatValue(e.HenDay),
HenHouse: floatValue(e.HenHouse),
FeedIntake: floatValue(e.FeedIntake),
EggMass: floatValue(e.EggMass),
EggWeight: floatValue(e.EggWeight),
Approval: latestApproval,
}
}
@@ -332,34 +321,6 @@ func recordingWarehouseDTO(e entity.Recording) *RecordingWarehouseDTO {
return mapWarehouseDTO(&pw.Warehouse)
}
func recordingKandangDTO(e entity.Recording) *RecordingKandangDTO {
if e.ProjectFlockKandang == nil || e.ProjectFlockKandang.Kandang.Id == 0 {
return nil
}
kandang := e.ProjectFlockKandang.Kandang
return &RecordingKandangDTO{
Id: kandang.Id,
Name: kandang.Name,
Status: kandang.Status,
Capacity: kandang.Capacity,
}
}
func recordingKandangLocationDTO(e entity.Recording) *RecordingLocationDTO {
if e.ProjectFlockKandang == nil || e.ProjectFlockKandang.Kandang.Id == 0 {
return nil
}
location := e.ProjectFlockKandang.Kandang.Location
if location.Id == 0 {
return nil
}
return &RecordingLocationDTO{
Id: location.Id,
Name: location.Name,
Address: location.Address,
}
}
func primaryProductWarehouse(e entity.Recording) *entity.ProductWarehouse {
if len(e.Stocks) > 0 {
pw := e.Stocks[0].ProductWarehouse
@@ -16,7 +16,6 @@ import (
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"
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/fifo"
@@ -32,7 +31,6 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
projectFlockPopulationRepo := rProjectFlock.NewProjectFlockPopulationRepository(db)
productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db)
stockAllocationRepo := commonRepo.NewStockAllocationRepository(db)
stockLogRepo := rStockLogs.NewStockLogRepository(db)
productionStandardRepo := rProductionStandard.NewProductionStandardRepository(db)
productionStandardDetailRepo := rProductionStandard.NewProductionStandardDetailRepository(db)
standardGrowthDetailRepo := rProductionStandard.NewStandardGrowthDetailRepository(db)
@@ -76,28 +74,6 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
panic(fmt.Sprintf("failed to register recording usable workflow: %v", err))
}
}
if err := fifoService.RegisterUsable(fifo.UsableConfig{
Key: fifo.UsableKeyRecordingDepletion,
Table: "recording_depletions",
Columns: fifo.UsableColumns{
ID: "id",
ProductWarehouseID: "source_product_warehouse_id",
UsageQuantity: "qty",
PendingQuantity: "pending_qty",
CreatedAt: "id",
},
ExcludedStockables: []fifo.StockableKey{
fifo.StockableKeyTransferToLayingIn,
fifo.StockableKeyStockTransferIn,
fifo.StockableKeyAdjustmentIn,
fifo.StockableKeyPurchaseItems,
fifo.StockableKeyRecordingEgg,
},
}); err != nil {
if !strings.Contains(strings.ToLower(err.Error()), "already registered") {
panic(fmt.Sprintf("failed to register recording depletion usable workflow: %v", err))
}
}
approvalRepo := commonRepo.NewApprovalRepository(db)
approvalService := commonSvc.NewApprovalService(approvalRepo)
@@ -115,7 +91,6 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
approvalRepo,
approvalService,
fifoService,
stockLogRepo,
productionStandardService,
validate,
)
@@ -17,7 +17,6 @@ type RecordingRepository interface {
repository.BaseRepository[entity.Recording]
WithRelations(db *gorm.DB) *gorm.DB
ApplySearchFilters(db *gorm.DB, rawSearch string) *gorm.DB
GetLatestByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (*entity.Recording, error)
GenerateNextDay(tx *gorm.DB, projectFlockKandangId uint) (int, error)
@@ -25,7 +24,6 @@ type RecordingRepository interface {
DeleteStocks(tx *gorm.DB, recordingID uint) error
ListStocks(tx *gorm.DB, recordingID uint) ([]entity.RecordingStock, error)
UpdateStockUsage(tx *gorm.DB, stockID uint, usageQty, pendingQty float64) error
UpdateDepletionPending(tx *gorm.DB, depletionID uint, pendingQty float64) error
CreateDepletions(tx *gorm.DB, depletions []entity.RecordingDepletion) error
DeleteDepletions(tx *gorm.DB, recordingID uint) error
@@ -47,10 +45,8 @@ type RecordingRepository interface {
GetCumulativeEggQtyByProjectFlockKandang(tx *gorm.DB, projectFlockKandangId uint, recordTime time.Time) (float64, error)
GetFcrStandardNumber(tx *gorm.DB, fcrId uint, currentWeightKg float64) (float64, bool, error)
GetTotalWeightProducedFromUniformityByProjectFlockID(ctx context.Context, projectFlockID uint) (float64, error)
GetTotalWeightProducedFromUniformityByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error)
GetProductionWeightAndQtyByProjectFlockID(ctx context.Context, projectFlockID uint) (totalWeight float64, totalQty float64, err error)
GetTotalDepletionByProjectFlockID(ctx context.Context, projectFlockID uint) (totalDepletion float64, err error)
GetTotalDepletionByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (totalDepletion float64, err error)
GetLatestAvgWeightByProjectFlockID(ctx context.Context, projectFlockID uint) (avgWeight float64, err error)
GetTotalEggProductionWeightByProjectFlockID(ctx context.Context, projectFlockID uint) (totalWeightKg float64, err error)
GetAverageTargetMetricsByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint, includeTargets bool) (RecordingTargetAverages, error)
@@ -88,7 +84,6 @@ func (r *RecordingRepositoryImpl) WithRelations(db *gorm.DB) *gorm.DB {
Preload("CreatedUser").
Preload("ProjectFlockKandang").
Preload("ProjectFlockKandang.Kandang").
Preload("ProjectFlockKandang.Kandang.Location").
Preload("ProjectFlockKandang.ProjectFlock").
Preload("ProjectFlockKandang.ProjectFlock.ProductionStandard").
Preload("ProjectFlockKandang.ProjectFlock.Fcr").
@@ -112,42 +107,6 @@ func (r *RecordingRepositoryImpl) WithRelations(db *gorm.DB) *gorm.DB {
Preload("Eggs.ProductWarehouse.Warehouse.Location")
}
func (r *RecordingRepositoryImpl) ApplySearchFilters(db *gorm.DB, rawSearch string) *gorm.DB {
normalized := strings.ToLower(strings.TrimSpace(rawSearch))
if normalized == "" {
return db
}
likeQuery := "%" + normalized + "%"
subQuery := db.Session(&gorm.Session{NewDB: true}).
Table("recordings").
Select("recordings.id").
Joins("LEFT JOIN project_flock_kandangs pfk ON pfk.id = recordings.project_flock_kandangs_id").
Joins("LEFT JOIN project_flocks pf ON pf.id = pfk.project_flock_id").
Joins("LEFT JOIN kandangs k ON k.id = pfk.kandang_id").
Joins("LEFT JOIN locations l ON l.id = k.location_id").
Joins("LEFT JOIN recording_stocks rs ON rs.recording_id = recordings.id").
Joins("LEFT JOIN recording_depletions rd ON rd.recording_id = recordings.id").
Joins("LEFT JOIN recording_eggs re ON re.recording_id = recordings.id").
Joins("LEFT JOIN product_warehouses pws ON pws.id = rs.product_warehouse_id").
Joins("LEFT JOIN product_warehouses pwd ON pwd.id = rd.product_warehouse_id").
Joins("LEFT JOIN product_warehouses pwe ON pwe.id = re.product_warehouse_id").
Joins("LEFT JOIN warehouses ws ON ws.id = pws.warehouse_id").
Joins("LEFT JOIN warehouses wd ON wd.id = pwd.warehouse_id").
Joins("LEFT JOIN warehouses we ON we.id = pwe.warehouse_id").
Where(`
LOWER(pf.flock_name) LIKE ?
OR LOWER(k.name) LIKE ?
OR LOWER(l.name) LIKE ?
OR LOWER(l.address) LIKE ?
OR LOWER(ws.name) LIKE ?
OR LOWER(wd.name) LIKE ?
OR LOWER(we.name) LIKE ?`,
likeQuery, likeQuery, likeQuery, likeQuery, likeQuery, likeQuery, likeQuery,
)
return db.Where("recordings.id IN (?)", subQuery)
}
func (r *RecordingRepositoryImpl) GetLatestByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (*entity.Recording, error) {
if projectFlockKandangId == 0 {
return nil, errors.New("project_flock_kandang_id is required")
@@ -173,7 +132,6 @@ func (r *RecordingRepositoryImpl) GenerateNextDay(tx *gorm.DB, projectFlockKanda
var days []int
if err := tx.Model(&entity.Recording{}).
Where("project_flock_kandangs_id = ?", projectFlockKandangId).
Where("deleted_at IS NULL").
Where("day IS NOT NULL").
Pluck("day", &days).Error; err != nil {
return 0, err
@@ -209,12 +167,6 @@ func (r *RecordingRepositoryImpl) UpdateStockUsage(tx *gorm.DB, stockID uint, us
}).Error
}
func (r *RecordingRepositoryImpl) UpdateDepletionPending(tx *gorm.DB, depletionID uint, pendingQty float64) error {
return tx.Model(&entity.RecordingDepletion{}).
Where("id = ?", depletionID).
Update("pending_qty", pendingQty).Error
}
func (r *RecordingRepositoryImpl) CreateDepletions(tx *gorm.DB, depletions []entity.RecordingDepletion) error {
if len(depletions) == 0 {
return nil
@@ -370,25 +322,38 @@ func (r *RecordingRepositoryImpl) GetTotalChickinByProjectFlockKandang(tx *gorm.
}
func (r *RecordingRepositoryImpl) GetFeedUsageInGrams(tx *gorm.DB, recordingID uint) (float64, error) {
var result struct {
var rows []struct {
TotalQty float64
UomName string
}
if err := tx.
Table("recording_stocks").
Select("COALESCE(SUM(COALESCE(recording_stocks.usage_qty, 0) + COALESCE(recording_stocks.pending_qty, 0)), 0) AS total_qty").
Select("COALESCE(recording_stocks.usage_qty, 0) + COALESCE(recording_stocks.pending_qty, 0) AS total_qty, LOWER(uoms.name) AS uom_name").
Joins("JOIN product_warehouses ON product_warehouses.id = recording_stocks.product_warehouse_id").
Joins("JOIN products ON products.id = product_warehouses.product_id").
Joins("JOIN uoms ON uoms.id = products.uom_id").
Joins("JOIN flags ON flags.flagable_id = products.id AND flags.flagable_type = ? AND UPPER(flags.name) = ?", entity.FlagableTypeProduct, "PAKAN").
Where("recording_stocks.recording_id = ?", recordingID).
Scan(&result).Error; err != nil {
Scan(&rows).Error; err != nil {
return 0, err
}
if result.TotalQty <= 0 {
return 0, nil
var total float64
for _, row := range rows {
if row.TotalQty <= 0 {
continue
}
switch strings.TrimSpace(row.UomName) {
case "kilogram", "kg", "kilograms", "kilo":
total += row.TotalQty * 1000
case "gram", "g", "grams":
total += row.TotalQty
default:
total += row.TotalQty
}
}
return result.TotalQty * 1000, nil
return total, nil
}
func (r *RecordingRepositoryImpl) GetEggSummaryByRecording(tx *gorm.DB, recordingID uint) (totalQty float64, totalWeightGrams float64, err error) {
@@ -402,7 +367,7 @@ func (r *RecordingRepositoryImpl) GetEggSummaryByRecording(tx *gorm.DB, recordin
}
err = tx.
Table("recording_eggs").
Select("COALESCE(SUM(recording_eggs.qty), 0) AS total_qty, COALESCE(SUM(COALESCE(recording_eggs.weight, 0) * 1000), 0) AS total_weight_grams").
Select("COALESCE(SUM(recording_eggs.qty), 0) AS total_qty, COALESCE(SUM(recording_eggs.qty * COALESCE(recording_eggs.weight, 0)), 0) AS total_weight_grams").
Where("recording_eggs.recording_id = ?", recordingID).
Scan(&result).Error
if err != nil {
@@ -475,17 +440,6 @@ func (r *RecordingRepositoryImpl) GetTotalDepletionByProjectFlockID(ctx context.
return result, err
}
func (r *RecordingRepositoryImpl) GetTotalDepletionByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error) {
var result float64
err := r.DB().WithContext(ctx).
Table("recording_depletions").
Select("COALESCE(SUM(recording_depletions.qty), 0)").
Joins("JOIN recordings ON recordings.id = recording_depletions.recording_id").
Where("recordings.project_flock_kandangs_id = ?", projectFlockKandangID).
Scan(&result).Error
return result, err
}
func (r *RecordingRepositoryImpl) GetLatestAvgWeightByProjectFlockID(ctx context.Context, projectFlockID uint) (float64, error) {
// Body-weight tracking is removed; keep stub for report compatibility.
return 0, nil
@@ -499,7 +453,7 @@ func (r *RecordingRepositoryImpl) GetTotalEggProductionWeightByProjectFlockID(ct
var result float64
err := r.DB().WithContext(ctx).
Table("recording_eggs").
Select("COALESCE(SUM(recording_eggs.weight), 0)").
Select("COALESCE(SUM(recording_eggs.qty * recording_eggs.weight), 0) / 1000").
Joins("JOIN recordings ON recordings.id = recording_eggs.recording_id").
Joins("JOIN project_flock_kandangs ON project_flock_kandangs.id = recordings.project_flock_kandangs_id").
Where("project_flock_kandangs.project_flock_id = ?", projectFlockID).
@@ -622,23 +576,3 @@ func (r *RecordingRepositoryImpl) GetTotalWeightProducedFromUniformityByProjectF
return result.TotalWeight, err
}
func (r *RecordingRepositoryImpl) GetTotalWeightProducedFromUniformityByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error) {
if projectFlockKandangID == 0 {
return 0, nil
}
var result struct {
TotalWeight float64
}
err := r.DB().WithContext(ctx).
Table("project_flock_kandang_uniformity").
Select("COALESCE((mean_up / 1.10) * chick_qty_of_weight / 1000, 0) as total_weight").
Where("project_flock_kandang_id = ?", projectFlockKandangID).
Order("id DESC").
Limit(1).
Scan(&result).Error
return result.TotalWeight, err
}
@@ -4,10 +4,6 @@ import (
"context"
"errors"
"fmt"
"math"
"strings"
"time"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
@@ -18,11 +14,13 @@ import (
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"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations"
rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
recordingutil "gitlab.com/mbugroup/lti-api.git/internal/utils/recording"
"math"
"strings"
"time"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
@@ -33,7 +31,7 @@ import (
type RecordingService interface {
GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.Recording, int64, error)
GetOne(ctx *fiber.Ctx, id uint) (*entity.Recording, error)
GetNextDay(ctx *fiber.Ctx, projectFlockKandangId uint, recordTime time.Time) (int, error)
GetNextDay(ctx *fiber.Ctx, projectFlockKandangId uint) (int, error)
CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.Recording, error)
UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Recording, error)
DeleteOne(ctx *fiber.Ctx, id uint) error
@@ -41,12 +39,11 @@ type RecordingService interface {
}
type RecordingFIFOIntegrationService interface {
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, note string, actorID uint) error
ConsumeRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock) error
ReleaseRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock) error
}
var recordingStockUsableKey = fifo.UsableKeyRecordingStock
var recordingDepletionUsableKey = fifo.UsableKeyRecordingDepletion
type recordingService struct {
Log *logrus.Logger
@@ -59,7 +56,6 @@ type recordingService struct {
ApprovalSvc commonSvc.ApprovalService
ProductionStandardSvc sProductionStandard.ProductionStandardService
FifoSvc commonSvc.FifoService
StockLogRepo rStockLogs.StockLogRepository
}
func NewRecordingService(
@@ -70,7 +66,6 @@ func NewRecordingService(
approvalRepo commonRepo.ApprovalRepository,
approvalSvc commonSvc.ApprovalService,
fifoSvc commonSvc.FifoService,
stockLogRepo rStockLogs.StockLogRepository,
productionStandardSvc sProductionStandard.ProductionStandardService,
validate *validator.Validate,
) RecordingService {
@@ -85,7 +80,6 @@ func NewRecordingService(
ApprovalSvc: approvalSvc,
ProductionStandardSvc: productionStandardSvc,
FifoSvc: fifoSvc,
StockLogRepo: stockLogRepo,
}
}
@@ -93,14 +87,12 @@ func NewRecordingFIFOIntegrationService(
repo repository.RecordingRepository,
productWarehouseRepo rProductWarehouse.ProductWarehouseRepository,
fifoSvc commonSvc.FifoService,
stockLogRepo rStockLogs.StockLogRepository,
) RecordingFIFOIntegrationService {
return &recordingService{
Log: utils.Log,
Repository: repo,
ProductWarehouseRepo: productWarehouseRepo,
FifoSvc: fifoSvc,
StockLogRepo: stockLogRepo,
}
}
@@ -124,8 +116,7 @@ func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
if params.ProjectFlockKandangId != 0 {
db = db.Where("project_flock_kandangs_id = ?", params.ProjectFlockKandangId)
}
db = s.Repository.ApplySearchFilters(db, params.Search)
return db.Order("recordings.record_datetime DESC").Order("recordings.created_at DESC")
return db.Order("record_datetime DESC").Order("created_at DESC")
})
if err != nil {
@@ -161,21 +152,19 @@ func (s recordingService) GetOne(c *fiber.Ctx, id uint) (*entity.Recording, erro
return recording, nil
}
func (s recordingService) GetNextDay(c *fiber.Ctx, projectFlockKandangId uint, recordTime time.Time) (int, error) {
func (s recordingService) GetNextDay(c *fiber.Ctx, projectFlockKandangId uint) (int, error) {
if projectFlockKandangId == 0 {
return 0, fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is required")
}
if recordTime.IsZero() {
recordTime = time.Now().UTC()
}
day, err := s.computeRecordingDay(c.Context(), projectFlockKandangId, recordTime)
db := s.Repository.DB().WithContext(c.Context())
next, err := s.Repository.GenerateNextDay(db, projectFlockKandangId)
if err != nil {
s.Log.Errorf("Failed to compute recording day for project_flock_kandang_id=%d: %+v", projectFlockKandangId, err)
s.Log.Errorf("Failed to compute next recording day for project_flock_kandang_id=%d: %+v", projectFlockKandangId, err)
return 0, err
}
return day, nil
return next, nil
}
func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Recording, error) {
@@ -217,14 +206,12 @@ 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 {
return nil, fiber.NewError(fiber.StatusBadRequest, "Egg details permitted only for laying project flocks")
}
if isLaying && len(req.Eggs) == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "Egg details are required for laying project flocks")
}
if err := s.ensureProductWarehousesExist(c, req.Stocks, req.Depletions, req.Eggs); err != nil {
return nil, err
@@ -235,8 +222,13 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
}
var createdRecording entity.Recording
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 err := s.ProductionStandardSvc.EnsureWeekAvailable(ctx, pfk.ProjectFlock.ProductionStandardId, category, day); err != nil {
if err := s.ProductionStandardSvc.EnsureWeekAvailable(ctx, pfk.ProjectFlock.ProductionStandardId, category, nextDay); err != nil {
return err
}
}
@@ -250,6 +242,7 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
return fiber.NewError(fiber.StatusBadRequest, "Recording for this project flock today already exists")
}
day := nextDay
createdRecording = entity.Recording{
ProjectFlockKandangId: req.ProjectFlockKandangId,
RecordDatetime: recordTime,
@@ -282,33 +275,15 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
}
applyStockDesiredQuantities(mappedStocks, stockDesired, s.FifoSvc != nil)
note := fmt.Sprintf("Recording-Create#%d", createdRecording.Id)
if err := s.consumeRecordingStocks(ctx, tx, mappedStocks, note, actorID); err != nil {
if err := s.consumeRecordingStocks(ctx, tx, mappedStocks); err != nil {
return err
}
mappedDepletions := recordingutil.MapDepletions(createdRecording.Id, req.Depletions)
depletionDesired := resetDepletionQuantitiesForFIFO(mappedDepletions, s.FifoSvc != nil)
if s.FifoSvc != nil && len(mappedDepletions) > 0 {
sourceWarehouseID, err := s.resolvePopulationWarehouseID(ctx, req.ProjectFlockKandangId)
if err != nil {
return err
}
for i := range mappedDepletions {
mappedDepletions[i].SourceProductWarehouseId = &sourceWarehouseID
}
}
if err := s.Repository.CreateDepletions(tx, mappedDepletions); err != nil {
s.Log.Errorf("Failed to persist depletions: %+v", err)
return err
}
if s.FifoSvc != nil {
applyDepletionDesiredQuantities(mappedDepletions, depletionDesired, true)
note := fmt.Sprintf("Recording-Create#%d", createdRecording.Id)
if err := s.consumeRecordingDepletions(ctx, tx, mappedDepletions, note, actorID); err != nil {
return err
}
}
mappedEggs := recordingutil.MapEggs(createdRecording.Id, createdRecording.CreatedBy, req.Eggs)
if err := s.Repository.CreateEggs(tx, mappedEggs); err != nil {
@@ -316,14 +291,17 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
return err
}
if s.FifoSvc != nil {
note := fmt.Sprintf("Recording-Create#%d", createdRecording.Id)
if err := s.replenishRecordingEggs(ctx, tx, mappedEggs, note, actorID); err != nil {
if err := s.replenishRecordingEggs(ctx, tx, mappedEggs); err != nil {
return err
}
}
var warehouseDeltas map[uint]float64
warehouseDeltas = buildWarehouseDeltas(nil, mappedDepletions, nil, mappedEggs)
if s.FifoSvc != nil {
warehouseDeltas = buildWarehouseDeltas(nil, mappedDepletions, nil, nil)
} else {
warehouseDeltas = buildWarehouseDeltas(nil, mappedDepletions, nil, mappedEggs)
}
if err := s.adjustProductWarehouseQuantities(ctx, tx, warehouseDeltas); err != nil {
s.Log.Errorf("Failed to adjust product warehouses: %+v", err)
return err
@@ -359,10 +337,6 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
}
ctx := c.Context()
actorID, err := m.ActorIDFromContext(c)
if err != nil {
return nil, err
}
var recordingEntity *entity.Recording
var updatedRecording *entity.Recording
@@ -433,6 +407,9 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
if !isLaying && len(req.Eggs) > 0 {
return fiber.NewError(fiber.StatusBadRequest, "Egg details permitted only for laying project flocks")
}
if isLaying && len(req.Eggs) == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Egg details are required for laying project flocks")
}
}
if hasStockChanges {
@@ -448,49 +425,23 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
}
if hasStockChanges {
note := fmt.Sprintf("Recording-Edit#%d", recordingEntity.Id)
if err := s.syncRecordingStocks(ctx, tx, recordingEntity.Id, existingStocks, req.Stocks, note, actorID); err != nil {
if err := s.syncRecordingStocks(ctx, tx, recordingEntity.Id, existingStocks, req.Stocks); err != nil {
return err
}
}
if hasDepletionChanges {
if s.FifoSvc != nil {
note := fmt.Sprintf("Recording-Edit#%d", recordingEntity.Id)
if err := s.releaseRecordingDepletions(ctx, tx, existingDepletions, note, actorID); err != nil {
return err
}
}
if err := s.Repository.DeleteDepletions(tx, recordingEntity.Id); err != nil {
s.Log.Errorf("Failed to clear depletions: %+v", err)
return err
}
mappedDepletions := recordingutil.MapDepletions(recordingEntity.Id, req.Depletions)
depletionDesired := resetDepletionQuantitiesForFIFO(mappedDepletions, s.FifoSvc != nil)
if s.FifoSvc != nil && len(mappedDepletions) > 0 {
sourceWarehouseID, err := s.resolvePopulationWarehouseID(ctx, recordingEntity.ProjectFlockKandangId)
if err != nil {
return err
}
for i := range mappedDepletions {
mappedDepletions[i].SourceProductWarehouseId = &sourceWarehouseID
}
}
if err := s.Repository.CreateDepletions(tx, mappedDepletions); err != nil {
s.Log.Errorf("Failed to update depletions: %+v", err)
return err
}
if s.FifoSvc != nil {
applyDepletionDesiredQuantities(mappedDepletions, depletionDesired, true)
note := fmt.Sprintf("Recording-Edit#%d", recordingEntity.Id)
if err := s.consumeRecordingDepletions(ctx, tx, mappedDepletions, note, actorID); err != nil {
return err
}
}
if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(existingDepletions, mappedDepletions, nil, nil)); err != nil {
s.Log.Errorf("Failed to adjust product warehouses for depletions: %+v", err)
return err
@@ -502,28 +453,6 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
if err := ensureRecordingEggsUnused(existingEggs); err != nil {
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 {
s.Log.Errorf("Failed to adjust product warehouses for eggs: %+v", err)
return err
@@ -542,8 +471,7 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
}
if s.FifoSvc != nil {
note := fmt.Sprintf("Recording-Edit#%d", recordingEntity.Id)
if err := s.replenishRecordingEggs(ctx, tx, mappedEggs, note, actorID); err != nil {
if err := s.replenishRecordingEggs(ctx, tx, mappedEggs); err != nil {
return err
}
} else {
@@ -719,11 +647,6 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error {
s.Log.Errorf("Failed to list depletions before delete: %+v", err)
return err
}
if s.FifoSvc != nil {
if err := s.releaseRecordingDepletions(ctx, tx, oldDepletions, "", 0); err != nil {
return err
}
}
oldEggs, err := s.Repository.ListEggs(tx, id)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
@@ -742,7 +665,7 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error {
return err
}
if err := s.releaseRecordingStocks(ctx, tx, oldStocks, "", 0); err != nil {
if err := s.releaseRecordingStocks(ctx, tx, oldStocks); err != nil {
return err
}
@@ -801,19 +724,10 @@ func (s *recordingService) ensureProductWarehousesExist(c *fiber.Ctx, stocks []v
return nil
}
func (s *recordingService) consumeRecordingStocks(
ctx context.Context,
tx *gorm.DB,
stocks []entity.RecordingStock,
note string,
actorID uint,
) error {
func (s *recordingService) consumeRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock) error {
if len(stocks) == 0 || s.FifoSvc == nil {
return nil
}
if strings.TrimSpace(note) != "" && s.StockLogRepo == nil {
return errors.New("stock log repository is not available")
}
for _, stock := range stocks {
if stock.Id == 0 {
@@ -846,170 +760,19 @@ func (s *recordingService) consumeRecordingStocks(
if err := s.Repository.UpdateStockUsage(tx, stock.Id, result.UsageQuantity, result.PendingQuantity); err != nil {
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,
}
stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, stock.ProductWarehouseId, 1)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
log.Stock = latestStockLog.Stock
log.Stock -= log.Decrease
} else {
log.Stock = 0
}
if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil {
return err
}
}
}
return nil
}
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 {
return nil
}
if strings.TrimSpace(note) != "" && s.StockLogRepo == nil {
return errors.New("stock log repository is not available")
}
for _, depletion := range depletions {
if depletion.Id == 0 {
continue
}
sourceWarehouseID := uint(0)
if depletion.SourceProductWarehouseId != nil {
sourceWarehouseID = *depletion.SourceProductWarehouseId
}
if sourceWarehouseID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Source product warehouse tidak ditemukan untuk depletion")
}
desired := depletion.Qty + depletion.PendingQty
result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{
UsableKey: recordingDepletionUsableKey,
UsableID: depletion.Id,
ProductWarehouseID: sourceWarehouseID,
Quantity: desired,
AllowPending: false,
Tx: tx,
})
if err != nil {
s.Log.Errorf("Failed to consume FIFO stock for recording depletion %d: %+v", depletion.Id, err)
return err
}
if err := s.Repository.UpdateDepletionPending(tx, depletion.Id, result.PendingQuantity); err != nil {
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,
}
stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, sourceWarehouseID, 1)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
log.Stock = latestStockLog.Stock
log.Stock -= log.Decrease
} else {
log.Stock = 0
}
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 {
if depletion.ProductWarehouseId == sourceWarehouseID {
continue
}
log := &entity.StockLog{
ProductWarehouseId: depletion.ProductWarehouseId,
CreatedBy: actorID,
Increase: destDelta,
LoggableType: string(utils.StockLogTypeRecording),
LoggableId: depletion.RecordingId,
Notes: note,
}
stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, depletion.ProductWarehouseId, 1)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
log.Stock = latestStockLog.Stock
log.Stock += log.Increase
} else {
log.Stock = 0
}
if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil {
return err
}
}
}
return nil
func (s *recordingService) ConsumeRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock) error {
return s.consumeRecordingStocks(ctx, tx, stocks)
}
func (s *recordingService) ConsumeRecordingStocks(
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,
note string,
actorID uint,
) error {
func (s *recordingService) releaseRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock) error {
if len(stocks) == 0 || s.FifoSvc == nil {
return nil
}
if strings.TrimSpace(note) != "" && s.StockLogRepo == nil {
return errors.New("stock log repository is not available")
}
for _, stock := range stocks {
if stock.Id == 0 {
@@ -1028,202 +791,13 @@ func (s *recordingService) releaseRecordingStocks(
if err := s.Repository.UpdateStockUsage(tx, stock.Id, 0, 0); err != nil {
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,
}
stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, stock.ProductWarehouseId, 1)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
log.Stock = latestStockLog.Stock
log.Stock += log.Increase
} else {
log.Stock = 0
}
if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil {
return err
}
}
}
return nil
}
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 {
return nil
}
if strings.TrimSpace(note) != "" && s.StockLogRepo == nil {
return errors.New("stock log repository is not available")
}
for _, depletion := range depletions {
if depletion.Id == 0 {
continue
}
sourceWarehouseID := uint(0)
if depletion.SourceProductWarehouseId != nil {
sourceWarehouseID = *depletion.SourceProductWarehouseId
}
if sourceWarehouseID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Source product warehouse tidak ditemukan untuk depletion")
}
if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{
UsableKey: recordingDepletionUsableKey,
UsableID: depletion.Id,
Tx: tx,
}); err != nil {
s.Log.Errorf("Failed to release FIFO stock for recording depletion %d: %+v", depletion.Id, err)
return err
}
if err := s.Repository.UpdateDepletionPending(tx, depletion.Id, 0); err != nil {
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,
}
stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, sourceWarehouseID, 1)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
log.Stock = latestStockLog.Stock
log.Stock += log.Increase
} else {
log.Stock = 0
}
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 {
if depletion.ProductWarehouseId == sourceWarehouseID {
continue
}
log := &entity.StockLog{
ProductWarehouseId: depletion.ProductWarehouseId,
CreatedBy: actorID,
Decrease: destDelta,
LoggableType: string(utils.StockLogTypeRecording),
LoggableId: depletion.RecordingId,
Notes: note,
}
stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, depletion.ProductWarehouseId, 1)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
log.Stock = latestStockLog.Stock
log.Stock -= log.Decrease
} else {
log.Stock = 0
}
if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil {
return err
}
}
}
return nil
}
func (s *recordingService) ReleaseRecordingStocks(
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) {
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")
}
for _, pop := range populations {
if pop.ProductWarehouseId > 0 && pop.TotalQty > 0 {
return pop.ProductWarehouseId, nil
}
}
for _, pop := range populations {
if pop.ProductWarehouseId > 0 {
return pop.ProductWarehouseId, nil
}
}
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 (s *recordingService) ReleaseRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock) error {
return s.releaseRecordingStocks(ctx, tx, stocks)
}
func buildWarehouseDeltas(
@@ -1260,59 +834,27 @@ func (s *recordingService) adjustProductWarehouseQuantities(ctx context.Context,
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,
note string,
actorID uint,
) error {
func (s *recordingService) replenishRecordingEggs(ctx context.Context, tx *gorm.DB, eggs []entity.RecordingEgg) error {
if len(eggs) == 0 || s.FifoSvc == nil {
return nil
}
if strings.TrimSpace(note) != "" && s.StockLogRepo == nil {
return errors.New("stock log repository is not available")
}
for _, egg := range eggs {
if egg.Id == 0 || egg.ProductWarehouseId == 0 || egg.Qty <= 0 {
continue
}
note := fmt.Sprintf("Recording egg #%d", egg.Id)
if _, err := s.FifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{
StockableKey: fifo.StockableKeyRecordingEgg,
StockableID: egg.Id,
ProductWarehouseID: egg.ProductWarehouseId,
Quantity: float64(egg.Qty),
Note: &note,
Tx: tx,
}); err != nil {
s.Log.Errorf("Failed to replenish FIFO stock for recording egg %d: %+v", egg.Id, 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,
}
stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, egg.ProductWarehouseId, 1)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
log.Stock = latestStockLog.Stock
log.Stock += log.Increase
} else {
log.Stock = 0
}
if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil {
return err
}
}
}
return nil
@@ -1323,11 +865,6 @@ type desiredStock struct {
Pending float64
}
type desiredDepletion struct {
Qty float64
Pending float64
}
func resetStockQuantitiesForFIFO(stocks []entity.RecordingStock, enabled bool) []desiredStock {
desired := make([]desiredStock, len(stocks))
for i := range stocks {
@@ -1362,41 +899,12 @@ func applyStockDesiredQuantities(stocks []entity.RecordingStock, desired []desir
}
}
func resetDepletionQuantitiesForFIFO(depletions []entity.RecordingDepletion, enabled bool) []desiredDepletion {
desired := make([]desiredDepletion, len(depletions))
for i := range depletions {
desired[i].Qty = depletions[i].Qty
desired[i].Pending = depletions[i].PendingQty
if !enabled {
continue
}
depletions[i].Qty = 0
depletions[i].PendingQty = 0
}
return desired
}
func applyDepletionDesiredQuantities(depletions []entity.RecordingDepletion, desired []desiredDepletion, enabled bool) {
if !enabled {
return
}
for i := range depletions {
if i >= len(desired) {
break
}
depletions[i].Qty = desired[i].Qty
depletions[i].PendingQty = desired[i].Pending
}
}
func (s *recordingService) syncRecordingStocks(
ctx context.Context,
tx *gorm.DB,
recordingID uint,
existing []entity.RecordingStock,
incoming []validation.Stock,
note string,
actorID uint,
) error {
if s.FifoSvc == nil {
if err := s.Repository.DeleteStocks(tx, recordingID); err != nil {
@@ -1433,8 +941,10 @@ func (s *recordingService) syncRecordingStocks(
desired := item.Qty
stock.UsageQty = &desired
zero := 0.0
stock.PendingQty = &zero
if item.PendingQty != nil {
pending := *item.PendingQty
stock.PendingQty = &pending
}
stocksToConsume = append(stocksToConsume, stock)
}
@@ -1443,7 +953,7 @@ func (s *recordingService) syncRecordingStocks(
leftovers = append(leftovers, list...)
}
if len(leftovers) > 0 {
if err := s.releaseRecordingStocks(ctx, tx, leftovers, note, actorID); err != nil {
if err := s.releaseRecordingStocks(ctx, tx, leftovers); err != nil {
return err
}
ids := make([]uint, 0, len(leftovers))
@@ -1462,7 +972,7 @@ func (s *recordingService) syncRecordingStocks(
if len(stocksToConsume) == 0 {
return nil
}
return s.consumeRecordingStocks(ctx, tx, stocksToConsume, note, actorID)
return s.consumeRecordingStocks(ctx, tx, stocksToConsume)
}
type eggTotals struct {
@@ -1480,20 +990,43 @@ func ensureRecordingEggsUnused(eggs []entity.RecordingEgg) error {
}
func stocksMatch(existing []entity.RecordingStock, incoming []validation.Stock) bool {
hasPending := false
for _, item := range incoming {
if item.PendingQty != nil {
hasPending = true
break
}
}
existingUsage := make(map[uint]float64)
existingTotal := make(map[uint]float64)
for _, stock := range existing {
var usage float64
var pending float64
if stock.UsageQty != nil {
usage = *stock.UsageQty
}
if stock.PendingQty != nil {
pending = *stock.PendingQty
}
existingUsage[stock.ProductWarehouseId] += usage
existingTotal[stock.ProductWarehouseId] += usage + pending
}
incomingUsage := make(map[uint]float64)
incomingTotal := make(map[uint]float64)
for _, item := range incoming {
var pending float64
if item.PendingQty != nil {
pending = *item.PendingQty
}
incomingUsage[item.ProductWarehouseId] += item.Qty
incomingTotal[item.ProductWarehouseId] += item.Qty + pending
}
if hasPending {
return floatMapsMatch(existingTotal, incomingTotal)
}
return floatMapsMatch(existingUsage, incomingUsage)
}
@@ -1520,7 +1053,7 @@ func eggsMatch(existing []entity.RecordingEgg, incoming []validation.Egg) bool {
}
current := existingTotals[egg.ProductWarehouseId]
current.Qty += egg.Qty
current.Weight += weight
current.Weight += float64(egg.Qty) * weight
existingTotals[egg.ProductWarehouseId] = current
}
@@ -1532,7 +1065,7 @@ func eggsMatch(existing []entity.RecordingEgg, incoming []validation.Egg) bool {
}
current := incomingTotals[egg.ProductWarehouseId]
current.Qty += egg.Qty
current.Weight += weight
current.Weight += float64(egg.Qty) * weight
incomingTotals[egg.ProductWarehouseId] = current
}
@@ -1691,7 +1224,7 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm
var eggMass float64
if remainingChick > 0 && totalEggWeightGrams > 0 {
eggMass = (totalEggWeightGrams / remainingChick) / 1000
eggMass = (totalEggWeightGrams / remainingChick) * 1000
updates["egg_mass"] = eggMass
recording.EggMass = &eggMass
} else {
@@ -1701,7 +1234,7 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm
var eggWeight float64
if totalEggQty > 0 && totalEggWeightGrams > 0 {
eggWeight = totalEggWeightGrams / totalEggQty
eggWeight = (totalEggWeightGrams / totalEggQty) * 1000
updates["egg_weight"] = eggWeight
recording.EggWeight = &eggWeight
} else {
@@ -2,8 +2,9 @@ package validation
type (
Stock struct {
ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,number,min=1"`
Qty float64 `json:"qty" validate:"required,gte=0"`
ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,number,min=1"`
Qty float64 `json:"qty" validate:"required,gte=0"`
PendingQty *float64 `json:"pending_qty,omitempty" validate:"omitempty,gte=0"`
}
Depletion struct {
@@ -19,24 +20,23 @@ type (
)
type Create struct {
ProjectFlockKandangId uint `json:"project_flock_kandang_id" validate:"required,number,min=1"`
RecordDate *string `json:"record_date,omitempty" validate:"omitempty,datetime=2006-01-02"`
Stocks []Stock `json:"stocks" validate:"dive"`
Depletions []Depletion `json:"depletions,omitempty" validate:"omitempty,dive"`
Eggs []Egg `json:"eggs" validate:"omitempty,dive"`
ProjectFlockKandangId uint `json:"project_flock_kandang_id" validate:"required,number,min=1"`
RecordDate *string `json:"record_date,omitempty" validate:"omitempty,datetime=2006-01-02"`
Stocks []Stock `json:"stocks" validate:"dive"`
Depletions []Depletion `json:"depletions" validate:"dive"`
Eggs []Egg `json:"eggs" validate:"omitempty,dive"`
}
type Update struct {
Stocks []Stock `json:"stocks,omitempty" validate:"omitempty,dive"`
Depletions []Depletion `json:"depletions,omitempty" validate:"omitempty,dive"`
Eggs []Egg `json:"eggs,omitempty" validate:"omitempty,dive"`
Stocks []Stock `json:"stocks,omitempty" validate:"omitempty,dive"`
Depletions []Depletion `json:"depletions,omitempty" validate:"omitempty,dive"`
Eggs []Egg `json:"eggs,omitempty" validate:"omitempty,dive"`
}
type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"`
ProjectFlockKandangId uint `query:"project_flock_kandang_id" validate:"omitempty,number,min=1"`
Search string `query:"search" validate:"omitempty,max=50"`
Page int `query:"page" validate:"omitempty,number,min=1"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"`
ProjectFlockKandangId uint `query:"project_flock_kandang_id" validate:"omitempty,number,min=1"`
}
type Approve struct {
@@ -9,7 +9,6 @@ import (
service "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/services"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/transfer_layings/validations"
"gitlab.com/mbugroup/lti-api.git/internal/response"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"github.com/gofiber/fiber/v2"
)
@@ -29,11 +28,9 @@ func (u *TransferLayingController) GetAll(c *fiber.Ctx) error {
Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10),
Search: c.Query("search", ""),
StartDate: c.Query("start_date", ""),
EndDate: c.Query("end_date", ""),
FlockSource: utils.ParseQueryUintArray(c.Query("flock_source", "")),
FlockDestination: utils.ParseQueryUintArray(c.Query("flock_destination", "")),
Status: utils.ParseQueryArray(c.Query("status", "")),
TransferDate: c.Query("transfer_date", ""),
FlockSource: uint(c.QueryInt("flock_source", 0)),
FlockDestination: uint(c.QueryInt("flock_destination", 0)),
}
if query.Page < 1 || query.Limit < 1 {
@@ -73,7 +70,7 @@ func (u *TransferLayingController) GetOne(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
}
result, approval, err := u.TransferLayingService.GetOne(c, uint(id))
result, approval, err := u.TransferLayingService.GetOneWithApproval(c, uint(id))
if err != nil {
return err
}
@@ -221,29 +218,3 @@ func (u *TransferLayingController) GetAvailableQtyPerKandang(c *fiber.Ctx) error
Data: resp,
})
}
func (u *TransferLayingController) GetMaxTargetQtyPerKandang(c *fiber.Ctx) error {
projectFlockID, err := strconv.ParseUint(c.Params("project_flock_id"), 10, 32)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_id")
}
kandangMaxTargetQty, err := u.TransferLayingService.GetMaxTargetQtyPerKandang(c, uint(projectFlockID))
if err != nil {
return err
}
kandangs := make([]dto.KandangMaxTargetQtyDTO, 0, len(kandangMaxTargetQty))
for pfkId, maxTargetQty := range kandangMaxTargetQty {
kandangs = append(kandangs, dto.ToKandangMaxTargetQtyDTO(pfkId, maxTargetQty))
}
resp := dto.ToMaxTargetQtyForTransferDTO(uint(projectFlockID), kandangs)
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Get max target quantity successfully",
Data: resp,
})
}
@@ -5,9 +5,6 @@ import (
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto"
productWarehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/dto"
kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto"
projectFlockDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/dto"
userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto"
)
@@ -20,35 +17,60 @@ type TransferLayingRelationDTO struct {
Notes string `json:"notes"`
}
type ProjectFlockKandangWithKandangDTO struct {
Id uint `json:"id"`
KandangId uint `json:"kandang_id"`
ProjectFlockId uint `json:"project_flock_id"`
Kandang *kandangDTO.KandangRelationDTO `json:"kandang,omitempty"`
type ProjectFlockSummaryDTO struct {
Id uint `json:"id"`
FlockName string `json:"flock_name"`
Category string `json:"category"`
}
type ProductSummaryDTO struct {
Id uint `json:"id"`
Name string `json:"name"`
}
type WarehouseSummaryDTO struct {
Id uint `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
}
type ProductWarehouseSummaryDTO struct {
Product *ProductSummaryDTO `json:"product,omitempty"`
Warehouse *WarehouseSummaryDTO `json:"warehouse,omitempty"`
}
type ProjectFlockKandangSummaryDTO struct {
Id uint `json:"id"`
Kandang *KandangSummaryDTO `json:"kandang,omitempty"`
}
type KandangSummaryDTO struct {
Id uint `json:"id"`
Name string `json:"name"`
}
type LayingTransferSourceDTO struct {
SourceProjectFlockKandang *ProjectFlockKandangWithKandangDTO `json:"source_project_flock_kandang,omitempty"`
Qty float64 `json:"qty"`
ProductWarehouse *productWarehouseDTO.ProductWarehouseRelationDTO `json:"product_warehouse,omitempty"`
Note string `json:"note,omitempty"`
SourceProjectFlockKandang *ProjectFlockKandangSummaryDTO `json:"source_project_flock_kandang,omitempty"`
Qty float64 `json:"qty"`
ProductWarehouse *ProductWarehouseSummaryDTO `json:"product_warehouse,omitempty"`
Note string `json:"note,omitempty"`
}
type LayingTransferTargetDTO struct {
TargetProjectFlockKandang *ProjectFlockKandangWithKandangDTO `json:"target_project_flock_kandang,omitempty"`
Qty float64 `json:"qty"`
ProductWarehouse *productWarehouseDTO.ProductWarehouseRelationDTO `json:"product_warehouse,omitempty"`
Note string `json:"note,omitempty"`
TargetProjectFlockKandang *ProjectFlockKandangSummaryDTO `json:"target_project_flock_kandang,omitempty"`
Qty float64 `json:"qty"`
ProductWarehouse *ProductWarehouseSummaryDTO `json:"product_warehouse,omitempty"`
Note string `json:"note,omitempty"`
}
type TransferLayingListDTO struct {
TransferLayingRelationDTO
FromProjectFlock *projectFlockDTO.ProjectFlockRelationDTO `json:"from_project_flock,omitempty"`
ToProjectFlock *projectFlockDTO.ProjectFlockRelationDTO `json:"to_project_flock,omitempty"`
CreatedBy uint `json:"created_by"`
CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"`
CreatedAt time.Time `json:"created_at"`
Approval *approvalDTO.ApprovalRelationDTO `json:"approval,omitempty"`
FromProjectFlock *ProjectFlockSummaryDTO `json:"from_project_flock,omitempty"`
ToProjectFlock *ProjectFlockSummaryDTO `json:"to_project_flock,omitempty"`
CreatedBy uint `json:"created_by"`
CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"`
CreatedAt time.Time `json:"created_at"`
Approval *approvalDTO.ApprovalRelationDTO `json:"approval,omitempty"`
}
type TransferLayingDetailDTO struct {
@@ -72,26 +94,70 @@ type AvailableQtyForTransferDTO struct {
Kandangs []KandangAvailableQtyDTO `json:"kandangs"`
}
// === Max Target Quantity DTOs ===
type KandangMaxTargetQtyDTO struct {
ProjectFlockKandangId uint `json:"project_flock_kandang_id"`
MaxTargetQty float64 `json:"max_target_qty"`
}
type MaxTargetQtyForTransferDTO struct {
ProjectFlockId uint `json:"project_flock_id"`
ProjectFlockKandangs []KandangMaxTargetQtyDTO `json:"project_flock_kandangs"`
}
// === Mapper Functions ===
func ToTransferLayingRelationDTO(e entity.LayingTransfer) TransferLayingRelationDTO {
return TransferLayingRelationDTO{
Id: e.Id,
TransferNumber: e.TransferNumber,
TransferDate: e.TransferDate,
Notes: e.Notes,
func ToProjectFlockSummaryDTO(pf *entity.ProjectFlock) *ProjectFlockSummaryDTO {
if pf == nil || pf.Id == 0 {
return nil
}
return &ProjectFlockSummaryDTO{
Id: pf.Id,
FlockName: pf.FlockName,
Category: pf.Category,
}
}
func ToProjectFlockKandangSummaryDTO(pfk *entity.ProjectFlockKandang) *ProjectFlockKandangSummaryDTO {
if pfk == nil || pfk.Id == 0 {
return nil
}
var kandang *KandangSummaryDTO
if pfk.Kandang.Id != 0 {
kandang = &KandangSummaryDTO{
Id: pfk.Kandang.Id,
Name: pfk.Kandang.Name,
}
}
return &ProjectFlockKandangSummaryDTO{
Id: pfk.Id,
Kandang: kandang,
}
}
func ToProductSummaryDTO(product *entity.Product) *ProductSummaryDTO {
if product == nil || product.Id == 0 {
return nil
}
return &ProductSummaryDTO{
Id: product.Id,
Name: product.Name,
}
}
func ToWarehouseSummaryDTO(warehouse *entity.Warehouse) *WarehouseSummaryDTO {
if warehouse == nil || warehouse.Id == 0 {
return nil
}
return &WarehouseSummaryDTO{
Id: warehouse.Id,
Name: warehouse.Name,
Type: warehouse.Type,
}
}
func ToProductWarehouseSummaryDTO(pw *entity.ProductWarehouse) *ProductWarehouseSummaryDTO {
if pw == nil || pw.Id == 0 {
return nil
}
return &ProductWarehouseSummaryDTO{
Product: ToProductSummaryDTO(&pw.Product),
Warehouse: ToWarehouseSummaryDTO(&pw.Warehouse),
}
}
@@ -106,29 +172,10 @@ func ToLayingTransferSourceDTO(source entity.LayingTransferSource) LayingTransfe
displayQty = source.RequestedQty
}
var pfkDTO *ProjectFlockKandangWithKandangDTO
if source.SourceProjectFlockKandang != nil && source.SourceProjectFlockKandang.Id != 0 {
pfkDTO = &ProjectFlockKandangWithKandangDTO{
Id: source.SourceProjectFlockKandang.Id,
KandangId: source.SourceProjectFlockKandang.KandangId,
ProjectFlockId: source.SourceProjectFlockKandang.ProjectFlockId,
}
if source.SourceProjectFlockKandang.Kandang.Id != 0 {
mapped := kandangDTO.ToKandangRelationDTO(source.SourceProjectFlockKandang.Kandang)
pfkDTO.Kandang = &mapped
}
}
var pwDTO *productWarehouseDTO.ProductWarehouseRelationDTO
if source.ProductWarehouse != nil && source.ProductWarehouse.Id != 0 {
mapped := productWarehouseDTO.ToProductWarehouseRelationDTO(*source.ProductWarehouse)
pwDTO = &mapped
}
return LayingTransferSourceDTO{
SourceProjectFlockKandang: pfkDTO,
SourceProjectFlockKandang: ToProjectFlockKandangSummaryDTO(source.SourceProjectFlockKandang),
Qty: displayQty,
ProductWarehouse: pwDTO,
ProductWarehouse: ToProductWarehouseSummaryDTO(source.ProductWarehouse),
Note: source.Note,
}
}
@@ -145,29 +192,10 @@ func ToLayingTransferSourceDTOs(sources []entity.LayingTransferSource) []LayingT
}
func ToLayingTransferTargetDTO(target entity.LayingTransferTarget) LayingTransferTargetDTO {
var pfkDTO *ProjectFlockKandangWithKandangDTO
if target.TargetProjectFlockKandang != nil && target.TargetProjectFlockKandang.Id != 0 {
pfkDTO = &ProjectFlockKandangWithKandangDTO{
Id: target.TargetProjectFlockKandang.Id,
KandangId: target.TargetProjectFlockKandang.KandangId,
ProjectFlockId: target.TargetProjectFlockKandang.ProjectFlockId,
}
if target.TargetProjectFlockKandang.Kandang.Id != 0 {
mapped := kandangDTO.ToKandangRelationDTO(target.TargetProjectFlockKandang.Kandang)
pfkDTO.Kandang = &mapped
}
}
var pwDTO *productWarehouseDTO.ProductWarehouseRelationDTO
if target.ProductWarehouse != nil && target.ProductWarehouse.Id != 0 {
mapped := productWarehouseDTO.ToProductWarehouseRelationDTO(*target.ProductWarehouse)
pwDTO = &mapped
}
return LayingTransferTargetDTO{
TargetProjectFlockKandang: pfkDTO,
Qty: target.TotalQty,
ProductWarehouse: pwDTO,
TargetProjectFlockKandang: ToProjectFlockKandangSummaryDTO(target.TargetProjectFlockKandang),
Qty: target.TotalQty, // Ambil dari TotalQty (FIFO replenished quantity)
ProductWarehouse: ToProductWarehouseSummaryDTO(target.ProductWarehouse),
Note: target.Note,
}
}
@@ -183,6 +211,15 @@ func ToLayingTransferTargetDTOs(targets []entity.LayingTransferTarget) []LayingT
return result
}
func ToTransferLayingRelationDTO(e entity.LayingTransfer) TransferLayingRelationDTO {
return TransferLayingRelationDTO{
Id: e.Id,
TransferNumber: e.TransferNumber,
TransferDate: e.TransferDate,
Notes: e.Notes,
}
}
func ToTransferLayingListDTO(e entity.LayingTransfer) TransferLayingListDTO {
var createdUser *userDTO.UserRelationDTO
if e.CreatedUser != nil && e.CreatedUser.Id != 0 {
@@ -190,52 +227,26 @@ func ToTransferLayingListDTO(e entity.LayingTransfer) TransferLayingListDTO {
createdUser = &mapped
}
var approval *approvalDTO.ApprovalRelationDTO
if e.LatestApproval != nil {
mapped := approvalDTO.ToApprovalDTO(*e.LatestApproval)
approval = &mapped
}
// Build from project flock DTO
var fromProjectFlock *projectFlockDTO.ProjectFlockRelationDTO
if e.FromProjectFlock != nil && e.FromProjectFlock.Id != 0 {
fromProjectFlock = &projectFlockDTO.ProjectFlockRelationDTO{
Id: e.FromProjectFlock.Id,
FlockName: e.FromProjectFlock.FlockName,
}
}
var toProjectFlock *projectFlockDTO.ProjectFlockRelationDTO
if e.ToProjectFlock != nil && e.ToProjectFlock.Id != 0 {
toProjectFlock = &projectFlockDTO.ProjectFlockRelationDTO{
Id: e.ToProjectFlock.Id,
FlockName: e.ToProjectFlock.FlockName,
}
}
return TransferLayingListDTO{
TransferLayingRelationDTO: ToTransferLayingRelationDTO(e),
FromProjectFlock: fromProjectFlock,
ToProjectFlock: toProjectFlock,
FromProjectFlock: ToProjectFlockSummaryDTO(e.FromProjectFlock),
ToProjectFlock: ToProjectFlockSummaryDTO(e.ToProjectFlock),
CreatedBy: e.CreatedBy,
CreatedUser: createdUser,
CreatedAt: e.CreatedAt,
Approval: approval,
}
}
func ToTransferLayingDetailDTO(e entity.LayingTransfer, approvals []entity.Approval) TransferLayingDetailDTO {
var latestApproval *approvalDTO.ApprovalRelationDTO
// Prioritas: e.LatestApproval > approvals slice
approvalToMap := e.LatestApproval
if approvalToMap == nil && len(approvals) > 0 {
approvalToMap = &approvals[len(approvals)-1]
}
if approvalToMap != nil {
mapped := approvalDTO.ToApprovalDTO(*approvalToMap)
if e.LatestApproval != nil {
mapped := approvalDTO.ToApprovalDTO(*e.LatestApproval)
latestApproval = &mapped
} else if len(approvals) > 0 {
// Fallback to approvals slice
latest := approvalDTO.ToApprovalDTO(approvals[len(approvals)-1])
latestApproval = &latest
}
return TransferLayingDetailDTO{
@@ -249,14 +260,13 @@ func ToTransferLayingDetailDTO(e entity.LayingTransfer, approvals []entity.Appro
func ToTransferLayingDetailDTOWithSingleApproval(e entity.LayingTransfer, approval *entity.Approval) TransferLayingDetailDTO {
var mappedApproval *approvalDTO.ApprovalRelationDTO
// Prioritas: e.LatestApproval > approval parameter
approvalToMap := e.LatestApproval
if approvalToMap == nil && approval != nil {
approvalToMap = approval
}
if approvalToMap != nil {
mapped := approvalDTO.ToApprovalDTO(*approvalToMap)
// Prefer LatestApproval from entity
if e.LatestApproval != nil && e.LatestApproval.Id != 0 {
mapped := approvalDTO.ToApprovalDTO(*e.LatestApproval)
mappedApproval = &mapped
} else if approval != nil && approval.Id != 0 {
// Fallback to passed approval parameter
mapped := approvalDTO.ToApprovalDTO(*approval)
mappedApproval = &mapped
}
@@ -275,17 +285,3 @@ func ToTransferLayingListDTOs(items []entity.LayingTransfer) []TransferLayingLis
}
return result
}
func ToKandangMaxTargetQtyDTO(pfkId uint, maxTargetQTY float64) KandangMaxTargetQtyDTO {
return KandangMaxTargetQtyDTO{
ProjectFlockKandangId: uint(pfkId),
MaxTargetQty: maxTargetQTY,
}
}
func ToMaxTargetQtyForTransferDTO(pfId uint, kandangs []KandangMaxTargetQtyDTO) MaxTargetQtyForTransferDTO {
return MaxTargetQtyForTransferDTO{
ProjectFlockId: pfId,
ProjectFlockKandangs: kandangs,
}
}
@@ -2,7 +2,6 @@ package repository
import (
"context"
"strings"
"gitlab.com/mbugroup/lti-api.git/internal/common/repository"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
@@ -13,9 +12,6 @@ type TransferLayingRepository interface {
repository.BaseRepository[entity.LayingTransfer]
GetByTransferNumber(ctx context.Context, transferNumber string) (*entity.LayingTransfer, error)
IdExists(ctx context.Context, id uint) (bool, error)
// Tambah method baru untuk query dengan filter lengkap
GetAllWithFilters(ctx context.Context, offset int, limit int, params *GetAllFilterParams) ([]entity.LayingTransfer, int64, error)
}
type TransferLayingRepositoryImpl struct {
@@ -44,93 +40,3 @@ func (r *TransferLayingRepositoryImpl) GetByTransferNumber(ctx context.Context,
}
return &transfer, nil
}
type GetAllFilterParams struct {
Search string
StartDate string
EndDate string
FlockSource []uint
FlockDestination []uint
Status []string
}
func (r *TransferLayingRepositoryImpl) GetAllWithFilters(ctx context.Context, offset int, limit int, params *GetAllFilterParams) ([]entity.LayingTransfer, int64, error) {
var records []entity.LayingTransfer
var total int64
q := r.db.WithContext(ctx).Model(&entity.LayingTransfer{})
if params.Search != "" {
searchPattern := "%" + params.Search + "%"
q = q.Joins("LEFT JOIN project_flocks AS pf_from ON laying_transfers.from_project_flock_id = pf_from.id").
Joins("LEFT JOIN project_flocks AS pf_to ON laying_transfers.to_project_flock_id = pf_to.id").
Where("laying_transfers.transfer_number ILIKE ? OR laying_transfers.notes ILIKE ? OR pf_from.flock_name ILIKE ? OR pf_to.flock_name ILIKE ?",
searchPattern, searchPattern, searchPattern, searchPattern)
}
if params.StartDate != "" && params.EndDate != "" {
q = q.Where("transfer_date::date >= ?::date AND transfer_date::date <= ?::date",
params.StartDate, params.EndDate)
} else if params.StartDate != "" {
q = q.Where("transfer_date::date >= ?::date", params.StartDate)
} else if params.EndDate != "" {
q = q.Where("transfer_date::date <= ?::date", params.EndDate)
}
if len(params.FlockSource) > 0 {
q = q.Where("from_project_flock_id IN ?", params.FlockSource)
}
if len(params.FlockDestination) > 0 {
q = q.Where("to_project_flock_id IN ?", params.FlockDestination)
}
if len(params.Status) > 0 {
statusConditions := []string{}
statusValues := []interface{}{}
for _, status := range params.Status {
switch status {
case "PENDING":
statusConditions = append(statusConditions,
"NOT EXISTS (SELECT 1 FROM approvals WHERE approvable_type = 'TRANSFER_TO_LAYINGS' AND approvable_id = laying_transfers.id)")
case "APPROVED":
statusConditions = append(statusConditions,
"EXISTS (SELECT 1 FROM approvals WHERE approvable_type = 'TRANSFER_TO_LAYINGS' AND approvable_id = laying_transfers.id AND action = 'APPROVED' ORDER BY created_at DESC LIMIT 1)")
case "REJECTED":
statusConditions = append(statusConditions,
"EXISTS (SELECT 1 FROM approvals WHERE approvable_type = 'TRANSFER_TO_LAYINGS' AND approvable_id = laying_transfers.id AND action = 'REJECTED' ORDER BY created_at DESC LIMIT 1)")
}
}
if len(statusConditions) > 0 {
q = q.Where("("+strings.Join(statusConditions, " OR ")+")", statusValues...)
}
}
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
q = q.Offset(offset).Limit(limit).
Preload("FromProjectFlock").
Preload("ToProjectFlock").
Preload("CreatedUser").
Preload("Sources").
Preload("Sources.SourceProjectFlockKandang").
Preload("Sources.SourceProjectFlockKandang.Kandang").
Preload("Sources.ProductWarehouse").
Preload("Targets").
Preload("Targets.TargetProjectFlockKandang").
Preload("Targets.TargetProjectFlockKandang.Kandang").
Preload("Targets.ProductWarehouse").
Order("laying_transfers.created_at DESC")
if err := q.Find(&records).Error; err != nil {
return nil, 0, err
}
return records, total, nil
}
@@ -21,12 +21,11 @@ func TransferLayingRoutes(v1 fiber.Router, u user.UserService, s transferLaying.
// route.Delete("/:id", m.Auth(u), ctrl.DeleteOne)
// route.Post("/approval", m.Auth(u), ctrl.Approval)
route.Get("/", m.RequirePermissions(m.P_TransferToLaying_GetAll), ctrl.GetAll)
route.Post("/", m.RequirePermissions(m.P_TransferToLaying_CreateOne), ctrl.CreateOne)
route.Get("/:id", m.RequirePermissions(m.P_TransferToLaying_GetOne), ctrl.GetOne)
route.Patch("/:id", m.RequirePermissions(m.P_TransferToLaying_UpdateOne), ctrl.UpdateOne)
route.Delete("/:id", m.RequirePermissions(m.P_TransferToLaying_DeleteOne), ctrl.DeleteOne)
route.Post("/approvals", m.RequirePermissions(m.P_TransferToLaying_Approval), ctrl.Approval)
route.Get("/project-flocks/:project_flock_id/available-qty", m.RequirePermissions(m.P_TransferToLaying_GetAvailableQty), ctrl.GetAvailableQtyPerKandang)
route.Get("/project-flocks/:project_flock_id/max-target-qty", m.RequirePermissions(m.P_TransferToLaying_CreateOne), ctrl.GetMaxTargetQtyPerKandang)
route.Get("/",m.RequirePermissions(m.P_TransferToLaying_GetAll), ctrl.GetAll)
route.Post("/",m.RequirePermissions(m.P_TransferToLaying_CreateOne), ctrl.CreateOne)
route.Get("/:id",m.RequirePermissions(m.P_TransferToLaying_GetOne), ctrl.GetOne)
route.Patch("/:id",m.RequirePermissions(m.P_TransferToLaying_UpdateOne), ctrl.UpdateOne)
route.Delete("/:id",m.RequirePermissions(m.P_TransferToLaying_DeleteOne), ctrl.DeleteOne)
route.Post("/approvals",m.RequirePermissions(m.P_TransferToLaying_Approval), ctrl.Approval)
route.Get("/project-flocks/:project_flock_id/available-qty",m.RequirePermissions(m.P_TransferToLaying_GetAvailableQty), ctrl.GetAvailableQtyPerKandang)
}
@@ -28,13 +28,13 @@ import (
type TransferLayingService interface {
GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.LayingTransfer, int64, error)
GetOne(ctx *fiber.Ctx, id uint) (*entity.LayingTransfer, *entity.Approval, error)
GetOne(ctx *fiber.Ctx, id uint) (*entity.LayingTransfer, error)
GetOneWithApproval(ctx *fiber.Ctx, id uint) (*entity.LayingTransfer, *entity.Approval, error)
CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.LayingTransfer, error)
UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.LayingTransfer, error)
DeleteOne(ctx *fiber.Ctx, id uint) error
Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.LayingTransfer, error)
GetAvailableQtyPerKandang(ctx *fiber.Ctx, projectFlockID uint) (*entity.ProjectFlock, map[uint]float64, error)
GetMaxTargetQtyPerKandang(ctx *fiber.Ctx, projectFlockID uint) (map[uint]float64, error)
}
type transferLayingService struct {
@@ -109,20 +109,34 @@ func (s transferLayingService) GetAll(c *fiber.Ctx, params *validation.Query) ([
offset := (params.Page - 1) * params.Limit
filterParams := &repository.GetAllFilterParams{
Search: params.Search,
StartDate: params.StartDate,
EndDate: params.EndDate,
FlockSource: params.FlockSource,
FlockDestination: params.FlockDestination,
Status: params.Status,
}
transferLayings, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
// Apply search and filters
if params.Search != "" {
searchPattern := "%" + params.Search + "%"
db = db.Joins("LEFT JOIN project_flocks AS pf_from ON laying_transfers.from_project_flock_id = pf_from.id").
Joins("LEFT JOIN project_flocks AS pf_to ON laying_transfers.to_project_flock_id = pf_to.id").
Where("laying_transfers.transfer_number ILIKE ? OR laying_transfers.notes ILIKE ? OR pf_from.flock_name ILIKE ? OR pf_to.flock_name ILIKE ?",
searchPattern, searchPattern, searchPattern, searchPattern)
}
transferLayings, total, err := s.Repository.GetAllWithFilters(c.Context(), offset, params.Limit, filterParams)
if err != nil {
s.Log.Errorf("Failed to get transferLayings: %+v", err)
return nil, 0, err
}
if params.TransferDate != "" {
db = db.Where("transfer_date::date = ?::date", params.TransferDate)
}
if params.FlockSource > 0 {
db = db.Where("from_project_flock_id = ?", params.FlockSource)
}
if params.FlockDestination > 0 {
db = db.Where("to_project_flock_id = ?", params.FlockDestination)
}
db = db.Order("created_at DESC")
db = s.withRelations(db)
return db
})
if err != nil {
s.Log.Errorf("Failed to get transferLayings: %+v", err)
@@ -142,15 +156,14 @@ func (s transferLayingService) GetAll(c *fiber.Ctx, params *validation.Query) ([
return transferLayings, total, nil
}
func (s transferLayingService) GetOne(c *fiber.Ctx, id uint) (*entity.LayingTransfer, *entity.Approval, error) {
func (s transferLayingService) GetOne(c *fiber.Ctx, id uint) (*entity.LayingTransfer, error) {
transferLaying, err := s.Repository.GetByID(c.Context(), id, s.withRelations)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, fiber.NewError(fiber.StatusNotFound, "TransferLaying not found")
return nil, fiber.NewError(fiber.StatusNotFound, "TransferLaying not found")
}
if err != nil {
s.Log.Errorf("Failed get transferLaying by id: %+v", err)
return nil, nil, err
return nil, err
}
approvalRepo := commonRepo.NewApprovalRepository(s.Repository.DB())
@@ -161,6 +174,15 @@ func (s transferLayingService) GetOne(c *fiber.Ctx, id uint) (*entity.LayingTran
transferLaying.LatestApproval = latestApproval
}
return transferLaying, nil
}
func (s transferLayingService) GetOneWithApproval(c *fiber.Ctx, id uint) (*entity.LayingTransfer, *entity.Approval, error) {
transferLaying, err := s.GetOne(c, id)
if err != nil {
return nil, nil, err
}
return transferLaying, transferLaying.LatestApproval, nil
}
@@ -384,12 +406,7 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat transfer laying")
}
laying_transfer, _, err := s.GetOne(c, createBody.Id)
if err != nil {
return nil, err
}
return laying_transfer, nil
return s.GetOne(c, createBody.Id)
}
func (s *transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.LayingTransfer, error) {
@@ -565,9 +582,7 @@ func (s *transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update,
return nil, err
}
layingTransfer, _, err := s.GetOne(c, id)
return layingTransfer, err
return s.GetOne(c, id)
}
func (s transferLayingService) DeleteOne(c *fiber.Ctx, id uint) error {
@@ -758,7 +773,7 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) (
updated := make([]entity.LayingTransfer, 0, len(approvableIDs))
for _, approvableID := range approvableIDs {
transfer, _, err := s.GetOne(c, approvableID)
transfer, err := s.GetOne(c, approvableID)
if err != nil {
return nil, err
}
@@ -875,39 +890,3 @@ func (s *transferLayingService) validateKandangOwnership(
return nil
}
func (s transferLayingService) GetMaxTargetQtyPerKandang(c *fiber.Ctx, projectFlockID uint) (map[uint]float64, error) {
if err := commonSvc.EnsureRelations(c.Context(),
commonSvc.RelationCheck{Name: "Project Flock", ID: &projectFlockID, Exists: s.ProjectFlockRepo.IdExists},
); err != nil {
return nil, err
}
projectFlockKandangs, err := s.ProjectFlockKandangRepo.GetByProjectFlockID(c.Context(), projectFlockID)
if err != nil {
return nil, err
}
kandangMaxTargetQty := make(map[uint]float64)
for _, projectFlockKandang := range projectFlockKandangs {
capacity := projectFlockKandang.Kandang.Capacity
availableQty, err := s.ProjectFlockPopulationRepo.GetAvailableQtyByProjectFlockKandangID(
c.Context(),
projectFlockKandang.Id,
)
if err != nil {
return nil, err
}
kandangMaxTargetQty[projectFlockKandang.Id] = capacity - availableQty
if kandangMaxTargetQty[projectFlockKandang.Id] < 0 {
kandangMaxTargetQty[projectFlockKandang.Id] = 0
}
}
return kandangMaxTargetQty, nil
}
@@ -29,14 +29,12 @@ type Update struct {
}
type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1,gt=0"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"`
Search string `query:"search" validate:"omitempty"`
StartDate string `query:"start_date" validate:"omitempty"`
EndDate string `query:"end_date" validate:"omitempty"`
FlockSource []uint `query:"flock_source" validate:"omitempty"`
FlockDestination []uint `query:"flock_destination" validate:"omitempty"`
Status []string `query:"status" validate:"omitempty"`
Page int `query:"page" validate:"omitempty,number,min=1,gt=0"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"`
Search string `query:"search" validate:"omitempty"`
TransferDate string `query:"transfer_date" validate:"omitempty"`
FlockSource uint `query:"flock_source" validate:"omitempty,number"`
FlockDestination uint `query:"flock_destination" validate:"omitempty,number"`
}
type Approve struct {
@@ -345,52 +345,7 @@ func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file
); err != nil {
return nil, err
}
pfk, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), req.ProjectFlockKandangId)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Project Flock Kandang not found")
}
return nil, err
}
category := strings.TrimSpace(pfk.ProjectFlock.Category)
if s.ProductionStandardRepo != nil && pfk.ProjectFlock.ProductionStandardId != 0 {
if standard, err := s.ProductionStandardRepo.GetByID(c.Context(), pfk.ProjectFlock.ProductionStandardId, nil); err == nil {
if strings.TrimSpace(standard.ProjectCategory) != "" {
category = standard.ProjectCategory
}
}
}
weekBase := 1
if strings.EqualFold(category, string(utils.ProjectFlockCategoryLaying)) {
weekBase = 18
}
if req.Week < weekBase {
if weekBase == 18 {
return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 18 for laying projects")
}
return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 1 for growing projects")
}
var latestWeek int
if err := s.Repository.DB().WithContext(c.Context()).
Model(&entity.ProjectFlockKandangUniformity{}).
Where("project_flock_kandang_id = ?", req.ProjectFlockKandangId).
Select("COALESCE(MAX(week), 0)").
Scan(&latestWeek).Error; err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate uniformity week sequence")
}
if latestWeek == 0 && req.Week != weekBase {
if weekBase == 18 {
return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 18 for laying projects")
}
return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 1 for growing projects")
}
if latestWeek > 0 && req.Week > latestWeek+1 {
return nil, fiber.NewError(fiber.StatusBadRequest, "week must be sequential without skipping")
}
if err := s.ensureUniqueUniformity(c.Context(), 0, req.ProjectFlockKandangId, req.Week); err != nil {
if err := s.ensureUniqueUniformity(c.Context(), 0, req.ProjectFlockKandangId, req.Week, &uniformDate); err != nil {
return nil, err
}
@@ -532,35 +487,8 @@ func (s uniformityService) UpdateOne(c *fiber.Ctx, req *validation.Update, id ui
if req.ProjectFlockKandangId != nil {
targetPFKID = *req.ProjectFlockKandangId
}
if targetPFKID != 0 && targetWeek > 0 {
pfk, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), targetPFKID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Project Flock Kandang not found")
}
return nil, err
}
category := strings.TrimSpace(pfk.ProjectFlock.Category)
if s.ProductionStandardRepo != nil && pfk.ProjectFlock.ProductionStandardId != 0 {
if standard, err := s.ProductionStandardRepo.GetByID(c.Context(), pfk.ProjectFlock.ProductionStandardId, nil); err == nil {
if strings.TrimSpace(standard.ProjectCategory) != "" {
category = standard.ProjectCategory
}
}
}
weekBase := 1
if strings.EqualFold(category, string(utils.ProjectFlockCategoryLaying)) {
weekBase = 18
}
if targetWeek < weekBase {
if weekBase == 18 {
return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 18 for laying projects")
}
return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 1 for growing projects")
}
}
if targetDate != nil {
if err := s.ensureUniqueUniformity(c.Context(), id, targetPFKID, targetWeek); err != nil {
if err := s.ensureUniqueUniformity(c.Context(), id, targetPFKID, targetWeek, targetDate); err != nil {
return nil, err
}
}
@@ -676,7 +604,7 @@ func (s uniformityService) UpdateOne(c *fiber.Ctx, req *validation.Update, id ui
return s.GetOne(c, id)
}
func (s *uniformityService) ensureUniqueUniformity(ctx context.Context, id uint, projectFlockKandangID uint, week int) error {
func (s *uniformityService) ensureUniqueUniformity(ctx context.Context, id uint, projectFlockKandangID uint, week int, uniformDate *time.Time) error {
if projectFlockKandangID == 0 || week == 0 {
return nil
}
@@ -45,7 +45,10 @@ type groupedItem struct {
projectFK *uint
kandangID *uint
totalPrice float64
poNumber string
}
func groupingKey(supplierID uint, date time.Time, warehouseID uint) string {
return fmt.Sprintf("%d:%s:%d", supplierID, utils.FormatDate(date), warehouseID)
}
type expenseBridge struct {
@@ -219,7 +222,6 @@ func (b *expenseBridge) OnItemsReceived(c *fiber.Ctx, purchaseID uint, updates [
purchase, err := b.purchaseRepo.GetByID(ctx, purchaseID, func(db *gorm.DB) *gorm.DB {
return db.
Preload("Items").
Preload("Items.Product").
Preload("Items.Warehouse").
Preload("Items.Warehouse.Kandang")
})
@@ -307,7 +309,7 @@ func (b *expenseBridge) OnItemsReceived(c *fiber.Ctx, purchaseID uint, updates [
// If supplier/date unchanged, update nonstock in place.
if oldSupplier == supplierID && oldDate.Equal(newDate) {
note := purchaseItemDisplayNote(item, payload.PurchaseItemID, purchasePoNumber(purchase))
note := fmt.Sprintf("purchase_item:%d", payload.PurchaseItemID)
if err := b.db.WithContext(ctx).
Model(&entity.ExpenseNonstock{}).
Where("id = ?", link.ExpenseNonstockID).
@@ -338,7 +340,7 @@ func (b *expenseBridge) OnItemsReceived(c *fiber.Ctx, purchaseID uint, updates [
if err != nil {
return err
}
note := purchaseItemDisplayNote(item, payload.PurchaseItemID, purchasePoNumber(purchase))
note := fmt.Sprintf("purchase_item:%d", payload.PurchaseItemID)
if err := b.db.WithContext(ctx).
Model(&entity.Expense{}).
Where("id = ?", link.ExpenseID).
@@ -390,7 +392,6 @@ func (b *expenseBridge) OnItemsReceived(c *fiber.Ctx, purchaseID uint, updates [
projectFK: projectFK,
kandangID: kandangID,
totalPrice: totalPrice,
poNumber: purchasePoNumber(purchase),
}
newNonstockID, err := b.findExpeditionNonstockID(ctx, supplierID)
@@ -409,7 +410,7 @@ func (b *expenseBridge) OnItemsReceived(c *fiber.Ctx, purchaseID uint, updates [
createdNonstockID = noteMap[payload.PurchaseItemID]
}
note := purchaseItemDisplayNote(item, payload.PurchaseItemID, purchasePoNumber(purchase))
note := fmt.Sprintf("purchase_item:%d", payload.PurchaseItemID)
updateBody := map[string]interface{}{
"expense_id": expenseDetail.Id,
"qty": payload.ReceivedQty,
@@ -482,7 +483,6 @@ func (b *expenseBridge) OnItemsReceived(c *fiber.Ctx, purchaseID uint, updates [
projectFK: projectFK,
kandangID: kandangID,
totalPrice: totalPrice,
poNumber: purchasePoNumber(purchase),
})
}
@@ -679,14 +679,6 @@ func (b *expenseBridge) linkExpenseNonstocksToItems(ctx context.Context, detail
Update("expense_nonstock_id", expenseNonstockID).Error; err != nil {
return err
}
note := purchaseItemDisplayNote(gi.item, gi.payload.PurchaseItemID, gi.poNumber)
if err := b.db.WithContext(ctx).
Model(&entity.ExpenseNonstock{}).
Where("id = ?", expenseNonstockID).
Update("notes", note).Error; err != nil {
return err
}
}
return nil
@@ -717,22 +709,3 @@ func mapExpenseNotes(detail *expenseDto.ExpenseDetailDTO) map[uint]uint64 {
}
return result
}
func purchaseItemDisplayNote(item *entity.PurchaseItem, itemID uint, poNumber string) string {
poLabel := "PO"
if strings.TrimSpace(poNumber) != "" {
poLabel = strings.TrimSpace(poNumber)
}
productName := fmt.Sprintf("Item %d", itemID)
if item != nil && item.Product != nil && strings.TrimSpace(item.Product.Name) != "" {
productName = item.Product.Name
}
return fmt.Sprintf("%s (%s)", poLabel, productName)
}
func purchasePoNumber(purchase *entity.Purchase) string {
if purchase == nil || purchase.PoNumber == nil {
return ""
}
return *purchase.PoNumber
}
@@ -20,7 +20,6 @@ import (
projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
rPurchase "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/validations"
rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
@@ -752,9 +751,6 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
if receivedQty > item.SubQty {
return nil, utils.BadRequest(fmt.Sprintf("Received quantity for item %d cannot exceed ordered quantity (%.3f)", payload.PurchaseItemID, item.SubQty))
}
if receivedQty < item.TotalUsed {
return nil, utils.BadRequest(fmt.Sprintf("Received quantity for item %d cannot be lower than used amount (%.3f)", payload.PurchaseItemID, item.TotalUsed))
}
if _, dup := visitedItems[payload.PurchaseItemID]; dup {
return nil, utils.BadRequest(fmt.Sprintf("Duplicate receiving data for item %d", payload.PurchaseItemID))
@@ -831,37 +827,19 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
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 {
repoTx := rPurchase.NewPurchaseRepository(tx)
pwRepoTx := rProductWarehouse.NewProductWarehouseRepository(tx)
stockLogRepoTx := rStockLogs.NewStockLogRepository(tx)
deltas := make(map[uint]float64)
affected := make(map[uint]struct{})
updates := make([]rPurchase.PurchaseReceivingUpdate, 0, len(prepared))
priceUpdates := make([]rPurchase.PurchasePricingUpdate, 0, len(prepared))
totalQtyDeltas := make(map[uint]float64)
fifoAdds := make([]struct {
itemID uint
pwID uint
qty float64
}, 0, len(prepared))
fifoSubs := make([]struct {
itemID uint
pwID uint
qty float64
}, 0, len(prepared))
logEntries := make([]struct {
itemID uint
pwID uint
delta float64
}, 0, len(prepared))
for _, prep := range prepared {
item := prep.item
@@ -882,38 +860,16 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
newPWID = &pwID
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 {
case deltaQty > 0 && newPWID != nil:
if s.FifoSvc != nil {
fifoAdds = append(fifoAdds, struct {
itemID uint
pwID uint
qty float64
}{itemID: item.Id, pwID: *newPWID, qty: deltaQty})
} else {
deltas[*newPWID] += deltaQty
totalQtyDeltas[item.Id] += deltaQty
}
fifoAdds = append(fifoAdds, struct {
itemID uint
pwID uint
qty float64
}{itemID: item.Id, pwID: *newPWID, qty: deltaQty})
case deltaQty < 0 && newPWID != nil:
if s.FifoSvc != nil {
fifoSubs = append(fifoSubs, struct {
itemID uint
pwID uint
qty float64
}{itemID: item.Id, pwID: *newPWID, qty: deltaQty})
affected[*newPWID] = struct{}{}
} else {
deltas[*newPWID] += deltaQty // negative
affected[*newPWID] = struct{}{}
totalQtyDeltas[item.Id] += deltaQty
}
deltas[*newPWID] += deltaQty // negative
affected[*newPWID] = struct{}{}
}
dateCopy := prep.receivedDate
@@ -936,7 +892,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
updates = append(updates, update)
if prep.receivedQty >= 0 {
if item.Price > 0 && prep.receivedQty >= 0 {
priceUpdates = append(priceUpdates, rPurchase.PurchasePricingUpdate{
ItemID: item.Id,
Price: item.Price,
@@ -953,25 +909,16 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
return err
}
if err := pwRepoTx.CleanupEmpty(c.Context(), affected); err != nil {
return err
}
if len(priceUpdates) > 0 {
if err := repoTx.UpdatePricing(c.Context(), purchase.Id, priceUpdates); err != nil {
return err
}
}
if len(totalQtyDeltas) > 0 {
for itemID, delta := range totalQtyDeltas {
if delta == 0 {
continue
}
if err := tx.Model(&entity.PurchaseItem{}).
Where("purchase_id = ? AND id = ?", purchase.Id, itemID).
Update("total_qty", gorm.Expr("COALESCE(total_qty,0) + ?", delta)).Error; err != nil {
return err
}
}
}
// Update due_date based on earliest received date when receiving approved.
if earliestReceived != nil {
due := earliestReceived.AddDate(0, 0, purchase.CreditTerm)
@@ -997,48 +944,6 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
return err
}
}
for _, adj := range fifoSubs {
if adj.pwID == 0 || adj.qty >= 0 {
continue
}
if err := s.FifoSvc.AdjustStockableQuantity(c.Context(), commonSvc.StockAdjustRequest{
StockableKey: fifo.StockableKeyPurchaseItems,
StockableID: adj.itemID,
ProductWarehouseID: adj.pwID,
Quantity: adj.qty,
Tx: tx,
}); err != nil {
return err
}
}
}
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,
}
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 err := pwRepoTx.CleanupEmpty(c.Context(), affected); err != nil {
return err
}
}
return nil
@@ -1466,6 +1371,10 @@ func (s *purchaseService) buildStaffAdjustmentPayload(
qtyCopy := effectiveQty
update.Quantity = &qtyCopy
}
if syncReceiving {
qtyCopy := effectiveQty
update.TotalQty = &qtyCopy
}
updates = append(updates, update)
delete(requestItems, item.Id)
@@ -40,22 +40,98 @@ type RepportMarketingItemDTO struct {
type Summary struct {
TotalQty int `json:"total_qty"`
TotalWeightKg float64 `json:"total_weight_kg"`
AverageWeightKg float64 `json:"average_weight_kg"`
AverageSalesPrice float64 `json:"average_sales_price"`
TotalSalesAmount int64 `json:"total_sales_amount"`
TotalHppAmount int64 `json:"total_hpp_amount"`
TotalHppPricePerKg float64 `json:"total_hpp_price_per_kg"`
}
type RepportMarketingResponseDTO struct {
Items []RepportMarketingItemDTO `json:"items"`
Total *Summary `json:"total,omitempty"`
}
type ProductRelationDTOFixed struct {
productDTO.ProductRelationDTO
ProductPrice float64 `json:"product_price"`
SellingPrice *float64 `json:"selling_price,omitempty"`
}
func ToMarketingReportItems(mdps []entity.MarketingDeliveryProduct, hppMap map[uint]float64, agingMap map[int]int) []RepportMarketingItemDTO {
items := make([]RepportMarketingItemDTO, 0, len(mdps))
func ToRepportMarketingItemDTO(mdp entity.MarketingDeliveryProduct, hppPricePerKg float64, category string) RepportMarketingItemDTO {
soDate := time.Time{}
agingDays := 0
if mdp.MarketingProduct.Marketing.SoDate.Year() > 1 {
soDate = mdp.MarketingProduct.Marketing.SoDate
agingDays = int(time.Since(soDate).Hours() / 24)
}
realizationDate := time.Time{}
if mdp.DeliveryDate != nil {
realizationDate = *mdp.DeliveryDate
}
doNumber := marketingDTO.GenerateDeliveryOrderNumber(mdp.MarketingProduct.Marketing.SoNumber, mdp.DeliveryDate, mdp.MarketingProduct.ProductWarehouse.WarehouseId)
totalWeightKg := mdp.UsageQty * mdp.AvgWeight
salesAmount := totalWeightKg * mdp.UnitPrice
var hpp float64
var hppAmount float64
if isProductEligibleForHpp(mdp, category) {
hpp = hppPricePerKg
hppAmount = totalWeightKg * hppPricePerKg
}
item := RepportMarketingItemDTO{
ID: int(mdp.Id),
SoDate: soDate,
RealizationDate: realizationDate,
AgingDays: agingDays,
DoNumber: doNumber,
MarketingType: getMarketingType(mdp),
Qty: mdp.UsageQty,
AverageWeightKg: mdp.AvgWeight,
TotalWeightKg: totalWeightKg,
SalesPricePerKg: mdp.UnitPrice,
HppPricePerKg: hpp,
SalesAmount: salesAmount,
HppAmount: hppAmount,
}
if mdp.MarketingProduct.ProductWarehouse.WarehouseId != 0 {
mapped := warehouseDTO.ToWarehouseRelationDTO(mdp.MarketingProduct.ProductWarehouse.Warehouse)
item.Warehouse = &mapped
}
if mdp.MarketingProduct.Marketing.CustomerId != 0 {
mapped := customerDTO.ToCustomerRelationDTO(mdp.MarketingProduct.Marketing.Customer)
item.Customer = &mapped
}
if mdp.MarketingProduct.Marketing.SalesPersonId != 0 {
mapped := userDTO.ToUserRelationDTO(mdp.MarketingProduct.Marketing.SalesPerson)
item.Sales = &mapped
}
item.VehicleNumber = mdp.VehicleNumber
if mdp.MarketingProduct.ProductWarehouse.ProductId != 0 {
mapped := productDTO.ToProductRelationDTO(mdp.MarketingProduct.ProductWarehouse.Product)
item.Product = newProductRelationDTOFixedPtr(&mapped)
}
return item
}
func ToRepportMarketingItemDTOs(mdps []entity.MarketingDeliveryProduct, hppPricePerKg float64, category string) []RepportMarketingItemDTO {
items := make([]RepportMarketingItemDTO, 0, len(mdps))
for _, mdp := range mdps {
items = append(items, ToRepportMarketingItemDTO(mdp, hppPricePerKg, category))
}
return items
}
func ToRepportMarketingItemDTOsWithHppMap(mdps []entity.MarketingDeliveryProduct, hppMap map[uint]float64) []RepportMarketingItemDTO {
items := make([]RepportMarketingItemDTO, 0, len(mdps))
for _, mdp := range mdps {
hppPerKg := float64(0)
category := ""
@@ -66,113 +142,101 @@ func ToMarketingReportItems(mdps []entity.MarketingDeliveryProduct, hppMap map[u
category = projectFlockKandang.ProjectFlock.Category
}
soDate := time.Time{}
agingDays := 0
if mdp.MarketingProduct.Marketing.SoDate.Year() > 1 {
soDate = mdp.MarketingProduct.Marketing.SoDate
if ag, exists := agingMap[int(mdp.Id)]; exists {
agingDays = ag
} else {
agingDays = int(time.Since(soDate).Hours() / 24)
}
}
realizationDate := time.Time{}
if mdp.DeliveryDate != nil {
realizationDate = *mdp.DeliveryDate
}
totalWeightKg := mdp.UsageQty * mdp.AvgWeight
salesAmount := totalWeightKg * mdp.UnitPrice
var hpp float64
var hppAmount float64
var hasAyam, hasTelur, hasTrading bool
for _, flag := range mdp.MarketingProduct.ProductWarehouse.Product.Flags {
ft := utils.FlagType(flag.Name)
if ft == utils.FlagAyamAfkir || ft == utils.FlagAyamCulling || ft == utils.FlagAyamMati ||
ft == utils.FlagDOC || ft == utils.FlagPullet || ft == utils.FlagLayer {
hasAyam = true
}
if ft == utils.FlagTelur || ft == utils.FlagTelurUtuh || ft == utils.FlagTelurPecah ||
ft == utils.FlagTelurPutih || ft == utils.FlagTelurRetak {
hasTelur = true
}
if ft == utils.FlagOVK || ft == utils.FlagObat || ft == utils.FlagVitamin || ft == utils.FlagKimia ||
ft == utils.FlagPakan || ft == utils.FlagPreStarter || ft == utils.FlagStarter || ft == utils.FlagFinisher {
hasTrading = true
}
}
marketingType := "trading"
if hasTrading {
marketingType = "trading"
} else if hasTelur {
marketingType = "telur"
} else if hasAyam {
marketingType = "ayam"
}
eligibleForHpp := false
if utils.ProjectFlockCategory(category) == utils.ProjectFlockCategoryGrowing {
eligibleForHpp = hasAyam
} else {
eligibleForHpp = hasAyam || hasTelur
}
if eligibleForHpp {
hpp = hppPerKg
hppAmount = totalWeightKg * hppPerKg
}
item := RepportMarketingItemDTO{
ID: int(mdp.Id),
SoDate: soDate,
RealizationDate: realizationDate,
AgingDays: agingDays,
DoNumber: marketingDTO.GenerateDeliveryOrderNumber(mdp.MarketingProduct.Marketing.SoNumber, mdp.DeliveryDate, mdp.MarketingProduct.ProductWarehouse.WarehouseId),
MarketingType: marketingType,
Qty: mdp.UsageQty,
AverageWeightKg: mdp.AvgWeight,
TotalWeightKg: totalWeightKg,
SalesPricePerKg: mdp.UnitPrice,
HppPricePerKg: hpp,
SalesAmount: salesAmount,
HppAmount: hppAmount,
VehicleNumber: mdp.VehicleNumber,
}
if mdp.MarketingProduct.ProductWarehouse.WarehouseId != 0 {
mapped := warehouseDTO.ToWarehouseRelationDTO(mdp.MarketingProduct.ProductWarehouse.Warehouse)
item.Warehouse = &mapped
}
if mdp.MarketingProduct.Marketing.CustomerId != 0 {
mapped := customerDTO.ToCustomerRelationDTO(mdp.MarketingProduct.Marketing.Customer)
item.Customer = &mapped
}
if mdp.MarketingProduct.Marketing.SalesPersonId != 0 {
mapped := userDTO.ToUserRelationDTO(mdp.MarketingProduct.Marketing.SalesPerson)
item.Sales = &mapped
}
if mdp.MarketingProduct.ProductWarehouse.ProductId != 0 {
mapped := productDTO.ToProductRelationDTO(mdp.MarketingProduct.ProductWarehouse.Product)
item.Product = newProductRelationDTOFixedPtr(&mapped)
}
item := ToRepportMarketingItemDTO(mdp, hppPerKg, category)
items = append(items, item)
}
return items
}
func getMarketingType(mdp entity.MarketingDeliveryProduct) string {
hasAyam, hasTelur, hasTrading := checkProductFlags(mdp.MarketingProduct.ProductWarehouse.Product.Flags)
if hasAyam {
return "ayam"
}
if hasTelur {
return "telur"
}
if hasTrading {
return "trading"
}
return "trading" // default to trading if no flags found
}
func checkProductFlags(flags []entity.Flag) (hasAyam, hasTelur, hasTrading bool) {
if len(flags) == 0 {
return false, false, false
}
for _, flag := range flags {
ft := utils.FlagType(flag.Name)
if ft == utils.FlagAyamAfkir || ft == utils.FlagAyamCulling || ft == utils.FlagAyamMati ||
ft == utils.FlagDOC || ft == utils.FlagPullet || ft == utils.FlagLayer {
hasAyam = true
}
if ft == utils.FlagTelur || ft == utils.FlagTelurUtuh || ft == utils.FlagTelurPecah ||
ft == utils.FlagTelurPutih || ft == utils.FlagTelurRetak {
hasTelur = true
}
if ft == utils.FlagOVK || ft == utils.FlagObat || ft == utils.FlagVitamin || ft == utils.FlagKimia ||
ft == utils.FlagPakan || ft == utils.FlagPreStarter || ft == utils.FlagStarter || ft == utils.FlagFinisher {
hasTrading = true
}
}
return hasAyam, hasTelur, hasTrading
}
func isProductEligibleForHpp(mdp entity.MarketingDeliveryProduct, category string) bool {
hasAyam, hasTelur, _ := checkProductFlags(mdp.MarketingProduct.ProductWarehouse.Product.Flags)
if utils.ProjectFlockCategory(category) == utils.ProjectFlockCategoryGrowing {
return hasAyam
}
return hasAyam || hasTelur
}
func ToSummary(mdps []entity.MarketingDeliveryProduct, hppPricePerKg float64, category string) *Summary {
if len(mdps) == 0 {
return nil
}
totalQty := 0
totalWeightKg := 0.0
totalEligibleWeightKg := 0.0
totalSalesAmount := int64(0)
totalHppAmount := int64(0)
for _, mdp := range mdps {
calculatedTotalWeight := mdp.UsageQty * mdp.AvgWeight
totalQty += int(mdp.UsageQty)
totalWeightKg += calculatedTotalWeight
totalSalesAmount += int64(calculatedTotalWeight * mdp.UnitPrice)
if isProductEligibleForHpp(mdp, category) {
totalEligibleWeightKg += calculatedTotalWeight
totalHppAmount += int64(calculatedTotalWeight * hppPricePerKg)
}
}
totalHppPricePerKg := float64(0)
if totalEligibleWeightKg > 0 {
totalHppPricePerKg = float64(totalHppAmount) / totalEligibleWeightKg
}
return &Summary{
TotalQty: totalQty,
TotalWeightKg: totalWeightKg,
TotalSalesAmount: totalSalesAmount,
TotalHppAmount: totalHppAmount,
TotalHppPricePerKg: totalHppPricePerKg,
}
}
func ToSummaryFromDTOItems(items []RepportMarketingItemDTO) *Summary {
if len(items) == 0 {
return nil
@@ -180,8 +244,6 @@ func ToSummaryFromDTOItems(items []RepportMarketingItemDTO) *Summary {
totalQty := 0
totalWeightKg := 0.0
avgSalesPrice := 0.0
avgWeightKg := 0.0
totalSalesAmount := int64(0)
totalHppAmount := int64(0)
@@ -193,27 +255,29 @@ func ToSummaryFromDTOItems(items []RepportMarketingItemDTO) *Summary {
}
totalHppPricePerKg := float64(0)
if totalWeightKg > 0 {
totalHppPricePerKg = float64(totalHppAmount) / totalWeightKg
avgSalesPrice = float64(totalSalesAmount) / totalWeightKg
}
if totalQty > 0 {
avgWeightKg = totalWeightKg / float64(totalQty)
}
return &Summary{
TotalQty: totalQty,
TotalWeightKg: totalWeightKg,
AverageWeightKg: avgWeightKg,
AverageSalesPrice: avgSalesPrice,
TotalSalesAmount: totalSalesAmount,
TotalHppAmount: totalHppAmount,
TotalHppPricePerKg: totalHppPricePerKg,
}
}
func ToRepportMarketingResponseDTO(mdps []entity.MarketingDeliveryProduct, hppPricePerKg float64, category string) RepportMarketingResponseDTO {
items := ToRepportMarketingItemDTOs(mdps, hppPricePerKg, category)
total := ToSummary(mdps, hppPricePerKg, category)
return RepportMarketingResponseDTO{
Items: items,
Total: total,
}
}
func newProductRelationDTOFixedPtr(original *productDTO.ProductRelationDTO) *ProductRelationDTOFixed {
if original == nil {
return nil
@@ -27,12 +27,12 @@ type PurchaseSupplierRowDTO struct {
}
type PurchaseSupplierSummaryDTO struct {
TotalQty float64 `json:"total_qty"`
TotalPurchaseValue float64 `json:"total_purchase_value"`
TotalTransportValue float64 `json:"total_transport_value"`
TotalAmount float64 `json:"total_amount"`
TotalUnitPrice float64 `json:"total_unit_price"`
TotalTransportUnitPrice float64 `json:"total_transport_unit_price"`
TotalQty float64 `json:"total_qty"`
TotalPurchaseValue float64 `json:"total_purchase_value"`
TotalTransportValue float64 `json:"total_transport_value"`
TotalAmount float64 `json:"total_amount"`
TotalUnitPrice float64 `json:"total_unit_price"`
TotalTransportUnitPrice float64 `json:"total_transport_unit_price"`
}
type PurchaseSupplierDTO struct {
@@ -122,6 +122,11 @@ func ToPurchaseSupplierDTO(supplier entity.Supplier, items []entity.PurchaseItem
rows := make([]PurchaseSupplierRowDTO, 0, len(items))
summary := PurchaseSupplierSummaryDTO{}
var unitPriceSum float64
var unitPriceCount int
var transportUnitPriceSum float64
var transportUnitPriceCount int
for i := range items {
row := ToPurchaseSupplierRowDTO(&items[i])
rows = append(rows, row)
@@ -131,16 +136,19 @@ func ToPurchaseSupplierDTO(supplier entity.Supplier, items []entity.PurchaseItem
summary.TotalTransportValue += row.TransportValue
summary.TotalAmount += row.TotalAmount
unitPriceSum += row.UnitPrice
unitPriceCount++
transportUnitPriceSum += row.TransportUnitPrice
transportUnitPriceCount++
}
if summary.TotalQty > 0 {
avg := summary.TotalPurchaseValue / summary.TotalQty
summary.TotalUnitPrice = math.Round(avg)
if unitPriceCount > 0 {
summary.TotalUnitPrice = math.Round(unitPriceSum / float64(unitPriceCount))
}
if summary.TotalQty > 0 {
avg := summary.TotalTransportValue / summary.TotalQty
summary.TotalTransportUnitPrice = math.Round(avg)
if transportUnitPriceCount > 0 {
summary.TotalTransportUnitPrice = math.Round(transportUnitPriceSum / float64(transportUnitPriceCount))
}
return PurchaseSupplierDTO{
+1 -3
View File
@@ -32,7 +32,6 @@ func (RepportModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *
chickinRepository := chickinRepo.NewChickinRepository(db)
recordingRepository := recordingRepo.NewRecordingRepository(db)
approvalRepository := commonRepo.NewApprovalRepository(db)
hppCostRepository := commonRepo.NewHppCostRepository(db)
purchaseSupplierRepository := repportRepo.NewPurchaseSupplierRepository(db)
debtSupplierRepository := repportRepo.NewDebtSupplierRepository(db)
hppPerKandangRepository := repportRepo.NewHppPerKandangRepository(db)
@@ -44,8 +43,7 @@ func (RepportModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *
userRepository := rUser.NewUserRepository(db)
approvalSvc := approvalService.NewApprovalService(approvalRepository)
hppSvc := approvalService.NewHppService(hppCostRepository)
repportService := sRepport.NewRepportService(db, validate, expenseRealizationRepository, marketingDeliveryProductRepository, purchaseRepository, chickinRepository, recordingRepository, approvalSvc, hppSvc, purchaseSupplierRepository, debtSupplierRepository, hppPerKandangRepository, productionResultRepository, customerPaymentRepository, customerRepository, standardGrowthDetailRepository, productionStandardDetailRepository)
repportService := sRepport.NewRepportService(db, validate, expenseRealizationRepository, marketingDeliveryProductRepository, purchaseRepository, chickinRepository, recordingRepository, approvalSvc, purchaseSupplierRepository, debtSupplierRepository, hppPerKandangRepository, productionResultRepository, customerPaymentRepository, customerRepository, standardGrowthDetailRepository, productionStandardDetailRepository)
userService := sUser.NewUserService(userRepository, validate)
RepportRoutes(router, userService, repportService)
@@ -4,7 +4,6 @@ import (
"context"
"fmt"
"strings"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/validations"
@@ -18,8 +17,6 @@ type DebtSupplierRepository interface {
GetPurchasesBySuppliers(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) ([]entity.Purchase, error)
GetPaymentsBySuppliers(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) ([]entity.Payment, error)
GetPaymentTotalsByReferences(ctx context.Context, supplierIDs []uint, references []string) (map[string]float64, error)
GetPaymentSummariesByReferences(ctx context.Context, supplierIDs []uint, references []string) (map[string]PaymentReferenceSummary, error)
GetInitialBalanceTotals(ctx context.Context, supplierIDs []uint) (map[uint]float64, error)
GetPurchaseTotalsBeforeDate(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) (map[uint]float64, error)
GetPaymentTotalsBeforeDate(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) (map[uint]float64, error)
}
@@ -28,30 +25,10 @@ type debtSupplierRepositoryImpl struct {
db *gorm.DB
}
type PaymentReferenceSummary struct {
Total float64
LatestPaymentDate time.Time
}
func NewDebtSupplierRepository(db *gorm.DB) DebtSupplierRepository {
return &debtSupplierRepositoryImpl{db: db}
}
func (r *debtSupplierRepositoryImpl) latestPurchaseApproval(ctx context.Context) *gorm.DB {
return r.db.WithContext(ctx).
Table("approvals AS a").
Select("a.approvable_id, a.step_number, a.action").
Joins(`
JOIN (
SELECT approvable_id, MAX(action_at) AS latest_action_at
FROM approvals
WHERE approvable_type = ?
GROUP BY approvable_id
) AS la ON la.approvable_id = a.approvable_id AND la.latest_action_at = a.action_at`,
string(utils.ApprovalWorkflowPurchase),
)
}
func resolveDebtSupplierDateColumn(filterBy string) string {
switch strings.ToLower(strings.TrimSpace(filterBy)) {
case "po_date":
@@ -69,11 +46,7 @@ func (r *debtSupplierRepositoryImpl) baseSupplierQuery(ctx context.Context, filt
db := r.db.WithContext(ctx).
Model(&entity.Supplier{}).
Joins("JOIN purchases ON purchases.supplier_id = suppliers.id").
Joins("JOIN purchase_items ON purchase_items.purchase_id = purchases.id").
Joins("JOIN (?) AS la ON la.approvable_id = purchases.id", r.latestPurchaseApproval(ctx)).
Where("la.step_number >= ?", uint16(utils.PurchaseStepReceiving)).
Where("(la.action IS NULL OR la.action != ?)", string(entity.ApprovalActionRejected)).
Where("purchase_items.received_date IS NOT NULL")
Joins("JOIN purchase_items ON purchase_items.purchase_id = purchases.id")
if len(filters.SupplierIDs) > 0 {
db = db.Where("suppliers.id IN ?", filters.SupplierIDs)
@@ -194,8 +167,7 @@ func (r *debtSupplierRepositoryImpl) GetPaymentsBySuppliers(ctx context.Context,
Model(&entity.Payment{}).
Where("party_type = ?", string(utils.PaymentPartySupplier)).
Where("direction = ?", "OUT").
Where("party_id IN ?", supplierIDs).
Where("transaction_type <> ?", string(utils.TransactionTypeSaldoAwal))
Where("party_id IN ?", supplierIDs)
if strings.TrimSpace(filters.StartDate) != "" {
if dateFrom, err := utils.ParseDateString(filters.StartDate); err == nil {
@@ -226,11 +198,7 @@ func (r *debtSupplierRepositoryImpl) getPurchaseIDs(ctx context.Context, supplie
Table("purchases").
Select("DISTINCT purchases.id").
Joins("JOIN purchase_items ON purchase_items.purchase_id = purchases.id").
Joins("JOIN (?) AS la ON la.approvable_id = purchases.id", r.latestPurchaseApproval(ctx)).
Where("purchases.supplier_id IN ?", supplierIDs).
Where("la.step_number >= ?", uint16(utils.PurchaseStepReceiving)).
Where("(la.action IS NULL OR la.action != ?)", string(entity.ApprovalActionRejected)).
Where("purchase_items.received_date IS NOT NULL")
Where("purchases.supplier_id IN ?", supplierIDs)
if filters.StartDate != "" {
if dateFrom, err := utils.ParseDateString(filters.StartDate); err == nil {
@@ -270,7 +238,6 @@ func (r *debtSupplierRepositoryImpl) GetPaymentTotalsByReferences(ctx context.Co
Where("direction = ?", "OUT").
Where("party_id IN ?", supplierIDs).
Where("reference_number IN ?", references).
Where("transaction_type <> ?", string(utils.TransactionTypeSaldoAwal)).
Group("reference_number").
Scan(&rows).Error; err != nil {
return nil, err
@@ -287,75 +254,6 @@ func (r *debtSupplierRepositoryImpl) GetPaymentTotalsByReferences(ctx context.Co
return result, nil
}
func (r *debtSupplierRepositoryImpl) GetPaymentSummariesByReferences(ctx context.Context, supplierIDs []uint, references []string) (map[string]PaymentReferenceSummary, error) {
if len(supplierIDs) == 0 || len(references) == 0 {
return map[string]PaymentReferenceSummary{}, nil
}
type paymentRow struct {
ReferenceNumber *string `gorm:"column:reference_number"`
Total float64 `gorm:"column:total"`
LatestPaymentDate time.Time `gorm:"column:latest_payment_date"`
}
rows := make([]paymentRow, 0)
if err := r.db.WithContext(ctx).
Model(&entity.Payment{}).
Select("reference_number, SUM(nominal) AS total, MAX(payment_date) AS latest_payment_date").
Where("party_type = ?", string(utils.PaymentPartySupplier)).
Where("direction = ?", "OUT").
Where("party_id IN ?", supplierIDs).
Where("reference_number IN ?", references).
Where("transaction_type <> ?", string(utils.TransactionTypeSaldoAwal)).
Group("reference_number").
Scan(&rows).Error; err != nil {
return nil, err
}
result := make(map[string]PaymentReferenceSummary, len(rows))
for _, row := range rows {
if row.ReferenceNumber == nil || strings.TrimSpace(*row.ReferenceNumber) == "" {
continue
}
result[*row.ReferenceNumber] = PaymentReferenceSummary{
Total: row.Total,
LatestPaymentDate: row.LatestPaymentDate,
}
}
return result, nil
}
func (r *debtSupplierRepositoryImpl) GetInitialBalanceTotals(ctx context.Context, supplierIDs []uint) (map[uint]float64, error) {
if len(supplierIDs) == 0 {
return map[uint]float64{}, nil
}
type balanceRow struct {
SupplierID uint `gorm:"column:supplier_id"`
Total float64 `gorm:"column:total"`
}
rows := make([]balanceRow, 0)
if err := r.db.WithContext(ctx).
Model(&entity.Payment{}).
Select("party_id AS supplier_id, SUM(nominal) AS total").
Where("party_type = ?", string(utils.PaymentPartySupplier)).
Where("party_id IN ?", supplierIDs).
Where("transaction_type = ?", string(utils.TransactionTypeSaldoAwal)).
Group("party_id").
Scan(&rows).Error; err != nil {
return nil, err
}
result := make(map[uint]float64, len(rows))
for _, row := range rows {
result[row.SupplierID] = row.Total
}
return result, nil
}
func (r *debtSupplierRepositoryImpl) GetPurchaseTotalsBeforeDate(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) (map[uint]float64, error) {
if len(supplierIDs) == 0 || strings.TrimSpace(filters.StartDate) == "" {
return map[uint]float64{}, nil
@@ -378,11 +276,7 @@ func (r *debtSupplierRepositoryImpl) GetPurchaseTotalsBeforeDate(ctx context.Con
Table("purchases").
Select("purchases.supplier_id AS supplier_id, SUM(purchase_items.total_price) AS total").
Joins("JOIN purchase_items ON purchase_items.purchase_id = purchases.id").
Joins("JOIN (?) AS la ON la.approvable_id = purchases.id", r.latestPurchaseApproval(ctx)).
Where("purchases.supplier_id IN ?", supplierIDs).
Where("la.step_number >= ?", uint16(utils.PurchaseStepReceiving)).
Where("(la.action IS NULL OR la.action != ?)", string(entity.ApprovalActionRejected)).
Where("purchase_items.received_date IS NOT NULL").
Where(fmt.Sprintf("DATE(%s) < ?", dateColumn), dateFrom).
Group("purchases.supplier_id").
Scan(&rows).Error; err != nil {
@@ -419,7 +313,6 @@ func (r *debtSupplierRepositoryImpl) GetPaymentTotalsBeforeDate(ctx context.Cont
Where("party_type = ?", string(utils.PaymentPartySupplier)).
Where("direction = ?", "OUT").
Where("party_id IN ?", supplierIDs).
Where("transaction_type <> ?", string(utils.TransactionTypeSaldoAwal)).
Where("DATE(payment_date) < ?", dateFrom).
Group("party_id").
Scan(&rows).Error; err != nil {
@@ -6,6 +6,7 @@ import (
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"gorm.io/gorm"
)
@@ -23,10 +24,9 @@ type HppPerKandangRow struct {
// RemainingChickenBirds float64
// RemainingChickenWeight float64
EggProductionWeightKgRemaining float64
// AverageWeightEggPerPiece float64
// EggProductionPiecesRemaining float64
// EggProductionTotalWeightKg float64
// EggProductionTotalPieces float64
EggProductionPiecesRemaining float64
EggProductionTotalWeightKg float64
EggProductionTotalPieces float64
}
type HppPerKandangCostRow struct {
@@ -50,7 +50,7 @@ type HppPerKandangSupplierRow struct {
type HppPerKandangRepository interface {
GetRowsByPeriod(ctx context.Context, start, end time.Time, areaIDs, locationIDs, kandangIDs []int64) ([]HppPerKandangRow, error)
GetFeedOvkDocCostByPeriod(ctx context.Context, start, end time.Time, projectFlockKandangIDs []uint) ([]HppPerKandangCostRow, []HppPerKandangSupplierRow, error)
GetWeightRemainingByProjectFlockKandangIDs(ctx context.Context, start, end time.Time, projectFlockKandangIDs []uint) (map[uint]HppPerKandangRow, error)
GetEggProductionByProjectFlockKandangIDs(ctx context.Context, start, end time.Time, projectFlockKandangIDs []uint) (map[uint]HppPerKandangRow, error)
}
type hppPerKandangRepository struct {
@@ -133,6 +133,60 @@ func (r *hppPerKandangRepository) GetRowsByPeriod(ctx context.Context, start, en
func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context, start, end time.Time, projectFlockKandangIDs []uint) ([]HppPerKandangCostRow, []HppPerKandangSupplierRow, error) {
var rows []HppPerKandangCostRow
purchaseStockableKey := fifo.StockableKeyPurchaseItems.String()
transferStockableKey := fifo.StockableKeyStockTransferIn.String()
latestApproval := r.db.WithContext(ctx).
Table("approvals AS a").
Select("a.approvable_id, a.action").
Joins(`
JOIN (
SELECT approvable_id, MAX(action_at) AS latest_action_at
FROM approvals
WHERE approvable_type = ?
GROUP BY approvable_id
) AS la ON la.approvable_id = a.approvable_id AND la.latest_action_at = a.action_at`,
string(utils.ApprovalWorkflowRecording),
)
query := r.db.WithContext(ctx).
Table("recordings AS r").
Select(`
pfk.id AS project_flock_kandang_id,
COALESCE(SUM(CASE
WHEN f.name = ? THEN COALESCE(sa.qty, 0) * COALESCE(pi.price, 0)
WHEN sa.stockable_type = ? AND tf.name = ? THEN COALESCE(std.total_qty, 0) * COALESCE(tpi.price, 0)
ELSE 0
END), 0) AS feed_cost,
COALESCE(SUM(CASE
WHEN f.name = ? THEN COALESCE(sa.qty, 0) * COALESCE(pi.price, 0)
WHEN sa.stockable_type = ? AND tf.name = ? THEN COALESCE(std.total_qty, 0) * COALESCE(tpi.price, 0)
ELSE 0
END), 0) AS ovk_cost`,
utils.FlagPakan, transferStockableKey, utils.FlagPakan,
utils.FlagOVK, transferStockableKey, utils.FlagOVK).
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id").
Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id").
Joins("LEFT JOIN (?) AS la ON la.approvable_id = r.id", latestApproval).
Joins("LEFT JOIN recording_stocks AS rs ON rs.recording_id = r.id").
Joins("LEFT JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.status = ?", fifo.UsableKeyRecordingStock.String(), entity.StockAllocationStatusActive).
Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", purchaseStockableKey).
Joins("LEFT JOIN stock_transfer_details AS std ON std.id = sa.stockable_id AND sa.stockable_type = ?", transferStockableKey).
Joins("LEFT JOIN stock_transfers AS st ON st.id = std.stock_transfer_id").
Joins("LEFT JOIN purchase_items AS tpi ON tpi.product_id = std.product_id AND tpi.warehouse_id = st.from_warehouse_id").
Joins("LEFT JOIN flags AS f ON f.flagable_id = pi.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct).
Joins("LEFT JOIN flags AS tf ON tf.flagable_id = std.product_id AND tf.flagable_type = ?", entity.FlagableTypeProduct).
Where("r.project_flock_kandangs_id IN ?", projectFlockKandangIDs).
Where("r.record_datetime < ?", end).
Where("r.deleted_at IS NULL").
Where("(la.action IS NULL OR la.action != ?)", string(entity.ApprovalActionRejected))
query = query.Group("pfk.id").Order("pfk.id ASC")
if err := query.Scan(&rows).Error; err != nil {
return nil, nil, err
}
docRows := make([]struct {
ProjectFlockKandangID uint
DocCost float64
@@ -208,10 +262,130 @@ func (r *hppPerKandangRepository) GetFeedOvkDocCostByPeriod(ctx context.Context,
}
}
return rows, docSuppliers, nil
budgetRows := make([]struct {
ProjectFlockKandangID uint
BudgetCost float64
}, 0)
pfkUsageSub := r.db.
Table("project_chickins AS pc").
Select(`
pc.project_flock_kandang_id,
SUM(pc.usage_qty) AS kandang_usage_qty`).
Group("pc.project_flock_kandang_id")
projectUsageSub := r.db.
Table("project_chickins AS pc").
Select(`
pfk.project_flock_id,
SUM(pc.usage_qty) AS project_usage_qty`).
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = pc.project_flock_kandang_id").
Group("pfk.project_flock_id")
budgetQuery := r.db.WithContext(ctx).
Table("project_flock_kandangs AS pfk").
Select(`
pfk.id AS project_flock_kandang_id,
COALESCE(SUM((pb.qty * pb.price) * COALESCE(k_usage.kandang_usage_qty, 0) / NULLIF(p_usage.project_usage_qty, 0)), 0) AS budget_cost`).
Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id").
Joins("JOIN locations AS loc ON loc.id = k.location_id").
Joins("JOIN project_budgets AS pb ON pb.project_flock_id = pfk.project_flock_id").
Joins("LEFT JOIN (?) AS k_usage ON k_usage.project_flock_kandang_id = pfk.id", pfkUsageSub).
Joins("LEFT JOIN (?) AS p_usage ON p_usage.project_flock_id = pfk.project_flock_id", projectUsageSub).
Where("pfk.id IN (?)", projectFlockKandangIDs).
Group("pfk.id")
// budgetQuery = applyLocationFilters(budgetQuery, areaIDs, locationIDs, kandangIDs)
if err := budgetQuery.Scan(&budgetRows).Error; err != nil {
return nil, nil, err
}
for _, budget := range budgetRows {
entry, ok := costMap[budget.ProjectFlockKandangID]
if !ok {
rows = append(rows, HppPerKandangCostRow{
ProjectFlockKandangID: budget.ProjectFlockKandangID,
})
entry = &rows[len(rows)-1]
costMap[budget.ProjectFlockKandangID] = entry
}
entry.BudgetCost += budget.BudgetCost
}
expenseRows := make([]struct {
ProjectFlockKandangID uint
ExpenseCost float64
}, 0)
expenseQuery := r.db.WithContext(ctx).
Table("project_flock_kandangs AS pfk").
Select(`
pfk.id AS project_flock_kandang_id,
COALESCE(SUM(er.qty * er.price), 0) AS expense_cost`).
Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id").
Joins("JOIN locations AS loc ON loc.id = k.location_id").
Joins("JOIN expense_nonstocks AS en ON en.project_flock_kandang_id = pfk.id").
Joins("JOIN expense_realizations AS er ON er.expense_nonstock_id = en.id").
Where("pfk.id IN (?)", projectFlockKandangIDs).
Group("pfk.id")
// expenseQuery = applyLocationFilters(expenseQuery, areaIDs, locationIDs, kandangIDs)
if err := expenseQuery.Scan(&expenseRows).Error; err != nil {
return nil, nil, err
}
for _, exp := range expenseRows {
entry, ok := costMap[exp.ProjectFlockKandangID]
if !ok {
rows = append(rows, HppPerKandangCostRow{
ProjectFlockKandangID: exp.ProjectFlockKandangID,
})
entry = &rows[len(rows)-1]
costMap[exp.ProjectFlockKandangID] = entry
}
entry.ExpenseCost += exp.ExpenseCost
}
feedSuppliers := make([]HppPerKandangSupplierRow, 0)
feedQuery := r.db.WithContext(ctx).
Table("recordings AS r").
Select("DISTINCT pfk.id AS project_flock_kandang_id, s.id AS supplier_id, s.name AS supplier_name, s.alias AS supplier_alias").
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_id").
Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id").
Joins("JOIN locations AS loc ON loc.id = k.location_id").
Joins("LEFT JOIN recording_stocks AS rs ON rs.recording_id = r.id").
Joins("LEFT JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.status = ?", fifo.UsableKeyRecordingStock.String(), entity.StockAllocationStatusActive).
Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", purchaseStockableKey).
Joins("LEFT JOIN purchases AS pur ON pur.id = pi.purchase_id").
Joins("LEFT JOIN suppliers AS s ON s.id = pur.supplier_id").
Joins("LEFT JOIN flags AS f ON f.flagable_id = pi.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct).
Where("f.name IN ?", []utils.FlagType{utils.FlagPakan, utils.FlagOVK}).
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
Where("r.record_datetime < ?", end).
Where("r.deleted_at IS NULL")
// feedQuery = applyLocationFilters(feedQuery, areaIDs, locationIDs, kandangIDs)
if err := feedQuery.Scan(&feedSuppliers).Error; err != nil {
return nil, nil, err
}
for i := range feedSuppliers {
if _, exists := costMap[feedSuppliers[i].ProjectFlockKandangID]; !exists {
rows = append(rows, HppPerKandangCostRow{
ProjectFlockKandangID: feedSuppliers[i].ProjectFlockKandangID,
})
costMap[feedSuppliers[i].ProjectFlockKandangID] = &rows[len(rows)-1]
}
feedSuppliers[i].Category = "FEED"
}
supplierRows := append(docSuppliers, feedSuppliers...)
return rows, supplierRows, nil
}
func (r *hppPerKandangRepository) GetWeightRemainingByProjectFlockKandangIDs(ctx context.Context, start, end time.Time, projectFlockKandangIDs []uint) (map[uint]HppPerKandangRow, error) {
func (r *hppPerKandangRepository) GetEggProductionByProjectFlockKandangIDs(ctx context.Context, start, end time.Time, projectFlockKandangIDs []uint) (map[uint]HppPerKandangRow, error) {
if len(projectFlockKandangIDs) == 0 {
return map[uint]HppPerKandangRow{}, nil
}
@@ -230,11 +404,11 @@ func (r *hppPerKandangRepository) GetWeightRemainingByProjectFlockKandangIDs(ctx
)
type eggRow struct {
ProjectFlockKandangID uint
AverageWeightEggPerPiece float64
// EggProductionPiecesRemaining float64
// EggProductionTotalWeightKg float64
// EggProductionTotalPieces float64
ProjectFlockKandangID uint
EggProductionWeightKgRemaining float64
EggProductionPiecesRemaining float64
EggProductionTotalWeightKg float64
EggProductionTotalPieces float64
}
eggRows := make([]eggRow, 0)
@@ -242,7 +416,10 @@ func (r *hppPerKandangRepository) GetWeightRemainingByProjectFlockKandangIDs(ctx
Table("recordings AS r").
Select(`
r.project_flock_kandangs_id AS project_flock_kandang_id,
COALESCE(SUM(re.weight) / NULLIF(SUM(re.total_qty), 0), 0) AS average_weight_egg_per_piece`).
COALESCE(SUM((re.total_qty - re.total_used) * re.weight / 1000), 0) AS egg_production_weight_kg_remaining,
COALESCE(SUM(re.total_qty - re.total_used), 0) AS egg_production_pieces_remaining,
COALESCE(SUM(re.weight / 1000), 0) AS egg_production_total_weight_kg,
COALESCE(SUM(re.total_qty), 0) AS egg_production_total_pieces`).
Joins("LEFT JOIN (?) AS la ON la.approvable_id = r.id", latestApproval).
Joins("LEFT JOIN recording_eggs AS re ON re.recording_id = r.id").
Where("r.project_flock_kandangs_id IN ?", projectFlockKandangIDs).
@@ -258,11 +435,11 @@ func (r *hppPerKandangRepository) GetWeightRemainingByProjectFlockKandangIDs(ctx
result := make(map[uint]HppPerKandangRow, len(eggRows))
for _, row := range eggRows {
result[row.ProjectFlockKandangID] = HppPerKandangRow{
ProjectFlockKandangID: row.ProjectFlockKandangID,
// AverageWeightEggPerPiece: row.AverageWeightEggPerPiece,
// EggProductionPiecesRemaining: row.EggProductionPiecesRemaining,
// EggProductionTotalWeightKg: row.EggProductionTotalWeightKg,
// EggProductionTotalPieces: row.EggProductionTotalPieces,
ProjectFlockKandangID: row.ProjectFlockKandangID,
EggProductionWeightKgRemaining: row.EggProductionWeightKgRemaining,
EggProductionPiecesRemaining: row.EggProductionPiecesRemaining,
EggProductionTotalWeightKg: row.EggProductionTotalWeightKg,
EggProductionTotalPieces: row.EggProductionTotalPieces,
}
}
@@ -25,21 +25,6 @@ func NewPurchaseSupplierRepository(db *gorm.DB) PurchaseSupplierRepository {
return &purchaseSupplierRepositoryImpl{db: db}
}
func (r *purchaseSupplierRepositoryImpl) latestPurchaseApproval(ctx context.Context) *gorm.DB {
return r.db.WithContext(ctx).
Table("approvals AS a").
Select("a.approvable_id, a.step_number, a.action").
Joins(`
JOIN (
SELECT approvable_id, MAX(action_at) AS latest_action_at
FROM approvals
WHERE approvable_type = ?
GROUP BY approvable_id
) AS la ON la.approvable_id = a.approvable_id AND la.latest_action_at = a.action_at`,
string(utils.ApprovalWorkflowPurchase),
)
}
func (r *purchaseSupplierRepositoryImpl) baseSupplierQuery(ctx context.Context, filters *validation.PurchaseSupplierQuery) *gorm.DB {
dateColumn := "purchase_items.received_date"
switch strings.ToLower(strings.TrimSpace(filters.FilterBy)) {
@@ -49,16 +34,10 @@ func (r *purchaseSupplierRepositoryImpl) baseSupplierQuery(ctx context.Context,
dateColumn = "purchase_items.received_date"
}
latestApproval := r.latestPurchaseApproval(ctx)
db := r.db.WithContext(ctx).
Model(&entity.Supplier{}).
Joins("JOIN purchases ON purchases.supplier_id = suppliers.id").
Joins("JOIN purchase_items ON purchase_items.purchase_id = purchases.id").
Joins("JOIN (?) AS la ON la.approvable_id = purchases.id", latestApproval).
Where("la.step_number >= ?", uint16(utils.PurchaseStepReceiving)).
Where("(la.action IS NULL OR la.action != ?)", string(entity.ApprovalActionRejected)).
Where("purchase_items.received_date IS NOT NULL")
Joins("JOIN purchase_items ON purchase_items.purchase_id = purchases.id")
if filters.SupplierId > 0 {
db = db.Where("suppliers.id = ?", filters.SupplierId)
@@ -173,11 +152,7 @@ func (r *purchaseSupplierRepositoryImpl) GetItemsBySuppliers(ctx context.Context
Preload("ExpenseNonstock.Expense").
Preload("ExpenseNonstock.Expense.Supplier").
Joins("JOIN purchases ON purchases.id = purchase_items.purchase_id").
Joins("JOIN (?) AS la ON la.approvable_id = purchases.id", r.latestPurchaseApproval(ctx)).
Where("purchases.supplier_id IN ?", supplierIDs).
Where("la.step_number >= ?", uint16(utils.PurchaseStepReceiving)).
Where("(la.action IS NULL OR la.action != ?)", string(entity.ApprovalActionRejected)).
Where("purchase_items.received_date IS NOT NULL")
Where("purchases.supplier_id IN ?", supplierIDs)
if filters.ProductId > 0 {
db = db.Where("purchase_items.product_id = ?", filters.ProductId)
@@ -57,7 +57,6 @@ type repportService struct {
ChickinRepo chickinRepo.ProjectChickinRepository
RecordingRepo recordingRepo.RecordingRepository
ApprovalSvc approvalService.ApprovalService
HppSvc approvalService.HppService
PurchaseSupplierRepo repportRepo.PurchaseSupplierRepository
DebtSupplierRepo repportRepo.DebtSupplierRepository
HppPerKandangRepo repportRepo.HppPerKandangRepository
@@ -86,7 +85,6 @@ func NewRepportService(
chickinRepo chickinRepo.ProjectChickinRepository,
recordingRepo recordingRepo.RecordingRepository,
approvalSvc approvalService.ApprovalService,
hppSvc approvalService.HppService,
purchaseSupplierRepo repportRepo.PurchaseSupplierRepository,
debtSupplierRepo repportRepo.DebtSupplierRepository,
hppPerKandangRepo repportRepo.HppPerKandangRepository,
@@ -106,7 +104,6 @@ func NewRepportService(
ChickinRepo: chickinRepo,
RecordingRepo: recordingRepo,
ApprovalSvc: approvalSvc,
HppSvc: hppSvc,
PurchaseSupplierRepo: purchaseSupplierRepo,
DebtSupplierRepo: debtSupplierRepo,
HppPerKandangRepo: hppPerKandangRepo,
@@ -168,46 +165,6 @@ func (s *repportService) GetMarketing(c *fiber.Ctx, params *validation.Marketing
return nil, 0, err
}
customerGroups := make(map[uint][]entity.MarketingDeliveryProduct)
for _, dp := range deliveryProducts {
customerID := dp.MarketingProduct.Marketing.CustomerId
customerGroups[customerID] = append(customerGroups[customerID], dp)
}
agingMap := make(map[int]int)
for customerID := range customerGroups {
transactions, err := s.CustomerPaymentRepo.GetCustomerPaymentTransactions(c.Context(), &customerID)
if err != nil {
continue
}
initialBalance, err := s.CustomerPaymentRepo.GetInitialBalanceByCustomer(c.Context(), customerID)
if err != nil {
initialBalance = 0
}
runningBalance := initialBalance
for i, tx := range transactions {
if tx.TransactionType == "SALES" {
previousBalance := runningBalance
runningBalance -= tx.TotalPrice
currentBalance := runningBalance
_, paymentDate := s.determineSalesStatusAndPaymentDate(transactions, i, previousBalance, currentBalance)
if paymentDate != nil {
agingDays := int(paymentDate.Sub(tx.TransDate).Hours() / 24)
agingMap[int(tx.TransactionID)] = agingDays
} else {
agingDays := int(time.Since(tx.TransDate).Hours() / 24)
agingMap[int(tx.TransactionID)] = agingDays
}
} else if tx.TransactionType == "PAYMENT" {
runningBalance += tx.PaymentAmount
}
}
}
projectFlockIDMap := make(map[uint]bool)
hppMap := make(map[uint]float64)
@@ -218,31 +175,91 @@ func (s *repportService) GetMarketing(c *fiber.Ctx, params *validation.Marketing
projectFlockIDMap[projectFlockID] = true
category := projectFlockKandang.ProjectFlock.Category
if utils.ProjectFlockCategory(category) == utils.ProjectFlockCategoryLaying {
if s.HppSvc != nil {
hppCost, err := s.HppSvc.CalculateHppCost(projectFlockID, nil)
if err != nil {
hppMap[projectFlockID] = 0.0
} else if hppCost != nil {
hppMap[projectFlockID] = hppCost.Real.HargaKg
} else {
hppMap[projectFlockID] = 0.0
}
} else {
hppMap[projectFlockID] = 0.0
}
} else {
hppMap[projectFlockID] = 0.0
}
hppPerKg := s.calculateHppPricePerKg(c.Context(), projectFlockID, category)
hppMap[projectFlockID] = hppPerKg
}
}
}
items := dto.ToMarketingReportItems(deliveryProducts, hppMap, agingMap)
items := dto.ToRepportMarketingItemDTOsWithHppMap(deliveryProducts, hppMap)
return items, total, nil
}
func (s *repportService) calculateHppPricePerKg(ctx context.Context, projectFlockID uint, category string) float64 {
totalCost := s.getTotalProjectCost(ctx, projectFlockID)
if totalCost == 0 {
return 0
}
chickinQty, err := s.ChickinRepo.GetTotalChickinQtyByProjectFlockID(ctx, projectFlockID)
if err != nil {
s.Log.Warnf("HPP calculation: Failed to get chickin qty for project flock ID %d: %v", projectFlockID, err)
}
depletion, err := s.RecordingRepo.GetTotalDepletionByProjectFlockID(ctx, projectFlockID)
if err != nil {
s.Log.Warnf("HPP calculation: Failed to get depletion for project flock ID %d: %v", projectFlockID, err)
}
avgWeight, err := s.RecordingRepo.GetLatestAvgWeightByProjectFlockID(ctx, projectFlockID)
if err != nil {
s.Log.Warnf("HPP calculation: Failed to get avg weight for project flock ID %d: %v", projectFlockID, err)
}
var totalWeight float64
if utils.ProjectFlockCategory(category) == utils.ProjectFlockCategoryGrowing {
totalWeight = (chickinQty - depletion) * avgWeight
} else {
eggWeight, err := s.RecordingRepo.GetTotalEggProductionWeightByProjectFlockID(ctx, projectFlockID)
if err != nil {
s.Log.Warnf("HPP calculation: Failed to get egg weight for project flock ID %d: %v", projectFlockID, err)
}
totalWeight = (chickinQty-depletion)*avgWeight + eggWeight
}
if totalWeight == 0 {
return 0
}
hppPricePerKg := totalCost / totalWeight
return hppPricePerKg
}
func (s *repportService) getTotalProjectCost(ctx context.Context, projectFlockID uint) float64 {
if projectFlockID == 0 {
return 0
}
purchases, err := s.PurchaseRepo.GetItemsByProjectFlockID(ctx, projectFlockID)
if err != nil {
s.Log.Errorf("getTotalProjectCost: GetItemsByProjectFlockID error for project flock ID %d: %v", projectFlockID, err)
return 0
}
cost := float64(0)
purchaseCost := float64(0)
for _, p := range purchases {
purchaseCost += p.TotalPrice
}
cost += purchaseCost
realizations, err := s.ExpenseRealizationRepo.GetByProjectFlockID(ctx, projectFlockID)
if err != nil {
s.Log.Warnf("getTotalProjectCost: GetByProjectFlockID error for project flock ID %d: %v", projectFlockID, err)
}
bopCost := float64(0)
for _, r := range realizations {
if r.ExpenseNonstock != nil && r.ExpenseNonstock.Expense != nil &&
r.ExpenseNonstock.Expense.Category == string(utils.ExpenseCategoryBOP) {
bopCost += r.Price * r.Qty
}
}
cost += bopCost
return cost
}
func (s *repportService) GetProductionResult(ctx *fiber.Ctx, params *validation.ProductionResultQuery) ([]dto.ProductionResultDTO, int64, error) {
if err := s.Validate.Struct(params); err != nil {
return nil, 0, err
@@ -405,10 +422,12 @@ func (s *repportService) GetCustomerPayment(ctx *fiber.Ctx, params *validation.C
return nil, 0, err
}
// Determine customer IDs to process
var customerIDs []uint
var totalCustomers int64
if len(params.CustomerIDs) > 0 {
// Specific customer IDs mode (no pagination)
customerIDs = params.CustomerIDs
totalCustomers = int64(len(customerIDs))
@@ -416,6 +435,7 @@ func (s *repportService) GetCustomerPayment(ctx *fiber.Ctx, params *validation.C
return []dto.CustomerPaymentReportItem{}, 0, nil
}
} else {
// Multiple customers mode with pagination
page := params.Page
limit := params.Limit
if page < 1 {
@@ -554,7 +574,15 @@ func (s *repportService) processCustomerPayment(ctx context.Context, customerID
func (s *repportService) determineSalesStatusAndPaymentDate(transactions []repportRepo.CustomerPaymentTransaction, currentIndex int, previousBalance, currentBalance float64) (string, *time.Time) {
currentSales := transactions[currentIndex]
// Status Logic:
// 1. LUNAS: previousBalance >= salesAmount (paid from deposit)
// 2. LUNAS: future payments make AR >= 0 (eventually paid)
// 3. DIBAYAR SEBAGIAN: has payment but not enough
// 4. BELUM LUNAS: no payment at all
if previousBalance >= currentSales.TotalPrice {
// Cari payment yang digunakan untuk melunasi sales ini dengan FIFO
// Track payment allocations that are consumed by previous sales
type paymentAllocation struct {
date time.Time
amount float64
@@ -563,6 +591,7 @@ func (s *repportService) determineSalesStatusAndPaymentDate(transactions []reppo
allocations := []paymentAllocation{}
runningBalance := 0.0
// Process all transactions before current sales to build allocation map
for i := 0; i < currentIndex; i++ {
if transactions[i].TransactionType == "PAYMENT" {
allocations = append(allocations, paymentAllocation{
@@ -575,6 +604,7 @@ func (s *repportService) determineSalesStatusAndPaymentDate(transactions []reppo
salesAmount := transactions[i].TotalPrice
remainingToConsume := salesAmount
// Consume from oldest allocations first (FIFO)
for j := range allocations {
if remainingToConsume <= 0 {
break
@@ -593,18 +623,22 @@ func (s *repportService) determineSalesStatusAndPaymentDate(transactions []reppo
}
}
// Now find which allocation covers the current sales
amountNeeded := currentSales.TotalPrice
for _, alloc := range allocations {
available := alloc.amount - alloc.consumed
if available > 0 {
if amountNeeded <= available {
// This allocation fully covers the sales
return "LUNAS", &alloc.date
} else {
// This allocation partially covers, continue to next
amountNeeded -= available
}
}
}
// If we get here, use the oldest allocation
if len(allocations) > 0 {
return "LUNAS", &allocations[0].date
}
@@ -656,6 +690,7 @@ func mapRecordingToProductionResultDTO(record entity.Recording) dto.ProductionRe
if record.Day != nil {
result.Woa = float64(*record.Day)
}
// avgWeight := calculateAverageBodyWeight(record.BodyWeights)
avgWeight := 1.0
if avgWeight > 0 {
result.Bw = avgWeight
@@ -803,7 +838,7 @@ func (s *repportService) getUniformityByWeek(ctx context.Context, projectFlockKa
var rows []entity.ProjectFlockKandangUniformity
if err := s.DB.WithContext(ctx).
Model(&entity.ProjectFlockKandangUniformity{}).
Select("week, uniformity, uniform_date, id, chart_data").
Select("week, uniformity, uniform_date, id").
Where("project_flock_kandang_id = ?", projectFlockKandangID).
Where("week IN ?", weeks).
Order("uniform_date DESC").
@@ -1094,11 +1129,6 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu
return nil, 0, err
}
initialBalanceTotals, err := s.DebtSupplierRepo.GetInitialBalanceTotals(c.Context(), supplierIDs)
if err != nil {
return nil, 0, err
}
location, err := time.LoadLocation("Asia/Jakarta")
if err != nil {
return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "failed to load timezone configuration")
@@ -1113,16 +1143,6 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu
DeltaBalance float64
CountTotals bool
}
type debtSupplierAllocation struct {
RowIndex int
SortTime time.Time
Amount float64
Purchase entity.Purchase
}
type paymentAllocation struct {
Date time.Time
Amount float64
}
for _, supplierID := range supplierIDs {
supplier, exists := supplierMap[supplierID]
@@ -1130,17 +1150,15 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu
continue
}
initialBalance := initialBalanceTotals[supplierID] + (initialPaymentTotals[supplierID] - initialPurchaseTotals[supplierID])
initialBalance := initialPaymentTotals[supplierID] - initialPurchaseTotals[supplierID]
items := purchasesBySupplier[supplierID]
paymentItems := paymentsBySupplier[supplierID]
total := dto.DebtSupplierTotalDTO{}
combinedRows := make([]debtSupplierRowItem, 0, len(items)+len(paymentItems))
purchaseAllocations := make([]debtSupplierAllocation, 0, len(items))
for _, purchase := range items {
row := buildDebtSupplierRow(purchase, now, location)
sortTime := resolveDebtSupplierSortTime(purchase, params.FilterBy, location)
rowIndex := len(combinedRows)
combinedRows = append(combinedRows, debtSupplierRowItem{
Row: row,
SortTime: sortTime,
@@ -1148,24 +1166,6 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu
DeltaBalance: -row.TotalPrice,
CountTotals: true,
})
purchaseAllocations = append(purchaseAllocations, debtSupplierAllocation{
RowIndex: rowIndex,
SortTime: sortTime,
Amount: row.TotalPrice,
Purchase: purchase,
})
}
paymentAllocations := make([]paymentAllocation, 0, len(paymentItems)+1)
initialAllocation := initialBalanceTotals[supplierID] + initialPaymentTotals[supplierID] - initialPurchaseTotals[supplierID]
paymentCarry := 0.0
if initialAllocation > 0 && len(purchaseAllocations) > 0 {
paymentAllocations = append(paymentAllocations, paymentAllocation{
Date: purchaseAllocations[0].SortTime,
Amount: initialAllocation,
})
} else if initialAllocation < 0 {
paymentCarry = -initialAllocation
}
for _, payment := range paymentItems {
@@ -1178,53 +1178,6 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu
DeltaBalance: payment.Nominal,
CountTotals: false,
})
paymentAllocations = append(paymentAllocations, paymentAllocation{
Date: sortTime,
Amount: payment.Nominal,
})
}
if len(purchaseAllocations) > 0 && len(paymentAllocations) > 0 {
sort.SliceStable(purchaseAllocations, func(i, j int) bool {
return purchaseAllocations[i].SortTime.Before(purchaseAllocations[j].SortTime)
})
sort.SliceStable(paymentAllocations, func(i, j int) bool {
return paymentAllocations[i].Date.Before(paymentAllocations[j].Date)
})
remaining := make([]float64, len(purchaseAllocations))
for i := range purchaseAllocations {
remaining[i] = purchaseAllocations[i].Amount
}
purchaseIndex := 0
for _, pay := range paymentAllocations {
amount := pay.Amount
if amount <= 0 {
continue
}
if paymentCarry > 0 {
used := math.Min(amount, paymentCarry)
paymentCarry -= used
amount -= used
}
for amount > 0 && purchaseIndex < len(remaining) {
if remaining[purchaseIndex] <= 0 {
purchaseIndex++
continue
}
used := math.Min(amount, remaining[purchaseIndex])
remaining[purchaseIndex] -= used
amount -= used
if remaining[purchaseIndex] <= 0.000001 {
allocation := purchaseAllocations[purchaseIndex]
combinedRows[allocation.RowIndex].Row.Status = "Lunas"
combinedRows[allocation.RowIndex].Row.Aging = calculateDebtSupplierAging(allocation.Purchase, pay.Date, location)
purchaseIndex++
}
}
if purchaseIndex >= len(remaining) {
break
}
}
}
sort.SliceStable(combinedRows, func(i, j int) bool {
@@ -1421,55 +1374,6 @@ func resolveDebtSupplierSortTime(purchase entity.Purchase, filterBy string, loc
return purchase.CreatedAt.In(loc)
}
func collectDebtSupplierReferences(purchases []entity.Purchase) []string {
if len(purchases) == 0 {
return nil
}
seen := make(map[string]struct{}, len(purchases))
result := make([]string, 0, len(purchases))
for _, purchase := range purchases {
ref := resolveDebtSupplierReference(purchase)
if ref == "" {
continue
}
if _, ok := seen[ref]; ok {
continue
}
seen[ref] = struct{}{}
result = append(result, ref)
}
return result
}
func resolveDebtSupplierReference(purchase entity.Purchase) string {
if purchase.PoNumber != nil {
if ref := strings.TrimSpace(*purchase.PoNumber); ref != "" {
return ref
}
}
if ref := strings.TrimSpace(purchase.PrNumber); ref != "" {
return ref
}
return ""
}
func isDebtSupplierPaid(totalPrice, paymentTotal float64) bool {
if totalPrice <= 0 {
return true
}
return paymentTotal >= totalPrice-0.000001
}
func calculateDebtSupplierAging(purchase entity.Purchase, endDate time.Time, loc *time.Location) int {
prDate := purchase.CreatedAt.In(loc)
startDate := time.Date(prDate.Year(), prDate.Month(), prDate.Day(), 0, 0, 0, 0, loc)
stopDate := time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 0, 0, 0, 0, loc)
if stopDate.Before(startDate) {
return 0
}
return int(stopDate.Sub(startDate).Hours() / 24)
}
func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangResponseData, *dto.HppPerKandangMetaDTO, error) {
params, filters, err := s.parseHppPerKandangQuery(ctx)
if err != nil {
@@ -1516,27 +1420,29 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes
return nil, nil, err
}
// eggMap, err := s.HppPerKandangRepo.GetWeightRemainingByProjectFlockKandangIDs(ctx.Context(), startOfDay, endOfDay, validPfkIDs)
// if err != nil {
// return nil, nil, err
// }
// for pfkID, egg := range eggMap {
// if rowIdx, ok := pfkIndex[pfkID]; ok {
// repoRows[rowIdx].EggProductionWeightKgRemaining = egg.EggProductionWeightKgRemaining
// repoRows[rowIdx].AverageWeightEggPerPiece = egg.AverageWeightEggPerPiece
// }
// }
eggMap, err := s.HppPerKandangRepo.GetEggProductionByProjectFlockKandangIDs(ctx.Context(), startOfDay, endOfDay, validPfkIDs)
if err != nil {
return nil, nil, err
}
for pfkID, egg := range eggMap {
if rowIdx, ok := pfkIndex[pfkID]; ok {
repoRows[rowIdx].EggProductionWeightKgRemaining = egg.EggProductionWeightKgRemaining
repoRows[rowIdx].EggProductionPiecesRemaining = egg.EggProductionPiecesRemaining
repoRows[rowIdx].EggProductionTotalWeightKg = egg.EggProductionTotalWeightKg
repoRows[rowIdx].EggProductionTotalPieces = egg.EggProductionTotalPieces
}
}
}
costMap := make(map[uint]HppCostAggregate, len(costRows))
for _, row := range costRows {
costMap[row.ProjectFlockKandangID] = HppCostAggregate{
// FeedCost: row.FeedCost,
// OvkCost: row.OvkCost,
DocCost: row.DocCost,
DocQty: row.DocQty,
// BudgetCost: row.BudgetCost,
// ExpenseCost: row.ExpenseCost,
FeedCost: row.FeedCost,
OvkCost: row.OvkCost,
DocCost: row.DocCost,
DocQty: row.DocQty,
BudgetCost: row.BudgetCost,
ExpenseCost: row.ExpenseCost,
}
}
@@ -1594,9 +1500,12 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes
dataRows := make([]dto.HppPerKandangRowDTO, 0, len(repoRows))
perRangeMap := make(map[weightRangeKey]*weightRangeAggregate)
var totalBirds int64
// var totalWeight float64
var totalEggPieces int64
var totalEggKg float64
// var totalRemainingValueRp int64
var totalEggValueRp int64
// var totalHppSum float64
var totalHppCount int
var totalDocPriceSum float64
var totalDocPriceCount int
@@ -1610,44 +1519,35 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes
continue
}
var eggPiecesFloatRemaining float64
var eggRemainingWeightFloatRemaining float64
var eggTotalPiecesFloat float64
var eggWeightFloat float64
var avgWeight float64
eggHpp := 0.0
if s.HppSvc != nil {
hppCost, err := s.HppSvc.CalculateHppCost(row.ProjectFlockKandangID, &periodDate)
if err != nil {
return nil, nil, err
}
if hppCost != nil {
eggPiecesFloatRemaining = hppCost.Estimation.Butir - hppCost.Real.Butir
eggHpp = hppCost.Estimation.HargaKg
eggTotalPiecesFloat = hppCost.Estimation.Butir
eggWeightFloat = hppCost.Estimation.Kg
if eggTotalPiecesFloat > 0 {
avgWeight = eggWeightFloat / eggTotalPiecesFloat
}
eggRemainingWeightFloatRemaining = avgWeight * eggPiecesFloatRemaining
}
}
// birdsFloat := row.RemainingChickenBirds
// if math.IsNaN(birdsFloat) || math.IsInf(birdsFloat, 0) {
// birdsFloat = 0
// }
// weightFloat := row.RemainingChickenWeight
// if math.IsNaN(weightFloat) || math.IsInf(weightFloat, 0) {
// weightFloat = 0
// }
eggPiecesFloatRemaining := row.EggProductionPiecesRemaining
if math.IsNaN(eggPiecesFloatRemaining) || math.IsInf(eggPiecesFloatRemaining, 0) {
eggPiecesFloatRemaining = 0
}
eggTotalPiecesFloat := row.EggProductionTotalPieces
if math.IsNaN(eggTotalPiecesFloat) || math.IsInf(eggTotalPiecesFloat, 0) {
eggTotalPiecesFloat = 0
}
eggRemainingWeightFloatRemaining := row.EggProductionWeightKgRemaining
if math.IsNaN(eggRemainingWeightFloatRemaining) || math.IsInf(eggRemainingWeightFloatRemaining, 0) {
eggRemainingWeightFloatRemaining = 0
}
eggWeightFloat := row.EggProductionTotalWeightKg
if math.IsNaN(eggWeightFloat) || math.IsInf(eggWeightFloat, 0) {
eggWeightFloat = 0
}
if math.IsNaN(avgWeight) || math.IsInf(avgWeight, 0) {
avgWeight = 0
}
avgWeight := 0.0
if eggTotalPiecesFloat > 0 {
avgWeight = eggWeightFloat / eggTotalPiecesFloat
}
if params.WeightMin != nil && avgWeight < *params.WeightMin {
continue
}
@@ -1662,10 +1562,21 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes
weightMax := weightMin + 0.09
rangeKey := weightRangeKey{Min: weightMin, Max: weightMax}
// rowBirds := int64(math.Round(birdsFloat))
costEntry := costMap[row.ProjectFlockKandangID]
totalCost := costEntry.FeedCost + costEntry.OvkCost + costEntry.DocCost + costEntry.BudgetCost + costEntry.ExpenseCost
// hppRp := 0.0
// if weightFloat > 0 {
// hppRp = totalCost / weightFloat
// }
eggHpp := 0.0
if eggWeightFloat > 0 {
eggHpp = (totalCost / eggWeightFloat) / 1000
}
rowEggPieces := int64(math.Round(eggPiecesFloatRemaining))
rowEggValue := int64(eggHpp * eggRemainingWeightFloatRemaining)
// rowRemainingValue := int64(hppRp * weightFloat)
avgDocPrice := int64(0)
if costEntry.DocQty > 0 {
avgDocPrice = int64(math.Round(costEntry.DocCost / costEntry.DocQty))
@@ -1692,22 +1603,35 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes
WeightMin: weightMin,
WeightMax: weightMax,
},
AvgWeightKg: avgWeight,
NameWithPeriode: nameWithPeriod,
AvgWeightKg: avgWeight,
NameWithPeriode: nameWithPeriod,
// FeedCostRp: costEntry.FeedCost,
// OvkCostRp: costEntry.OvkCost,
DocSuppliers: docSupplierMap[row.ProjectFlockKandangID],
FeedSuppliers: feedSupplierMap[row.ProjectFlockKandangID],
EggProductionPieces: int64(math.Round(eggPiecesFloatRemaining)),
EggProductionKg: eggRemainingWeightFloatRemaining,
AverageDocPriceRp: avgDocPrice,
EggHppRpPerKg: eggHpp,
EggValueRp: rowEggValue,
// EggProductionTotalWeightKg: eggWeightFloat,
// EggProductionTotalPieces: int64(math.Round(eggTotalPiecesFloat)),
AverageDocPriceRp: avgDocPrice,
// HppRp: hppRp,
EggHppRpPerKg: eggHpp,
// RemainingValueRp: rowRemainingValue,
EggValueRp: rowEggValue,
})
// totalBirds += rowBirds
// totalWeight += weightFloat
totalEggPieces += rowEggPieces
totalEggKg += eggRemainingWeightFloatRemaining
// totalRemainingValueRp += rowRemainingValue
totalEggValueRp += rowEggValue
totalAvgWeightSum += avgWeight
totalAvgWeightCount++
// if weightFloat > 0 {
// totalHppSum += hppRp
// totalHppCount++
// }
if avgDocPrice > 0 {
totalDocPriceSum += float64(avgDocPrice)
totalDocPriceCount++
@@ -1734,6 +1658,8 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes
}
rangeSummary := rangeAgg.Summary
// rangeAgg.RemainingBirds += rowBirds
// rangeAgg.RemainingWeightKg += row.RemainingChickenWeight
rangeAgg.AvgWeightSum += avgWeight
rangeAgg.AvgWeightCount++
for _, supplier := range feedSupplierMap[row.ProjectFlockKandangID] {
@@ -1748,6 +1674,7 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes
}
rangeSummary.EggProductionPieces += rowEggPieces
rangeSummary.EggProductionKg += eggRemainingWeightFloatRemaining
// rangeSummary.RemainingValueRp += rowRemainingValue
rangeSummary.EggValueRp += rowEggValue
if eggWeightFloat > 0 {
rangeAgg.EggHppSum += eggHpp
@@ -26,7 +26,7 @@ type MarketingQuery struct {
AreaId int64 `query:"area_id" validate:"omitempty"`
LocationId int64 `query:"location_id" validate:"omitempty"`
MarketingType string `query:"marketing_type" validate:"omitempty,oneof=ayam telur trading"`
FilterBy string `query:"filter_by" validate:"omitempty,oneof= so_date realization_date"`
FilterBy string `query:"filter_by" validate:"omitempty,oneof=so_date realization_date"`
StartDate string `query:"start_date" validate:"omitempty,datetime=2006-01-02"`
EndDate string `query:"end_date" validate:"omitempty,datetime=2006-01-02"`
SortBy string `query:"sort_by" validate:"omitempty,oneof=so_date realization_date customer warehouse product sales_person vehicle_number sales_amount hpp_amount qty average_weight total_weight sales_price hpp_price aging_days"`
-2
View File
@@ -113,8 +113,6 @@ const (
StockLogTypeTransfer StockLogType = "TRANSFER"
StockLogTypeMarketing StockLogType = "MARKETING"
StockLogTypeChikin StockLogType = "CHICKIN"
StockLogTypePurchase StockLogType = "PURCHASE"
StockLogTypeRecording StockLogType = "RECORDING"
)
// -------------------------------------------------------------------
+11 -12
View File
@@ -2,19 +2,18 @@ package fifo
const (
// Usable Keys
UsableKeyRecordingStock UsableKey = "RECORDING_STOCK"
UsableKeyRecordingDepletion UsableKey = "RECORDING_DEPLETION"
UsableKeyProjectChickin UsableKey = "PROJECT_CHICKIN"
UsableKeyMarketingDelivery UsableKey = "MARKETING_DELIVERY"
UsableKeyTransferToLayingOut UsableKey = "TRANSFERTOLAYING_OUT"
UsableKeyStockTransferOut UsableKey = "STOCK_TRANSFER_OUT"
UsableKeyAdjustmentOut UsableKey = "ADJUSTMENT_OUT"
UsableKeyRecordingStock UsableKey = "RECORDING_STOCK"
UsableKeyProjectChickin UsableKey = "PROJECT_CHICKIN"
UsableKeyMarketingDelivery UsableKey = "MARKETING_DELIVERY"
UsableKeyTransferToLayingOut UsableKey = "TRANSFERTOLAYING_OUT"
UsableKeyStockTransferOut UsableKey = "STOCK_TRANSFER_OUT"
UsableKeyAdjustmentOut UsableKey = "ADJUSTMENT_OUT"
// Stockable Keys
StockableKeyTransferToLayingIn StockableKey = "TRANSFERTOLAYING_IN"
StockableKeyStockTransferIn StockableKey = "STOCK_TRANSFER_IN"
StockableKeyAdjustmentIn StockableKey = "ADJUSTMENT_IN"
StockableKeyPurchaseItems StockableKey = "PURCHASE_ITEMS"
StockableKeyTransferToLayingIn StockableKey = "TRANSFERTOLAYING_IN"
StockableKeyStockTransferIn StockableKey = "STOCK_TRANSFER_IN"
StockableKeyAdjustmentIn StockableKey = "ADJUSTMENT_IN"
StockableKeyPurchaseItems StockableKey = "PURCHASE_ITEMS"
StockableKeyProjectFlockPopulation StockableKey = "PROJECT_FLOCK_POPULATION"
StockableKeyRecordingEgg StockableKey = "RECORDING_EGG"
StockableKeyRecordingEgg StockableKey = "RECORDING_EGG"
)
@@ -14,10 +14,15 @@ func MapStocks(recordingID uint, items []validation.Stock) []entity.RecordingSto
for _, item := range items {
usagePtr := new(float64)
*usagePtr = item.Qty
pending := item.PendingQty
if pending == nil {
pending = new(float64)
}
result = append(result, entity.RecordingStock{
RecordingId: recordingID,
ProductWarehouseId: item.ProductWarehouseId,
UsageQty: usagePtr,
PendingQty: pending,
})
}
return result
-52
View File
@@ -2,7 +2,6 @@ package utils
import (
"sort"
"strconv"
"strings"
)
@@ -48,54 +47,3 @@ func ParseFlags(raw string) []string {
sort.Strings(res)
return res
}
// ParseQueryArray parses comma-separated string values and returns a slice of trimmed strings
// Example: "a, b, c" → ["a", "b", "c"]
func ParseQueryArray(raw string) []string {
if raw == "" {
return nil
}
parts := strings.Split(raw, ",")
result := make([]string, 0, len(parts))
for _, p := range parts {
trimmed := strings.TrimSpace(p)
if trimmed != "" {
result = append(result, trimmed)
}
}
if len(result) == 0 {
return nil
}
return result
}
// ParseQueryUintArray parses comma-separated string values and returns a slice of uint
// Invalid values are skipped
// Example: "1, 2, 3" → [1, 2, 3]
func ParseQueryUintArray(raw string) []uint {
if raw == "" {
return nil
}
parts := strings.Split(raw, ",")
result := make([]uint, 0, len(parts))
for _, p := range parts {
trimmed := strings.TrimSpace(p)
if trimmed == "" {
continue
}
if num, err := strconv.ParseUint(trimmed, 10, 32); err == nil {
result = append(result, uint(num))
}
}
if len(result) == 0 {
return nil
}
return result
}