Compare commits

..

1 Commits

Author SHA1 Message Date
giovanni 09c951c16a adjust max limit 2026-01-20 11:49:40 +07:00
174 changed files with 4633 additions and 12642 deletions
+84 -29
View File
@@ -1,35 +1,90 @@
workflow: stages:
rules: - deploy
# MR pipeline
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
when: always
# Push pipeline hanya untuk env branch deploy-dev:
- if: '$CI_COMMIT_BRANCH == "development"' stage: deploy
when: always image: alpine:3.20
- if: '$CI_COMMIT_BRANCH == "staging"' variables:
when: always DEPLOY_APP: "LTI-MBUGROUP"
- if: '$CI_COMMIT_BRANCH == "production"' # Opsional: kalau pakai submodule, ini bikin clone submodule pakai SSH juga
when: always GIT_SUBMODULE_STRATEGY: recursive
GIT_DEPTH: "1"
# Selain itu jangan buat pipeline before_script:
- when: never - echo "🧰 Installing dependencies..."
- apk update && apk add --no-cache openssh git curl bash
include: # Setup SSH di runner
# khusus MR (notif) - mkdir -p ~/.ssh
- local: "ci/merge_request.yml" - echo "$SSH_PRIVATE_KEY" | tr -d '\r' > ~/.ssh/id_rsa
rules: - chmod 600 ~/.ssh/id_rsa
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"' - eval "$(ssh-agent -s)"
- ssh-add ~/.ssh/id_rsa
# khusus push ke branch env # Trust host keys (server + gitlab) biar SSH gak nanya interaktif
- local: "ci/development.yml" - ssh-keyscan -H "$SERVER_IP" >> ~/.ssh/known_hosts
rules: - ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts
- if: '$CI_COMMIT_BRANCH == "development"'
- local: "ci/staging.yml" script:
rules: - echo "🚀 Deploying latest code to $SERVER_USER@$SERVER_IP"
- if: '$CI_COMMIT_BRANCH == "staging"'
- local: "ci/production.yml" - >
rules: if ssh -o StrictHostKeyChecking=no "$SERVER_USER@$SERVER_IP" "
- if: '$CI_COMMIT_BRANCH == "production"' 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
-91
View File
@@ -1,91 +0,0 @@
stages:
- deploy
deploy-dev:
stage: deploy
image: alpine:3.20
rules:
- if: '$CI_COMMIT_BRANCH == "development"'
when: on_success
- when: never
variables:
DEPLOY_APP: "LTI-MBUGROUP"
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";
environment:
name: development
-48
View File
@@ -1,48 +0,0 @@
stages:
- notify
notify_discord_on_mr_request_main_dev:
stage: notify
image: alpine:3.20
rules:
# hanya MR yang target ke main atau development
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" && ($CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main" || $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "development")'
when: on_success
- when: never
script:
- apk add --no-cache curl jq coreutils
- |
TIME_HUMAN="$(date '+%d/%m/%y, %H.%M')"
TIME_ISO="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
TITLE="${CI_MERGE_REQUEST_TITLE}"
IID="!${CI_MERGE_REQUEST_IID}"
USER_LINE="${GITLAB_USER_NAME} (${GITLAB_USER_LOGIN})"
PROJECT_PATH="${CI_PROJECT_PATH}"
USERNAME="${GITLAB_USER_LOGIN}"
MR_URL="${CI_PROJECT_URL}/-/merge_requests/${CI_MERGE_REQUEST_IID}"
DESC="$(printf "**%s**\n\n%s opened merge request %s %s\n%s" \
"$USERNAME" "$USER_LINE" "$IID" "$TITLE" "$TIME_HUMAN")"
payload=$(jq -n \
--arg desc "$DESC" \
--arg project "$PROJECT_PATH" \
--arg timeiso "$TIME_ISO" \
--arg mrurl "$MR_URL" \
'{
"username": "Mock-api - Merge Requests",
"embeds": [
{
"description": ($desc + "\n" + $mrurl),
"color": 15105570,
"footer": { "text": $project },
"timestamp": $timeiso
}
]
}')
curl -sS -H "Content-Type: application/json" \
-d "$payload" \
"$DISCORD_WEBHOOK_URL"
-155
View File
@@ -1,155 +0,0 @@
stages:
- build
- migrate
- deploy
- seed
default:
tags:
- self-hosted-prod
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_COMMIT_BRANCH == "production"'
when: on_success
- when: never
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)
# =========================
migrate_production:
stage: migrate
rules:
- if: '$CI_COMMIT_BRANCH == "production"'
when: on_success
- when: never
needs:
- job: build_production
artifacts: false
script: |
set -e
echo "✅ Running migrations (production) ..."
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)
set -a
. ./.env
set +a
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"
# NOTE: pastikan nama servicenya benar untuk production (ini sebelumnya masih stg-*)
docker compose -f "$COMPOSE_FILE" up -d stg-postgres-lti stg-redis-lti || true
COMPOSE_NETWORK_KEY="$(docker compose -f "$COMPOSE_FILE" config | awk '/networks:/ {getline; print $1}' | tr -d ':')"
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 "✅ 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"
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_production:
stage: deploy
rules:
- if: '$CI_COMMIT_BRANCH == "production"'
when: on_success
- when: never
needs:
- 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
- when: never
script: |
set -e
cd "$DEPLOY_DIR"
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
-164
View File
@@ -1,164 +0,0 @@
stages:
- build
- migrate
- deploy
- seed
default:
tags:
- self-hosted-stg
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_COMMIT_BRANCH == "staging"'
when: on_success
- when: never
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_COMMIT_BRANCH == "staging"'
when: on_success
- when: never
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)
set -a
. ./.env
set +a
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"
echo "✅ Ensuring postgres & redis running ..."
docker compose -f "$COMPOSE_FILE" up -d stg-postgres-lti stg-redis-lti || true
COMPOSE_NETWORK_KEY="$(docker compose -f "$COMPOSE_FILE" config | awk '/networks:/ {getline; print $1}' | tr -d ':')"
echo "✅ Compose network key: $COMPOSE_NETWORK_KEY"
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"
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"
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_COMMIT_BRANCH == "staging"'
when: on_success
- when: never
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_COMMIT_BRANCH == "staging"'
when: manual
- when: never
needs:
- job: deploy_staging
artifacts: false
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 -1
View File
@@ -14,8 +14,8 @@ import (
"gitlab.com/mbugroup/lti-api.git/internal/database" "gitlab.com/mbugroup/lti-api.git/internal/database"
"gitlab.com/mbugroup/lti-api.git/internal/middleware" "gitlab.com/mbugroup/lti-api.git/internal/middleware"
"gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session" "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session"
sso "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/verifier"
"gitlab.com/mbugroup/lti-api.git/internal/route" "gitlab.com/mbugroup/lti-api.git/internal/route"
"gitlab.com/mbugroup/lti-api.git/internal/sso"
"gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
@@ -1,308 +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 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("EXISTS (SELECT 1 FROM flags f WHERE f.flagable_id = pw.product_id AND f.flagable_type = ? AND f.name IN ?)", entity.FlagableTypeProduct, 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
}
@@ -15,7 +15,7 @@ type ApprovalService interface {
WorkflowSteps(workflow approvalutils.ApprovalWorkflowKey) map[approvalutils.ApprovalStep]string WorkflowSteps(workflow approvalutils.ApprovalWorkflowKey) map[approvalutils.ApprovalStep]string
WorkflowStepName(workflow approvalutils.ApprovalWorkflowKey, step approvalutils.ApprovalStep) (string, bool) WorkflowStepName(workflow approvalutils.ApprovalWorkflowKey, step approvalutils.ApprovalStep) (string, bool)
CreateApproval(ctx context.Context, workflow approvalutils.ApprovalWorkflowKey, approvableID uint, step approvalutils.ApprovalStep, action *entity.ApprovalAction, actorID uint, note *string) (*entity.Approval, error) CreateApproval(ctx context.Context, workflow approvalutils.ApprovalWorkflowKey, approvableID uint, step approvalutils.ApprovalStep, action *entity.ApprovalAction, actorID uint, note *string) (*entity.Approval, error)
List(ctx context.Context, module string, approvableID *uint, page, limit int, search string, orderByDate string) ([]entity.Approval, int64, error) List(ctx context.Context, module string, approvableID *uint, page, limit int, search string) ([]entity.Approval, int64, error)
ListByTarget(ctx context.Context, workflow approvalutils.ApprovalWorkflowKey, approvableID uint, modifier func(*gorm.DB) *gorm.DB) ([]entity.Approval, error) ListByTarget(ctx context.Context, workflow approvalutils.ApprovalWorkflowKey, approvableID uint, modifier func(*gorm.DB) *gorm.DB) ([]entity.Approval, error)
LatestByTarget(ctx context.Context, workflow approvalutils.ApprovalWorkflowKey, approvableID uint, modifier func(*gorm.DB) *gorm.DB) (*entity.Approval, error) LatestByTarget(ctx context.Context, workflow approvalutils.ApprovalWorkflowKey, approvableID uint, modifier func(*gorm.DB) *gorm.DB) (*entity.Approval, error)
LatestByTargets(ctx context.Context, workflow approvalutils.ApprovalWorkflowKey, approvableIDs []uint, modifier func(*gorm.DB) *gorm.DB) (map[uint]*entity.Approval, error) LatestByTargets(ctx context.Context, workflow approvalutils.ApprovalWorkflowKey, approvableIDs []uint, modifier func(*gorm.DB) *gorm.DB) (map[uint]*entity.Approval, error)
@@ -70,14 +70,9 @@ func (s *approvalService) List(
approvableID *uint, approvableID *uint,
page, limit int, page, limit int,
search string, search string,
orderByDate string,
) ([]entity.Approval, int64, error) { ) ([]entity.Approval, int64, error) {
module = strings.TrimSpace(strings.ToUpper(module)) module = strings.TrimSpace(strings.ToUpper(module))
search = strings.TrimSpace(search) search = strings.TrimSpace(search)
orderByDate = strings.TrimSpace(strings.ToUpper(orderByDate))
if orderByDate != "ASC" && orderByDate != "DESC" {
orderByDate = "DESC"
}
if limit <= 0 { if limit <= 0 {
limit = 10 limit = 10
@@ -95,7 +90,7 @@ func (s *approvalService) List(
func(db *gorm.DB) *gorm.DB { func(db *gorm.DB) *gorm.DB {
query := db. query := db.
Where("approvable_type = ?", module). Where("approvable_type = ?", module).
Order("action_at " + orderByDate). Order("action_at DESC").
Preload("ActionUser") Preload("ActionUser")
if approvableID != nil { if approvableID != nil {
@@ -20,7 +20,7 @@ import (
) )
const ( const (
defaultDocumentPathLimit = 255 defaultDocumentPathLimit = 50
defaultDocumentKeyPrefix = "docs" defaultDocumentKeyPrefix = "docs"
maxDocumentNameLength = 50 maxDocumentNameLength = 50
) )
@@ -363,19 +363,13 @@ func (s *documentService) generateObjectKey(ext string) (string, error) {
} }
u := uuid.New().String() u := uuid.New().String()
keyPrefix := strings.Trim(s.keyPrefix, "/") key := fmt.Sprintf("%s/%s%s", strings.Trim(s.keyPrefix, "/"), u, normalizedExt)
key := fmt.Sprintf("%s%s", u, normalizedExt) if s.keyPrefix == "" {
if keyPrefix != "" { key = fmt.Sprintf("%s%s", u, normalizedExt)
key = fmt.Sprintf("%s/%s%s", keyPrefix, u, normalizedExt)
} }
if len(key) > s.maxPathLength { if len(key) > s.maxPathLength {
compact := strings.ReplaceAll(u, "-", "") key = fmt.Sprintf("%s%s", u, normalizedExt)
if keyPrefix != "" {
key = fmt.Sprintf("%s/%s%s", keyPrefix, compact, normalizedExt)
} else {
key = fmt.Sprintf("%s%s", compact, normalizedExt)
}
} }
if len(key) > s.maxPathLength { if len(key) > s.maxPathLength {
+20 -136
View File
@@ -25,8 +25,6 @@ type FifoService interface {
Replenish(ctx context.Context, req StockReplenishRequest) (*StockReplenishResult, error) Replenish(ctx context.Context, req StockReplenishRequest) (*StockReplenishResult, error)
Consume(ctx context.Context, req StockConsumeRequest) (*StockConsumeResult, error) Consume(ctx context.Context, req StockConsumeRequest) (*StockConsumeResult, error)
ReleaseUsage(ctx context.Context, req StockReleaseRequest) error ReleaseUsage(ctx context.Context, req StockReleaseRequest) error
AdjustStockableQuantity(ctx context.Context, req StockAdjustRequest) error
ResolvePending(ctx context.Context, req PendingResolveRequest) ([]PendingResolution, error)
} }
type fifoService struct { type fifoService struct {
@@ -97,26 +95,12 @@ type StockReplenishRequest struct {
Tx *gorm.DB Tx *gorm.DB
} }
type StockAdjustRequest struct {
StockableKey fifo.StockableKey
StockableID uint
ProductWarehouseID uint
Quantity float64
Note *string
Tx *gorm.DB
}
type PendingResolution struct { type PendingResolution struct {
UsableKey fifo.UsableKey UsableKey fifo.UsableKey
UsableID uint UsableID uint
Quantity float64 Quantity float64
} }
type PendingResolveRequest struct {
ProductWarehouseID uint
Tx *gorm.DB
}
type StockReplenishResult struct { type StockReplenishResult struct {
AddedQuantity float64 AddedQuantity float64
PendingResolved []PendingResolution PendingResolved []PendingResolution
@@ -154,38 +138,6 @@ type StockReleaseRequest struct {
Tx *gorm.DB 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) { func (s *fifoService) Replenish(ctx context.Context, req StockReplenishRequest) (*StockReplenishResult, error) {
if req.StockableID == 0 || strings.TrimSpace(req.StockableKey.String()) == "" { if req.StockableID == 0 || strings.TrimSpace(req.StockableKey.String()) == "" {
return nil, errors.New("stockable key and id are required") return nil, errors.New("stockable key and id are required")
@@ -233,23 +185,6 @@ func (s *fifoService) Replenish(ctx context.Context, req StockReplenishRequest)
return result, nil return result, nil
} }
func (s *fifoService) ResolvePending(ctx context.Context, req PendingResolveRequest) ([]PendingResolution, error) {
if req.ProductWarehouseID == 0 {
return nil, errors.New("product warehouse id is required")
}
var resolved []PendingResolution
err := s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error {
var err error
resolved, err = s.resolvePendingForWarehouse(ctx, tx, req.ProductWarehouseID)
return err
})
if err != nil {
return nil, err
}
return resolved, nil
}
func (s *fifoService) Consume(ctx context.Context, req StockConsumeRequest) (*StockConsumeResult, error) { func (s *fifoService) Consume(ctx context.Context, req StockConsumeRequest) (*StockConsumeResult, error) {
if req.UsableID == 0 || strings.TrimSpace(req.UsableKey.String()) == "" { if req.UsableID == 0 || strings.TrimSpace(req.UsableKey.String()) == "" {
return nil, errors.New("usable key and id are required") return nil, errors.New("usable key and id are required")
@@ -332,7 +267,7 @@ func (s *fifoService) Consume(ctx context.Context, req StockConsumeRequest) (*St
} }
if reductionTarget > 0 { if reductionTarget > 0 {
released, err := s.releaseUsagePortion(ctx, tx, req.UsableKey, req.UsableID, reductionTarget, productWarehouseID) released, err := s.releaseUsagePortion(ctx, tx, req.UsableKey, req.UsableID, reductionTarget)
if err != nil { if err != nil {
return err return err
} }
@@ -379,7 +314,7 @@ func (s *fifoService) ReleaseUsage(ctx context.Context, req StockReleaseRequest)
} }
var usageDelta, pendingDelta float64 var usageDelta, pendingDelta float64
if ctxRow.UsageQty > 0 { if ctxRow.UsageQty > 0 {
if _, err := s.releaseUsagePortion(ctx, tx, req.UsableKey, req.UsableID, ctxRow.UsageQty, ctxRow.ProductWarehouseID); err != nil { if _, err := s.releaseUsagePortion(ctx, tx, req.UsableKey, req.UsableID, ctxRow.UsageQty); err != nil {
return err return err
} }
usageDelta -= ctxRow.UsageQty usageDelta -= ctxRow.UsageQty
@@ -745,7 +680,6 @@ func (s *fifoService) releaseUsagePortion(
usableKey fifo.UsableKey, usableKey fifo.UsableKey,
usableID uint, usableID uint,
target float64, target float64,
expectedWarehouseID uint,
) (float64, error) { ) (float64, error) {
if target <= 0 { if target <= 0 {
return 0, nil return 0, nil
@@ -761,18 +695,6 @@ func (s *fifoService) releaseUsagePortion(
if len(allocations) == 0 { if len(allocations) == 0 {
return 0, nil return 0, nil
} }
for i := range allocations {
alloc := &allocations[i]
if expectedWarehouseID == 0 || alloc.ProductWarehouseId == expectedWarehouseID {
continue
}
if err := tx.Model(&entities.StockAllocation{}).
Where("id = ?", alloc.Id).
Update("product_warehouse_id", expectedWarehouseID).Error; err != nil {
return 0, err
}
alloc.ProductWarehouseId = expectedWarehouseID
}
var ( var (
remaining = target remaining = target
@@ -869,30 +791,30 @@ func (s *fifoService) fetchPendingCandidates(ctx context.Context, tx *gorm.DB, p
cfg.Columns.CreatedAt, cfg.Columns.CreatedAt,
) )
if cfg.Columns.CreatedAt == cfg.Columns.ID { var rows []struct {
var rows []struct { ID uint
ID uint Pending float64
Pending float64 `gorm:"column:pending_qty"` CreatedAt time.Time
CreatedAt int64 `gorm:"column:created_at"` }
}
query := tx.Table(cfg.Table). query := tx.Table(cfg.Table).
Select(selectStmt). Select(selectStmt).
Where(fmt.Sprintf("%s = ?", cfg.Columns.ProductWarehouseID), productWarehouseID). Where(fmt.Sprintf("%s = ?", cfg.Columns.ProductWarehouseID), productWarehouseID).
Where(fmt.Sprintf("%s > 0", cfg.Columns.PendingQuantity)). Where(fmt.Sprintf("%s > 0", cfg.Columns.PendingQuantity)).
Limit(s.pendingBatchPerUsable) Limit(s.pendingBatchPerUsable)
if cfg.Scope != nil { if cfg.Scope != nil {
query = cfg.Scope(query) query = cfg.Scope(query)
} }
for _, order := range s.orderClauses(cfg.OrderBy) { for _, order := range s.orderClauses(cfg.OrderBy) {
query = query.Order(order) query = query.Order(order)
} }
if err := query.Find(&rows).Error; err != nil { if err := query.Find(&rows).Error; err != nil {
return nil, err return nil, err
} }
for _, row := range rows { for _, row := range rows {
if row.Pending <= 0 { if row.Pending <= 0 {
continue continue
@@ -902,47 +824,9 @@ func (s *fifoService) fetchPendingCandidates(ctx context.Context, tx *gorm.DB, p
Config: cfg, Config: cfg,
UsableID: row.ID, UsableID: row.ID,
Pending: row.Pending, Pending: row.Pending,
CreatedAt: time.Unix(0, row.CreatedAt), CreatedAt: row.CreatedAt,
}) })
} }
} else {
var rows []struct {
ID uint
Pending float64 `gorm:"column:pending_qty"`
CreatedAt time.Time `gorm:"column:created_at"`
}
query := tx.Table(cfg.Table).
Select(selectStmt).
Where(fmt.Sprintf("%s = ?", cfg.Columns.ProductWarehouseID), productWarehouseID).
Where(fmt.Sprintf("%s > 0", cfg.Columns.PendingQuantity)).
Limit(s.pendingBatchPerUsable)
if cfg.Scope != nil {
query = cfg.Scope(query)
}
for _, order := range s.orderClauses(cfg.OrderBy) {
query = query.Order(order)
}
if err := query.Find(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
if row.Pending <= 0 {
continue
}
candidates = append(candidates, pendingCandidate{
UsableKey: key,
Config: cfg,
UsableID: row.ID,
Pending: row.Pending,
CreatedAt: row.CreatedAt,
})
}
}
} }
if len(candidates) == 0 { if len(candidates) == 0 {
@@ -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 -23
View File
@@ -61,7 +61,6 @@ var (
SSOCookieDomain string SSOCookieDomain string
SSOCookieSecure bool SSOCookieSecure bool
SSOCookieSameSite string SSOCookieSameSite string
SSOAccessTokenMaxBytes int
SSOTokenBlacklistPrefix string SSOTokenBlacklistPrefix string
SSOPKCETTL time.Duration SSOPKCETTL time.Duration
SSOUserSyncDrift time.Duration SSOUserSyncDrift time.Duration
@@ -74,7 +73,6 @@ var (
S3SecretKey string S3SecretKey string
S3ForcePathStyle bool S3ForcePathStyle bool
S3PublicBaseURL string S3PublicBaseURL string
S3EnvPrefix string
S3DocumentKeyPrefix string S3DocumentKeyPrefix string
) )
@@ -125,12 +123,7 @@ func init() {
S3SecretKey = strings.TrimSpace(viper.GetString("S3_SECRET_KEY")) S3SecretKey = strings.TrimSpace(viper.GetString("S3_SECRET_KEY"))
S3ForcePathStyle = viper.GetBool("S3_FORCE_PATH_STYLE") S3ForcePathStyle = viper.GetBool("S3_FORCE_PATH_STYLE")
S3PublicBaseURL = strings.TrimSuffix(strings.TrimSpace(viper.GetString("S3_PUBLIC_BASE_URL")), "/") S3PublicBaseURL = strings.TrimSuffix(strings.TrimSpace(viper.GetString("S3_PUBLIC_BASE_URL")), "/")
S3EnvPrefix = defaultString(strings.Trim(strings.TrimSpace(viper.GetString("S3_ENV_PREFIX")), "/"), "local") S3DocumentKeyPrefix = defaultString(strings.Trim(strings.TrimSpace(viper.GetString("S3_DOCUMENT_PREFIX")), "/"), "docs")
docPrefix := strings.Trim(strings.TrimSpace(viper.GetString("S3_DOCUMENT_PREFIX")), "/")
if docPrefix == "" {
docPrefix = "docs"
}
S3DocumentKeyPrefix = joinPath(S3EnvPrefix, docPrefix)
// SSO integration // SSO integration
SSOIssuer = viper.GetString("SSO_ISSUER") SSOIssuer = viper.GetString("SSO_ISSUER")
@@ -145,10 +138,6 @@ func init() {
SSOCookieDomain = viper.GetString("SSO_COOKIE_DOMAIN") SSOCookieDomain = viper.GetString("SSO_COOKIE_DOMAIN")
SSOCookieSecure = viper.GetBool("SSO_COOKIE_SECURE") SSOCookieSecure = viper.GetBool("SSO_COOKIE_SECURE")
SSOCookieSameSite = defaultString(viper.GetString("SSO_COOKIE_SAMESITE"), "Lax") SSOCookieSameSite = defaultString(viper.GetString("SSO_COOKIE_SAMESITE"), "Lax")
SSOAccessTokenMaxBytes = viper.GetInt("SSO_ACCESS_TOKEN_MAX_BYTES")
if SSOAccessTokenMaxBytes <= 0 {
SSOAccessTokenMaxBytes = 4096
}
SSOTokenBlacklistPrefix = defaultString(viper.GetString("SSO_TOKEN_BLACKLIST_PREFIX"), "sso:blacklist") SSOTokenBlacklistPrefix = defaultString(viper.GetString("SSO_TOKEN_BLACKLIST_PREFIX"), "sso:blacklist")
if ttl := viper.GetInt("SSO_PKCE_TTL_SECONDS"); ttl > 0 { if ttl := viper.GetInt("SSO_PKCE_TTL_SECONDS"); ttl > 0 {
SSOPKCETTL = time.Duration(ttl) * time.Second SSOPKCETTL = time.Duration(ttl) * time.Second
@@ -253,17 +242,6 @@ func defaultString(v, def string) string {
return v return v
} }
func joinPath(parts ...string) string {
out := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.Trim(part, "/")
if part != "" {
out = append(out, part)
}
}
return strings.Join(out, "/")
}
func ensureProdConfig() { func ensureProdConfig() {
if SSOAuthorizeURL == "" || !strings.HasPrefix(SSOAuthorizeURL, "https://") { if SSOAuthorizeURL == "" || !strings.HasPrefix(SSOAuthorizeURL, "https://") {
panic("SSO_AUTHORIZE_URL must be https in production") panic("SSO_AUTHORIZE_URL must be https in production")
@@ -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,56 +0,0 @@
BEGIN;
DO $$
DECLARE
t text;
seq_name text;
BEGIN
FOREACH t IN ARRAY ARRAY[
'daily_checklist_activity_task_assignments',
'daily_checklist_activity_tasks',
'daily_checklist_phases',
'daily_checklist_tasks',
'daily_checklists',
'employee_kandangs',
'employees',
'phase_activities',
'phases'
]
LOOP
-- Sequence name convention
seq_name := format('public.%I_id_seq', t);
-- 1) Drop default nextval (bigserial behavior)
EXECUTE format(
'ALTER TABLE public.%I ALTER COLUMN id DROP DEFAULT',
t
);
-- 2) Add IDENTITY back (BY DEFAULT is safer for rollback)
EXECUTE format(
'ALTER TABLE public.%I ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY',
t
);
-- 3) Detach & optionally drop sequence (safe)
IF EXISTS (
SELECT 1 FROM pg_class
WHERE relkind = 'S'
AND relname = t || '_id_seq'
) THEN
EXECUTE format(
'ALTER SEQUENCE %s OWNED BY NONE',
seq_name
);
-- Optional: drop sequence (comment if you want to keep it)
EXECUTE format(
'DROP SEQUENCE IF EXISTS %s',
seq_name
);
END IF;
END LOOP;
END $$;
COMMIT;
@@ -1,59 +0,0 @@
BEGIN;
DO $$
DECLARE
t text;
seq_name text;
max_id bigint;
BEGIN
FOREACH t IN ARRAY ARRAY[
'daily_checklist_activity_task_assignments',
'daily_checklist_activity_tasks',
'daily_checklist_phases',
'daily_checklist_tasks',
'daily_checklists',
'employee_kandangs',
'employees',
'phase_activities',
'phases'
]
LOOP
-- Sequence name convention: public.<table>_id_seq
seq_name := format('public.%I_id_seq', t);
-- Drop IDENTITY only if the column is identity (safe to re-run)
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = t
AND column_name = 'id'
AND is_identity = 'YES'
) THEN
EXECUTE format('ALTER TABLE public.%I ALTER COLUMN id DROP IDENTITY', t);
END IF;
-- Ensure sequence exists
EXECUTE format('CREATE SEQUENCE IF NOT EXISTS %s', seq_name);
-- Set default like bigserial
EXECUTE format(
'ALTER TABLE public.%I ALTER COLUMN id SET DEFAULT nextval(''%s'')',
t, seq_name
);
-- Own the sequence by the column
EXECUTE format(
'ALTER SEQUENCE %s OWNED BY public.%I.id',
seq_name, t
);
-- Sync sequence to MAX(id) + 1 to avoid duplicate key
EXECUTE format('SELECT COALESCE(MAX(id), 0) FROM public.%I', t) INTO max_id;
EXECUTE format('SELECT setval(''%s'', $1, false)', seq_name)
USING (max_id + 1);
END LOOP;
END $$;
COMMIT;
@@ -1,2 +0,0 @@
ALTER TABLE stock_logs
DROP COLUMN stock;
@@ -1,18 +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;
@@ -1,7 +0,0 @@
BEGIN;
-- Migration: revert documents.path length
ALTER TABLE documents
ALTER COLUMN path TYPE VARCHAR(50);
COMMIT;
@@ -1,7 +0,0 @@
BEGIN;
-- Migration: extend documents.path length for environment prefixes
ALTER TABLE documents
ALTER COLUMN path TYPE VARCHAR(255);
COMMIT;
@@ -1,2 +0,0 @@
-- Drop transfer laying sequence
DROP SEQUENCE IF EXISTS transfer_laying_seq;
@@ -1,33 +0,0 @@
-- Create sequence for transfer laying movement number
CREATE SEQUENCE IF NOT EXISTS transfer_laying_seq START
WITH
1 INCREMENT BY 1 MINVALUE 1 MAXVALUE 99999 NO CYCLE;
-- Set sequence starting value based on existing data (if any)
-- This prevents duplicate movement numbers if there's already data
DO $$ DECLARE max_existing INTEGER;
BEGIN
-- Check if table exists and has data
IF EXISTS (
SELECT 1
FROM information_schema.tables
WHERE
table_schema = 'public'
AND table_name = 'transfer_to_layings'
) THEN
-- Get max ID from existing records
SELECT COALESCE(MAX(id), 0) INTO max_existing
FROM transfer_to_layings;
-- Set sequence to start after the highest existing ID
IF max_existing > 0 THEN PERFORM setval (
'transfer_laying_seq',
max_existing
);
END IF;
END IF;
END $$;
@@ -1,6 +0,0 @@
BEGIN;
ALTER TABLE adjustment_stocks
DROP COLUMN adj_number;
COMMIT;
@@ -1,10 +0,0 @@
BEGIN;
ALTER TABLE adjustment_stocks
ADD COLUMN adj_number VARCHAR(255);
UPDATE adjustment_stocks
SET adj_number = CONCAT('ADJ-', LPAD(id::text, 5, '0'))
WHERE adj_number IS NULL;
COMMIT;
@@ -1,8 +0,0 @@
-- Remove columns from marketing_products
ALTER TABLE marketing_products
DROP COLUMN IF EXISTS week,
DROP COLUMN IF EXISTS weight_per_convertion,
DROP COLUMN IF EXISTS convertion_unit;
-- Remove column from marketings
ALTER TABLE marketings DROP COLUMN IF EXISTS marketing_type;
@@ -1,9 +0,0 @@
-- Add marketing_type to marketings table
ALTER TABLE marketings
ADD COLUMN IF NOT EXISTS marketing_type VARCHAR(50);
-- Add convertion fields to marketing_products table
ALTER TABLE marketing_products
ADD COLUMN IF NOT EXISTS convertion_unit VARCHAR(20),
ADD COLUMN IF NOT EXISTS weight_per_convertion NUMERIC(15, 3),
ADD COLUMN IF NOT EXISTS week INTEGER;
@@ -1,47 +0,0 @@
BEGIN;
DO $$
DECLARE
fallback_fcr_id BIGINT;
BEGIN
IF NOT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'project_flocks'
AND column_name = 'fcr_id'
) THEN
ALTER TABLE project_flocks
ADD COLUMN fcr_id BIGINT;
END IF;
SELECT id INTO fallback_fcr_id
FROM fcrs
ORDER BY id ASC
LIMIT 1;
IF fallback_fcr_id IS NOT NULL THEN
UPDATE project_flocks
SET fcr_id = fallback_fcr_id
WHERE fcr_id IS NULL;
ALTER TABLE project_flocks
ALTER COLUMN fcr_id SET NOT NULL;
END IF;
IF EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'project_flocks_fcr_id_fkey'
) THEN
ALTER TABLE project_flocks
DROP CONSTRAINT project_flocks_fcr_id_fkey;
END IF;
ALTER TABLE project_flocks
ADD CONSTRAINT project_flocks_fcr_id_fkey
FOREIGN KEY (fcr_id) REFERENCES fcrs(id)
ON DELETE RESTRICT ON UPDATE CASCADE;
END $$;
COMMIT;
@@ -1,26 +0,0 @@
BEGIN;
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'project_flocks'
AND column_name = 'fcr_id'
) THEN
IF EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'project_flocks_fcr_id_fkey'
) THEN
ALTER TABLE project_flocks
DROP CONSTRAINT project_flocks_fcr_id_fkey;
END IF;
ALTER TABLE project_flocks
DROP COLUMN fcr_id;
END IF;
END $$;
COMMIT;
@@ -1,12 +0,0 @@
BEGIN;
ALTER TABLE recording_depletions
DROP CONSTRAINT IF EXISTS chk_recording_depletions_pending_zero;
ALTER TABLE recording_depletions
DROP COLUMN IF EXISTS total_used_qty;
ALTER TABLE recording_depletions
DROP COLUMN IF EXISTS usage_qty;
COMMIT;
@@ -1,15 +0,0 @@
BEGIN;
ALTER TABLE recording_depletions
ADD COLUMN IF NOT EXISTS total_used_qty numeric(15, 3) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS usage_qty numeric(15, 3) NOT NULL DEFAULT 0;
UPDATE recording_depletions
SET pending_qty = 0
WHERE pending_qty IS NULL OR pending_qty <> 0;
ALTER TABLE recording_depletions
ADD CONSTRAINT chk_recording_depletions_pending_zero
CHECK (pending_qty = 0);
COMMIT;
+5 -25
View File
@@ -74,7 +74,7 @@ func seedUsers(tx *gorm.DB) (map[string]uint, error) {
} }
func seedUoms(tx *gorm.DB, createdBy uint) (map[string]uint, error) { func seedUoms(tx *gorm.DB, createdBy uint) (map[string]uint, error) {
names := []string{"Kilogram", "Gram", "Liter", "Unit", "Ekor", "Butir"} names := []string{"Kilogram", "Gram", "Liter", "Unit", "Ekor"}
result := make(map[string]uint, len(names)) result := make(map[string]uint, len(names))
for _, name := range names { for _, name := range names {
@@ -235,7 +235,7 @@ func seedProducts(tx *gorm.DB, createdBy uint, uoms map[string]uint, categories
Name: "Telur Utuh", Name: "Telur Utuh",
Brand: "-", Brand: "-",
Sku: "4", Sku: "4",
Uom: "Butir", Uom: "Gram",
Category: "Telur", Category: "Telur",
Price: 1, Price: 1,
Flags: []utils.FlagType{utils.FlagTelurUtuh}, Flags: []utils.FlagType{utils.FlagTelurUtuh},
@@ -245,7 +245,7 @@ func seedProducts(tx *gorm.DB, createdBy uint, uoms map[string]uint, categories
Name: "Telur Pecah", Name: "Telur Pecah",
Brand: "-", Brand: "-",
Sku: "5", Sku: "5",
Uom: "Butir", Uom: "Gram",
Category: "Telur", Category: "Telur",
Price: 1, Price: 1,
Flags: []utils.FlagType{utils.FlagTelurPecah}, Flags: []utils.FlagType{utils.FlagTelurPecah},
@@ -255,7 +255,7 @@ func seedProducts(tx *gorm.DB, createdBy uint, uoms map[string]uint, categories
Name: "Telur Putih", Name: "Telur Putih",
Brand: "-", Brand: "-",
Sku: "6", Sku: "6",
Uom: "Butir", Uom: "Gram",
Category: "Telur", Category: "Telur",
Price: 1, Price: 1,
Flags: []utils.FlagType{utils.FlagTelurPutih}, Flags: []utils.FlagType{utils.FlagTelurPutih},
@@ -265,32 +265,12 @@ func seedProducts(tx *gorm.DB, createdBy uint, uoms map[string]uint, categories
Name: "Telur Retak", Name: "Telur Retak",
Brand: "-", Brand: "-",
Sku: "7", Sku: "7",
Uom: "Butir", Uom: "Gram",
Category: "Telur", Category: "Telur",
Price: 1, Price: 1,
Flags: []utils.FlagType{utils.FlagTelurRetak}, Flags: []utils.FlagType{utils.FlagTelurRetak},
IsVisible: false, IsVisible: false,
}, },
{
Name: "Telur Papacal",
Brand: "-",
Sku: "8",
Uom: "Butir",
Category: "Telur",
Price: 1,
Flags: []utils.FlagType{utils.FlagTelur},
IsVisible: false,
},
{
Name: "Telur Jumbo",
Brand: "-",
Sku: "9",
Uom: "Butir",
Category: "Telur",
Price: 1,
Flags: []utils.FlagType{utils.FlagTelur},
IsVisible: false,
},
} }
for _, seed := range seeds { for _, seed := range seeds {
+21 -10
View File
@@ -2,17 +2,28 @@ package entities
import "time" 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 { type AdjustmentStock struct {
Id uint `gorm:"primaryKey"` Id uint `gorm:"primaryKey"`
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` StockLogId uint `gorm:"column:stock_log_id;not null;index"`
TotalQty float64 `gorm:"column:total_qty;default:0"` ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
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"`
AdjNumber string `gorm:"column:adj_number;uniqueIndex;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"` ProductWarehouse *ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"`
StockLog *StockLog `gorm:"polymorphic:Loggable;polymorphicType:LoggableType;polymorphicId:LoggableId;polymorphicValue:ADJUSTMENT"`
} }
+1 -1
View File
@@ -7,7 +7,7 @@ type Document struct {
DocumentableType string `gorm:"size:50;not null;index:documents_documentable_polymorphic,priority:1"` DocumentableType string `gorm:"size:50;not null;index:documents_documentable_polymorphic,priority:1"`
DocumentableId uint64 `gorm:"not null;index:documents_documentable_polymorphic,priority:2"` DocumentableId uint64 `gorm:"not null;index:documents_documentable_polymorphic,priority:2"`
Type string `gorm:"size:50;not null"` Type string `gorm:"size:50;not null"`
Path string `gorm:"size:255;not null"` Path string `gorm:"size:50;not null"`
Name string `gorm:"size:50;not null"` Name string `gorm:"size:50;not null"`
Ext string `gorm:"size:50;not null"` Ext string `gorm:"size:50;not null"`
Size float64 `gorm:"type:numeric(15,3);not null"` Size float64 `gorm:"type:numeric(15,3);not null"`
-1
View File
@@ -14,7 +14,6 @@ type Marketing struct {
SoDate time.Time `gorm:"type:date;not null"` SoDate time.Time `gorm:"type:date;not null"`
SalesPersonId uint `gorm:"not null"` SalesPersonId uint `gorm:"not null"`
Notes string `gorm:"type:text"` Notes string `gorm:"type:text"`
MarketingType string `gorm:"type:varchar(50)"`
CreatedBy uint `gorm:"not null"` CreatedBy uint `gorm:"not null"`
CreatedAt time.Time `gorm:"autoCreateTime"` CreatedAt time.Time `gorm:"autoCreateTime"`
UpdatedAt time.Time `gorm:"autoUpdateTime"` UpdatedAt time.Time `gorm:"autoUpdateTime"`
+8 -11
View File
@@ -1,17 +1,14 @@
package entities package entities
type MarketingProduct struct { type MarketingProduct struct {
Id uint `gorm:"primaryKey;autoIncrement"` Id uint `gorm:"primaryKey;autoIncrement"`
MarketingId uint `gorm:"not null"` MarketingId uint `gorm:"not null"`
ProductWarehouseId uint `gorm:"not null"` ProductWarehouseId uint `gorm:"not null"`
Qty float64 `gorm:"type:numeric(15,3);not null"` Qty float64 `gorm:"type:numeric(15,3);not null"`
ConvertionUnit *string `gorm:"type:varchar(20)"` UnitPrice float64 `gorm:"type:numeric(15,3);not null"`
WeightPerConvertion *float64 `gorm:"type:numeric(15,3)"` AvgWeight float64 `gorm:"type:numeric(15,3);not null"`
Week *int `gorm:"type:integer"` TotalWeight float64 `gorm:"type:numeric(15,3);not null"`
UnitPrice float64 `gorm:"type:numeric(15,3);not null"` TotalPrice float64 `gorm:"type:numeric(15,3);not null"`
AvgWeight float64 `gorm:"type:numeric(15,3);not null"`
TotalWeight float64 `gorm:"type:numeric(15,3);not null"`
TotalPrice float64 `gorm:"type:numeric(15,3);not null"`
Marketing Marketing `gorm:"foreignKey:MarketingId;references:Id"` Marketing Marketing `gorm:"foreignKey:MarketingId;references:Id"`
ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"`
+2
View File
@@ -11,6 +11,7 @@ type ProjectFlock struct {
FlockName string `gorm:"type:varchar(255);not null;uniqueIndex"` FlockName string `gorm:"type:varchar(255);not null;uniqueIndex"`
AreaId uint `gorm:"not null"` AreaId uint `gorm:"not null"`
Category string `gorm:"type:varchar(20);not null"` Category string `gorm:"type:varchar(20);not null"`
FcrId uint `gorm:"not null"`
ProductionStandardId uint `gorm:"column:production_standard_id"` ProductionStandardId uint `gorm:"column:production_standard_id"`
LocationId uint `gorm:"not null"` LocationId uint `gorm:"not null"`
CreatedBy uint `gorm:"not null"` CreatedBy uint `gorm:"not null"`
@@ -19,6 +20,7 @@ type ProjectFlock struct {
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
Area Area `gorm:"foreignKey:AreaId;references:Id"` Area Area `gorm:"foreignKey:AreaId;references:Id"`
Fcr Fcr `gorm:"foreignKey:FcrId;references:Id"`
ProductionStandard ProductionStandard `gorm:"foreignKey:ProductionStandardId;references:Id"` ProductionStandard ProductionStandard `gorm:"foreignKey:ProductionStandardId;references:Id"`
Location Location `gorm:"foreignKey:LocationId;references:Id"` Location Location `gorm:"foreignKey:LocationId;references:Id"`
CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"`
-1
View File
@@ -21,7 +21,6 @@ type PurchaseItem struct {
Price float64 `gorm:"type:numeric(15,3);default:0"` Price float64 `gorm:"type:numeric(15,3);default:0"`
TotalPrice float64 `gorm:"type:numeric(15,3);default:0"` TotalPrice float64 `gorm:"type:numeric(15,3);default:0"`
ExpenseNonstockId *uint64 ExpenseNonstockId *uint64
HasChickin bool `gorm:"-" json:"-"`
// Relations // Relations
ExpenseNonstock *ExpenseNonstock `gorm:"foreignKey:ExpenseNonstockId;references:Id"` ExpenseNonstock *ExpenseNonstock `gorm:"foreignKey:ExpenseNonstockId;references:Id"`
-2
View File
@@ -12,9 +12,7 @@ type Recording struct {
RecordDatetime time.Time `gorm:"column:record_datetime;not null"` RecordDatetime time.Time `gorm:"column:record_datetime;not null"`
Day *int `gorm:"column:day"` Day *int `gorm:"column:day"`
TotalDepletionQty *float64 `gorm:"column:total_depletion_qty"` TotalDepletionQty *float64 `gorm:"column:total_depletion_qty"`
TotalDepletionCumQty *float64 `gorm:"-"`
CumDepletionRate *float64 `gorm:"column:cum_depletion_rate"` CumDepletionRate *float64 `gorm:"column:cum_depletion_rate"`
DepletionRate *float64 `gorm:"-"`
CumIntake *int `gorm:"column:cum_intake"` CumIntake *int `gorm:"column:cum_intake"`
FcrValue *float64 `gorm:"column:fcr_value"` FcrValue *float64 `gorm:"column:fcr_value"`
TotalChickQty *float64 `gorm:"column:total_chick_qty"` TotalChickQty *float64 `gorm:"column:total_chick_qty"`
+4 -7
View File
@@ -1,13 +1,10 @@
package entities package entities
type RecordingDepletion struct { type RecordingDepletion struct {
Id uint `gorm:"primaryKey"` Id uint `gorm:"primaryKey"`
RecordingId uint `gorm:"column:recording_id;not null;index"` RecordingId uint `gorm:"column:recording_id;not null;index"`
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
SourceProductWarehouseId *uint `gorm:"column:source_product_warehouse_id"` Qty float64 `gorm:"column:qty;not null"`
Qty float64 `gorm:"column:qty;not null"`
UsageQty float64 `gorm:"column:usage_qty"`
PendingQty float64 `gorm:"column:pending_qty"`
Recording Recording `gorm:"foreignKey:RecordingId;references:Id"` Recording Recording `gorm:"foreignKey:RecordingId;references:Id"`
ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;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"` Increase float64 `gorm:"column:increase;type:numeric(15,3);default:0"`
Decrease float64 `gorm:"column:decrease;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"` LoggableType string `gorm:"column:loggable_type;type:varchar(50);not null"`
LoggableId uint `gorm:"column:loggable_id;not null"` LoggableId uint `gorm:"column:loggable_id;not null"`
+1 -1
View File
@@ -6,7 +6,7 @@ import "time"
type StockTransferDelivery struct { type StockTransferDelivery struct {
Id uint64 `gorm:"primaryKey;autoIncrement"` Id uint64 `gorm:"primaryKey;autoIncrement"`
StockTransferId uint64 StockTransferId uint64
SupplierId *uint64 SupplierId uint64
VehiclePlate string VehiclePlate string
DriverName string DriverName string
DocumentNumber string DocumentNumber string
+8 -26
View File
@@ -7,8 +7,8 @@ import (
"gitlab.com/mbugroup/lti-api.git/internal/config" "gitlab.com/mbugroup/lti-api.git/internal/config"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session" "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session"
sso "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/verifier"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" service "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
"gitlab.com/mbugroup/lti-api.git/internal/sso"
"gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils"
) )
@@ -24,10 +24,6 @@ type AuthContext struct {
User *entity.User User *entity.User
Roles []sso.Role Roles []sso.Role
Permissions map[string]struct{} Permissions map[string]struct{}
UserAreaIDs []uint
UserLocationIDs []uint
UserAllArea bool
UserAllLocation bool
} }
// Auth validates the incoming request against the central SSO access token and // Auth validates the incoming request against the central SSO access token and
@@ -71,19 +67,15 @@ func Auth(userService service.UserService, requiredScopes ...string) fiber.Handl
var roles []sso.Role var roles []sso.Role
permissions := make(map[string]struct{}) permissions := make(map[string]struct{})
var profile *sso.UserProfile
if verification.UserID != 0 { if verification.UserID != 0 {
if p, err := sso.FetchProfile(c.Context(), token, verification); err != nil { if profile, err := sso.FetchProfile(c.Context(), token, verification); err != nil {
utils.Log.WithError(err).Warn("auth: failed to fetch sso profile") utils.Log.WithError(err).Warn("auth: failed to fetch sso profile")
} else { } else if profile != nil {
profile = p roles = profile.Roles
} for _, perm := range profile.PermissionNames() {
} if perm != "" {
if profile != nil { permissions[perm] = struct{}{}
roles = profile.Roles }
for _, perm := range profile.PermissionNames() {
if perm != "" {
permissions[perm] = struct{}{}
} }
} }
} }
@@ -94,16 +86,6 @@ func Auth(userService service.UserService, requiredScopes ...string) fiber.Handl
User: user, User: user,
Roles: roles, Roles: roles,
Permissions: permissions, Permissions: permissions,
UserAreaIDs: nil,
UserLocationIDs: nil,
UserAllArea: false,
UserAllLocation: false,
}
if profile != nil {
ctx.UserAreaIDs = profile.AreaIDs
ctx.UserLocationIDs = profile.LocationIDs
ctx.UserAllArea = profile.AllArea
ctx.UserAllLocation = profile.AllLocation
} }
c.Locals(authContextLocalsKey, ctx) c.Locals(authContextLocalsKey, ctx)
-636
View File
@@ -1,636 +0,0 @@
package middleware
import (
"errors"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
)
type ScopeFilter struct {
IDs []uint
Restrict bool
}
type roleScope struct {
allArea bool
allLocation bool
areaIDs []uint
locationIDs []uint
hasAnyScopes bool
}
func ResolveAreaScope(c *fiber.Ctx, db *gorm.DB) (ScopeFilter, error) {
scope, err := collectRoleScope(c)
if err != nil || !scope.hasAnyScopes {
return ScopeFilter{}, err
}
if scope.allArea || scope.allLocation {
return ScopeFilter{}, nil
}
allowed := uniqueUint(scope.areaIDs)
if len(scope.locationIDs) > 0 {
derived, err := areaIDsByLocationIDs(db, scope.locationIDs)
if err != nil {
return ScopeFilter{}, err
}
allowed = uniqueUint(append(allowed, derived...))
}
if len(allowed) == 0 {
return ScopeFilter{Restrict: true}, nil
}
return ScopeFilter{IDs: allowed, Restrict: true}, nil
}
func ResolveLocationScope(c *fiber.Ctx, db *gorm.DB) (ScopeFilter, error) {
scope, err := collectRoleScope(c)
if err != nil || !scope.hasAnyScopes {
return ScopeFilter{}, err
}
if scope.allLocation || scope.allArea {
return ScopeFilter{}, nil
}
areaIDs := uniqueUint(scope.areaIDs)
locationIDs := uniqueUint(scope.locationIDs)
switch {
case len(locationIDs) > 0 && len(areaIDs) > 0:
filtered, err := filterLocationIDsByAreaIDs(db, locationIDs, areaIDs)
if err != nil {
return ScopeFilter{}, err
}
locationIDs = filtered
case len(locationIDs) == 0 && len(areaIDs) > 0:
derived, err := locationIDsByAreaIDs(db, areaIDs)
if err != nil {
return ScopeFilter{}, err
}
locationIDs = derived
}
locationIDs = uniqueUint(locationIDs)
if len(locationIDs) == 0 {
return ScopeFilter{Restrict: true}, nil
}
return ScopeFilter{IDs: locationIDs, Restrict: true}, nil
}
func ResolveLocationAreaScopes(c *fiber.Ctx, db *gorm.DB) (ScopeFilter, ScopeFilter, error) {
locationScope, err := ResolveLocationScope(c, db)
if err != nil {
return ScopeFilter{}, ScopeFilter{}, err
}
areaScope, err := ResolveAreaScope(c, db)
if err != nil {
return ScopeFilter{}, ScopeFilter{}, err
}
return locationScope, areaScope, nil
}
func collectRoleScope(c *fiber.Ctx) (roleScope, error) {
ctx, ok := AuthDetails(c)
if !ok || ctx == nil {
return roleScope{}, nil
}
userAreaIDs := uniqueUint(ctx.UserAreaIDs)
userLocationIDs := uniqueUint(ctx.UserLocationIDs)
userScope := roleScope{
allArea: ctx.UserAllArea,
allLocation: ctx.UserAllLocation,
areaIDs: userAreaIDs,
locationIDs: userLocationIDs,
hasAnyScopes: ctx.UserAllArea || ctx.UserAllLocation || len(userAreaIDs) > 0 || len(userLocationIDs) > 0,
}
if userScope.hasAnyScopes {
return userScope, nil
}
return roleScope{}, nil
}
func areaIDsByLocationIDs(db *gorm.DB, locationIDs []uint) ([]uint, error) {
if db == nil {
return nil, errors.New("database not configured")
}
if len(locationIDs) == 0 {
return nil, nil
}
var areaIDs []uint
if err := db.Model(&entity.Location{}).
Where("deleted_at IS NULL").
Where("id IN ?", locationIDs).
Distinct("area_id").
Pluck("area_id", &areaIDs).Error; err != nil {
return nil, err
}
return areaIDs, nil
}
func locationIDsByAreaIDs(db *gorm.DB, areaIDs []uint) ([]uint, error) {
if db == nil {
return nil, errors.New("database not configured")
}
if len(areaIDs) == 0 {
return nil, nil
}
var locationIDs []uint
if err := db.Model(&entity.Location{}).
Where("deleted_at IS NULL").
Where("area_id IN ?", areaIDs).
Distinct("id").
Pluck("id", &locationIDs).Error; err != nil {
return nil, err
}
return locationIDs, nil
}
func filterLocationIDsByAreaIDs(db *gorm.DB, locationIDs, areaIDs []uint) ([]uint, error) {
if db == nil {
return nil, errors.New("database not configured")
}
if len(locationIDs) == 0 || len(areaIDs) == 0 {
return nil, nil
}
var filtered []uint
if err := db.Model(&entity.Location{}).
Where("deleted_at IS NULL").
Where("id IN ?", locationIDs).
Where("area_id IN ?", areaIDs).
Distinct("id").
Pluck("id", &filtered).Error; err != nil {
return nil, err
}
return filtered, nil
}
func uniqueUint(ids []uint) []uint {
if len(ids) == 0 {
return nil
}
seen := make(map[uint]struct{}, len(ids))
result := make([]uint, 0, len(ids))
for _, id := range ids {
if id == 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
result = append(result, id)
}
return result
}
func ApplyScopeFilter(db *gorm.DB, scope ScopeFilter, column string) *gorm.DB {
if db == nil || !scope.Restrict {
return db
}
if len(scope.IDs) == 0 {
return db.Where("1 = 0")
}
return db.Where(column+" IN ?", scope.IDs)
}
func ApplyLocationScope(c *fiber.Ctx, db *gorm.DB, column string) (*gorm.DB, error) {
scopeDB := db
if db != nil {
scopeDB = db.Session(&gorm.Session{NewDB: true})
}
scope, err := ResolveLocationScope(c, scopeDB)
if err != nil {
return db, err
}
return ApplyScopeFilter(db, scope, column), nil
}
func ApplyAreaScope(c *fiber.Ctx, db *gorm.DB, column string) (*gorm.DB, error) {
scopeDB := db
if db != nil {
scopeDB = db.Session(&gorm.Session{NewDB: true})
}
scope, err := ResolveAreaScope(c, scopeDB)
if err != nil {
return db, err
}
return ApplyScopeFilter(db, scope, column), nil
}
func ApplyLocationAreaScope(c *fiber.Ctx, db *gorm.DB, locationColumn, areaColumn string) (*gorm.DB, error) {
scopeDB := db
if db != nil {
scopeDB = db.Session(&gorm.Session{NewDB: true})
}
if locationColumn != "" {
locationScope, err := ResolveLocationScope(c, scopeDB)
if err != nil {
return db, err
}
db = ApplyScopeFilter(db, locationScope, locationColumn)
}
if areaColumn != "" {
areaScope, err := ResolveAreaScope(c, scopeDB)
if err != nil {
return db, err
}
db = ApplyScopeFilter(db, areaScope, areaColumn)
}
return db, nil
}
func EnsureWarehouseAccess(c *fiber.Ctx, db *gorm.DB, warehouseID uint) error {
if warehouseID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid warehouse id")
}
if db == nil {
return fiber.NewError(fiber.StatusInternalServerError, "Database not configured")
}
scope, err := ResolveLocationScope(c, db)
if err != nil || !scope.Restrict {
return err
}
if len(scope.IDs) == 0 {
return fiber.NewError(fiber.StatusNotFound, "Warehouse not found")
}
var count int64
if err := ApplyScopeFilter(
db.WithContext(c.Context()).
Model(&entity.Warehouse{}).
Where("id = ?", warehouseID),
scope,
"warehouses.location_id",
).Count(&count).Error; err != nil {
return err
}
if count == 0 {
return fiber.NewError(fiber.StatusNotFound, "Warehouse not found")
}
return nil
}
func EnsureAreaAccess(c *fiber.Ctx, db *gorm.DB, areaID uint) error {
if areaID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid area id")
}
if db == nil {
return fiber.NewError(fiber.StatusInternalServerError, "Database not configured")
}
scope, err := ResolveAreaScope(c, db)
if err != nil || !scope.Restrict {
return err
}
if len(scope.IDs) == 0 {
return fiber.NewError(fiber.StatusNotFound, "Area not found")
}
var count int64
if err := ApplyScopeFilter(
db.WithContext(c.Context()).
Model(&entity.Area{}).
Where("id = ?", areaID),
scope,
"areas.id",
).Count(&count).Error; err != nil {
return err
}
if count == 0 {
return fiber.NewError(fiber.StatusNotFound, "Area not found")
}
return nil
}
func EnsureLocationAccess(c *fiber.Ctx, db *gorm.DB, locationID uint) error {
if locationID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid location id")
}
if db == nil {
return fiber.NewError(fiber.StatusInternalServerError, "Database not configured")
}
scope, err := ResolveLocationScope(c, db)
if err != nil || !scope.Restrict {
return err
}
if len(scope.IDs) == 0 {
return fiber.NewError(fiber.StatusNotFound, "Location not found")
}
var count int64
if err := ApplyScopeFilter(
db.WithContext(c.Context()).
Model(&entity.Location{}).
Where("id = ?", locationID),
scope,
"locations.id",
).Count(&count).Error; err != nil {
return err
}
if count == 0 {
return fiber.NewError(fiber.StatusNotFound, "Location not found")
}
return nil
}
func EnsureKandangAccess(c *fiber.Ctx, db *gorm.DB, kandangID uint) error {
if kandangID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid kandang id")
}
if db == nil {
return fiber.NewError(fiber.StatusInternalServerError, "Database not configured")
}
scope, err := ResolveLocationScope(c, db)
if err != nil || !scope.Restrict {
return err
}
if len(scope.IDs) == 0 {
return fiber.NewError(fiber.StatusNotFound, "Kandang not found")
}
var count int64
if err := ApplyScopeFilter(
db.WithContext(c.Context()).
Model(&entity.Kandang{}).
Where("id = ?", kandangID),
scope,
"kandangs.location_id",
).Count(&count).Error; err != nil {
return err
}
if count == 0 {
return fiber.NewError(fiber.StatusNotFound, "Kandang not found")
}
return nil
}
func EnsureProductWarehouseAccess(c *fiber.Ctx, db *gorm.DB, productWarehouseID uint) error {
if productWarehouseID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid product warehouse id")
}
if db == nil {
return fiber.NewError(fiber.StatusInternalServerError, "Database not configured")
}
scope, err := ResolveLocationScope(c, db)
if err != nil || !scope.Restrict {
return err
}
if len(scope.IDs) == 0 {
return fiber.NewError(fiber.StatusNotFound, "Product warehouse not found")
}
var count int64
q := db.WithContext(c.Context()).
Table("product_warehouses pw").
Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
Where("pw.id = ?", productWarehouseID)
q = ApplyScopeFilter(q, scope, "w.location_id")
if err := q.Count(&count).Error; err != nil {
return err
}
if count == 0 {
return fiber.NewError(fiber.StatusNotFound, "Product warehouse not found")
}
return nil
}
func EnsureStockLogAccess(c *fiber.Ctx, db *gorm.DB, stockLogID uint) error {
if stockLogID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid stock log id")
}
if db == nil {
return fiber.NewError(fiber.StatusInternalServerError, "Database not configured")
}
scope, err := ResolveLocationScope(c, db)
if err != nil || !scope.Restrict {
return err
}
if len(scope.IDs) == 0 {
return fiber.NewError(fiber.StatusNotFound, "Stock log not found")
}
var count int64
q := db.WithContext(c.Context()).
Table("stock_logs sl").
Joins("JOIN product_warehouses pw ON pw.id = sl.product_warehouse_id").
Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
Where("sl.id = ?", stockLogID)
q = ApplyScopeFilter(q, scope, "w.location_id")
if err := q.Count(&count).Error; err != nil {
return err
}
if count == 0 {
return fiber.NewError(fiber.StatusNotFound, "Stock log not found")
}
return nil
}
func EnsureMarketingAccess(c *fiber.Ctx, db *gorm.DB, marketingID uint) error {
if marketingID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid marketing id")
}
if db == nil {
return fiber.NewError(fiber.StatusInternalServerError, "Database not configured")
}
scope, err := ResolveLocationScope(c, db)
if err != nil || !scope.Restrict {
return err
}
if len(scope.IDs) == 0 {
return fiber.NewError(fiber.StatusNotFound, "Marketing not found")
}
var count int64
q := db.WithContext(c.Context()).
Table("marketings m").
Joins("JOIN marketing_products mp ON mp.marketing_id = m.id").
Joins("JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id").
Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
Where("m.id = ?", marketingID)
q = ApplyScopeFilter(q, scope, "w.location_id")
if err := q.Count(&count).Error; err != nil {
return err
}
if count == 0 {
return fiber.NewError(fiber.StatusNotFound, "Marketing not found")
}
return nil
}
func EnsureRecordingAccess(c *fiber.Ctx, db *gorm.DB, recordingID uint) error {
if recordingID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid recording id")
}
if db == nil {
return fiber.NewError(fiber.StatusInternalServerError, "Database not configured")
}
scope, err := ResolveLocationScope(c, db)
if err != nil || !scope.Restrict {
return err
}
if len(scope.IDs) == 0 {
return fiber.NewError(fiber.StatusNotFound, "Recording not found")
}
var count int64
q := db.WithContext(c.Context()).
Table("recordings r").
Joins("JOIN project_flock_kandangs pfk ON pfk.id = r.project_flock_kandangs_id").
Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id").
Where("r.id = ?", recordingID)
q = ApplyScopeFilter(q, scope, "pf.location_id")
if err := q.Count(&count).Error; err != nil {
return err
}
if count == 0 {
return fiber.NewError(fiber.StatusNotFound, "Recording not found")
}
return nil
}
func EnsureUniformityAccess(c *fiber.Ctx, db *gorm.DB, uniformityID uint) error {
if uniformityID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid uniformity id")
}
if db == nil {
return fiber.NewError(fiber.StatusInternalServerError, "Database not configured")
}
scope, err := ResolveLocationScope(c, db)
if err != nil || !scope.Restrict {
return err
}
if len(scope.IDs) == 0 {
return fiber.NewError(fiber.StatusNotFound, "Uniformity not found")
}
var count int64
q := db.WithContext(c.Context()).
Table("project_flock_kandang_uniformity u").
Joins("JOIN project_flock_kandangs pfk ON pfk.id = u.project_flock_kandang_id").
Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id").
Where("u.id = ?", uniformityID)
q = ApplyScopeFilter(q, scope, "pf.location_id")
if err := q.Count(&count).Error; err != nil {
return err
}
if count == 0 {
return fiber.NewError(fiber.StatusNotFound, "Uniformity not found")
}
return nil
}
func EnsureLayingTransferAccess(c *fiber.Ctx, db *gorm.DB, transferID uint) error {
if transferID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid transfer id")
}
if db == nil {
return fiber.NewError(fiber.StatusInternalServerError, "Database not configured")
}
scope, err := ResolveLocationScope(c, db)
if err != nil || !scope.Restrict {
return err
}
if len(scope.IDs) == 0 {
return fiber.NewError(fiber.StatusNotFound, "Transfer not found")
}
var count int64
q := db.WithContext(c.Context()).
Table("laying_transfers lt").
Joins("JOIN project_flocks pf_from ON pf_from.id = lt.from_project_flock_id").
Joins("JOIN project_flocks pf_to ON pf_to.id = lt.to_project_flock_id").
Where("lt.id = ?", transferID).
Where("(pf_from.location_id IN ? OR pf_to.location_id IN ?)", scope.IDs, scope.IDs)
if err := q.Count(&count).Error; err != nil {
return err
}
if count == 0 {
return fiber.NewError(fiber.StatusNotFound, "Transfer not found")
}
return nil
}
func EnsureProjectFlockAccess(c *fiber.Ctx, db *gorm.DB, projectFlockID uint) error {
if projectFlockID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid project flock id")
}
if db == nil {
return fiber.NewError(fiber.StatusInternalServerError, "Database not configured")
}
scope, err := ResolveLocationScope(c, db)
if err != nil || !scope.Restrict {
return err
}
if len(scope.IDs) == 0 {
return fiber.NewError(fiber.StatusNotFound, "Project Flock not found")
}
var count int64
if err := ApplyScopeFilter(
db.WithContext(c.Context()).
Model(&entity.ProjectFlock{}).
Where("id = ?", projectFlockID),
scope,
"project_flocks.location_id",
).Count(&count).Error; err != nil {
return err
}
if count == 0 {
return fiber.NewError(fiber.StatusNotFound, "Project Flock not found")
}
return nil
}
func EnsureProjectFlockKandangAccess(c *fiber.Ctx, db *gorm.DB, projectFlockID, projectFlockKandangID uint) error {
if projectFlockKandangID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid project flock kandang id")
}
if db == nil {
return fiber.NewError(fiber.StatusInternalServerError, "Database not configured")
}
scope, err := ResolveLocationScope(c, db)
if err != nil || !scope.Restrict {
return err
}
if len(scope.IDs) == 0 {
return fiber.NewError(fiber.StatusNotFound, "Project Flock Kandang not found")
}
var count int64
q := db.WithContext(c.Context()).
Table("project_flock_kandangs").
Joins("JOIN project_flocks ON project_flocks.id = project_flock_kandangs.project_flock_id").
Where("project_flock_kandangs.id = ?", projectFlockKandangID)
if projectFlockID > 0 {
q = q.Where("project_flock_kandangs.project_flock_id = ?", projectFlockID)
}
q = ApplyScopeFilter(q, scope, "project_flocks.location_id")
if err := q.Count(&count).Error; err != nil {
return err
}
if count == 0 {
return fiber.NewError(fiber.StatusNotFound, "Project Flock Kandang not found")
}
return nil
}
@@ -44,15 +44,6 @@ func (u *ApprovalController) GetAll(c *fiber.Ctx) error {
page := c.QueryInt("page", 1) page := c.QueryInt("page", 1)
limit := c.QueryInt("limit", 10) limit := c.QueryInt("limit", 10)
search := strings.TrimSpace(c.Query("search", "")) search := strings.TrimSpace(c.Query("search", ""))
orderByDate := strings.TrimSpace(c.Query("order_by_date", ""))
if orderByDate == "" {
orderByDate = "DESC"
} else {
orderByDate = strings.ToUpper(orderByDate)
if orderByDate != "ASC" && orderByDate != "DESC" {
return fiber.NewError(fiber.StatusBadRequest, "order_by_date must be either ASC or DESC")
}
}
query := &validation.Query{ query := &validation.Query{
ModuleName: moduleName, ModuleName: moduleName,
@@ -61,7 +52,6 @@ func (u *ApprovalController) GetAll(c *fiber.Ctx) error {
Page: page, Page: page,
Limit: limit, Limit: limit,
Search: search, Search: search,
OrderByDate: orderByDate,
} }
records, totalResults, err := u.ApprovalService.List( records, totalResults, err := u.ApprovalService.List(
@@ -71,7 +61,6 @@ func (u *ApprovalController) GetAll(c *fiber.Ctx) error {
query.Page, query.Page,
query.Limit, query.Limit,
query.Search, query.Search,
query.OrderByDate,
) )
if err != nil { if err != nil {
return err return err
@@ -7,5 +7,4 @@ type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1"` Page int `query:"page" validate:"omitempty,number,min=1"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"` Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"`
Search string `query:"search" validate:"omitempty,max=50"` Search string `query:"search" validate:"omitempty,max=50"`
OrderByDate string `query:"order_by_date" validate:"omitempty,oneof=ASC DESC"`
} }
@@ -28,31 +28,10 @@ func NewClosingController(closingService service.ClosingService, sapronakService
} }
func (u *ClosingController) GetAll(c *fiber.Ctx) error { 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{ query := &validation.Query{
Page: c.QueryInt("page", 1), Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10), Limit: c.QueryInt("limit", 10),
Search: c.Query("search", ""), Search: c.Query("search", ""),
ProjectStatus: projectStatus,
LocationID: locationID,
} }
if query.Page < 1 || query.Limit < 1 { if query.Page < 1 || query.Limit < 1 {
@@ -181,7 +160,7 @@ func (u *ClosingController) GetPenjualan(c *fiber.Ctx) error {
Code: fiber.StatusOK, Code: fiber.StatusOK,
Status: "success", Status: "success",
Message: "Get closing penjualan successfully", 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, Code: fiber.StatusOK,
Status: "success", Status: "success",
Message: "Get closing penjualan by project flock kandang successfully", 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{ query := &validation.ClosingSapronakQuery{
Type: strings.ToLower(c.Query("type")), Type: strings.ToLower(c.Query("type")),
Page: c.QueryInt("page", 1), Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10), Limit: c.QueryInt("limit", 10),
Search: c.Query("search"),
} }
if raw := c.Query("kandang_id"); raw != "" { if raw := c.Query("kandang_id"); raw != "" {
kandangInt, convErr := strconv.Atoi(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 { func (u *ClosingController) GetSapronakByProject(c *fiber.Ctx) error {
param := c.Params("project_flock_id") param := c.Params("project_flock_id")
flag := c.Query("flag", "") flag := c.Query("flag", "")
@@ -347,12 +286,12 @@ func (u *ClosingController) GetSapronakByProject(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_id") return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_id")
} }
result, productFlags, err := u.SapronakService.GetSapronakByProject(c, uint(projectID), flag) result, err := u.SapronakService.GetSapronakByProject(c, uint(projectID), flag)
if err != nil { if err != nil {
return err return err
} }
payload := dto.ToSapronakProjectAggregatedFromReports(result, flag, productFlags) payload := dto.ToSapronakProjectAggregatedFromReports(result, flag)
return c.Status(fiber.StatusOK). return c.Status(fiber.StatusOK).
JSON(response.Success{ JSON(response.Success{
@@ -377,12 +316,12 @@ func (u *ClosingController) GetSapronakByKandang(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_kandang_id") return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_kandang_id")
} }
result, productFlags, err := u.SapronakService.GetSapronakByKandang(c, uint(projectID), uint(pfkID), flag) result, err := u.SapronakService.GetSapronakByKandang(c, uint(projectID), uint(pfkID), flag)
if err != nil { if err != nil {
return err return err
} }
payload := dto.ToSapronakProjectAggregatedFromReport(result, flag, productFlags) payload := dto.ToSapronakProjectAggregatedFromReport(result, flag)
return c.Status(fiber.StatusOK). return c.Status(fiber.StatusOK).
JSON(response.Success{ JSON(response.Success{
+20 -20
View File
@@ -98,26 +98,26 @@ type ClosingEggSalesDTO struct {
} }
type ClosingPerformanceDTO struct { type ClosingPerformanceDTO struct {
Depletion float64 `json:"depletion"` Depletion float64 `json:"depletion"`
Age float64 `json:"age_day"` Age float64 `json:"age_day"`
MortalityStd float64 `json:"mor_std"` MortalityStd float64 `json:"mor_std"`
MortalityAct float64 `json:"mor_act"` MortalityAct float64 `json:"mor_act"`
DeffMortality float64 `json:"mor_diff"` DeffMortality float64 `json:"mor_diff"`
FcrStd float64 `json:"fcr_std"` FcrStd float64 `json:"fcr_std"`
FcrAct float64 `json:"fcr_act"` FcrAct float64 `json:"fcr_act"`
DeffFcr float64 `json:"fcr_diff"` DeffFcr float64 `json:"fcr_diff"`
AwgAct float64 `json:"awg_act"` AwgAct float64 `json:"awg_act"`
AwgStd float64 `json:"awg_std"` AwgStd float64 `json:"awg_std"`
FeedIntake float64 `json:"feed_intake"` FeedIntake float64 `json:"feed_intake"`
FeedIntakeStd float64 `json:"feed_intake_std"` FeedIntakeStd float64 `json:"feed_intake_std"`
HenDayAct float64 `json:"hen_day_act,omitempty"` HenDayAct *float64 `json:"hen_day_act,omitempty"`
HendayStd float64 `json:"hen_day_std"` HendayStd float64 `json:"hen_day_std"`
EggMass float64 `json:"egg_mass,omitempty"` EggMass *float64 `json:"egg_mass,omitempty"`
EggMassStd float64 `json:"egg_mass_std"` EggMassStd float64 `json:"egg_mass_std"`
EggWeight float64 `json:"egg_weight,omitempty"` EggWeight *float64 `json:"egg_weight,omitempty"`
EggWeightStd float64 `json:"egg_weight_std"` EggWeightStd float64 `json:"egg_weight_std"`
HenHouseAct float64 `json:"hen_housed_act,omitempty"` HenHouseAct *float64 `json:"hen_housed_act,omitempty"`
HenHouseStd float64 `json:"hen_housed_std"` HenHouseStd float64 `json:"hen_housed_std"`
} }
type ClosingSalesGroupDTO struct { type ClosingSalesGroupDTO struct {
@@ -1,12 +1,8 @@
package dto package dto
import ( // === CLOSING KEUANGAN CODES ===
"strings"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
)
// Closing HPP Codes
type ClosingHPPCode string type ClosingHPPCode string
const ( const (
@@ -18,30 +14,36 @@ const (
HPPCodeEkspedisi ClosingHPPCode = "EKSPEDISI" HPPCodeEkspedisi ClosingHPPCode = "EKSPEDISI"
) )
// Closing Profit Loss Codes
type ClosingProfitLossCode string type ClosingProfitLossCode string
const ( const (
PLCodeSales ClosingProfitLossCode = "SALES" PLCodeSales ClosingProfitLossCode = "SALES"
PLCodeSapronak ClosingProfitLossCode = "SAPRONAK" PLCodeSapronak ClosingProfitLossCode = "SAPRONAK"
PLCodeOverhead ClosingProfitLossCode = "OVERHEAD" PLCodeOverhead ClosingProfitLossCode = "OVERHEAD"
PLCodeEkspedisi ClosingProfitLossCode = "EKSPEDISI" PLCodeEkspedisi ClosingProfitLossCode = "EKSPEDISI"
) )
// === NEW CLOSING KEUANGAN DTO ===
// FinancialMetrics represents financial metrics with per unit and total amounts
type FinancialMetrics struct { type FinancialMetrics struct {
RpPerBird float64 `json:"rp_per_bird"` RpPerBird float64 `json:"rp_per_bird"`
RpPerKg float64 `json:"rp_per_kg"` RpPerKg float64 `json:"rp_per_kg"`
Amount float64 `json:"amount"` Amount float64 `json:"amount"`
} }
// HPPItem represents an item in HPP section
type HPPItem struct { type HPPItem struct {
ID uint `json:"id"` ID uint `json:"id"`
Category string `json:"category"` Category string `json:"category"` // "purchase" or "overhead"
Code string `json:"code"` Code string `json:"code"` // "PAKAN", "OVK", "DOC", "EKSPEDISI"
Label string `json:"label"` Label string `json:"label"`
Budgeting FinancialMetrics `json:"budgeting"` Budgeting FinancialMetrics `json:"budgeting"`
Realization FinancialMetrics `json:"realization"` Realization FinancialMetrics `json:"realization"`
} }
// HPPSummary represents summary for HPP section
type HPPSummary struct { type HPPSummary struct {
Label string `json:"label"` Label string `json:"label"`
Budgeting FinancialMetrics `json:"budgeting"` Budgeting FinancialMetrics `json:"budgeting"`
@@ -50,41 +52,52 @@ type HPPSummary struct {
EggRealization *FinancialMetrics `json:"egg_realization,omitempty"` EggRealization *FinancialMetrics `json:"egg_realization,omitempty"`
} }
// HPPSection represents HPP data section
type HPPSection struct { type HPPSection struct {
Items []HPPItem `json:"items"` Items []HPPItem `json:"items"`
Summary HPPSummary `json:"summary"` Summary HPPSummary `json:"summary"`
} }
// ProfitLossItem represents an item in Profit & Loss section
type ProfitLossItem struct { type ProfitLossItem struct {
Code string `json:"code"` Code string `json:"code"` // "SALES", "PURCHASE_DOC", "OVERHEAD", "EKSPEDISI"
Label string `json:"label"` Label string `json:"label"`
Type string `json:"type"` Type string `json:"type"` // "income", "purchase", "overhead"
RpPerBird float64 `json:"rp_per_bird"` RpPerBird float64 `json:"rp_per_bird"`
RpPerKg float64 `json:"rp_per_kg"` RpPerKg float64 `json:"rp_per_kg"`
Amount float64 `json:"amount"` Amount float64 `json:"amount"`
} }
// ProfitLossSummary represents summary for Profit & Loss section
type ProfitLossSummary struct { type ProfitLossSummary struct {
GrossProfit FinancialMetrics `json:"gross_profit"` GrossProfit FinancialMetrics `json:"gross_profit"`
SubTotal FinancialMetrics `json:"sub_total"` SubTotal FinancialMetrics `json:"sub_total"`
NetProfit FinancialMetrics `json:"net_profit"` NetProfit FinancialMetrics `json:"net_profit"`
} }
// ProfitLossSection represents Profit & Loss data section
type ProfitLossSection struct { type ProfitLossSection struct {
Items []ProfitLossItem `json:"items"` Items []ProfitLossItem `json:"items"`
Summary ProfitLossSummary `json:"summary"` Summary ProfitLossSummary `json:"summary"`
} }
// ClosingKeuanganData represents the main data structure
type ClosingKeuanganData struct { type ClosingKeuanganData struct {
HPP HPPSection `json:"hpp"` HPP HPPSection `json:"hpp"`
ProfitLoss ProfitLossSection `json:"profit_loss"` ProfitLoss ProfitLossSection `json:"profit_loss"`
} }
type MetricsCalculator struct {
TotalPopulation float64 // ClosingKeuanganResponse represents the full API response
ActualPopulation float64 type ClosingKeuanganResponse struct {
TotalWeightProduced float64 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 { func ToFinancialMetrics(rpPerBird, rpPerKg, amount float64) FinancialMetrics {
return FinancialMetrics{ return FinancialMetrics{
RpPerBird: rpPerBird, 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 { func ToHPPItem(id uint, category, code, label string, budgeting, realization FinancialMetrics) HPPItem {
return HPPItem{ return HPPItem{
ID: id, 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 { func ToHPPSummary(label string, budgeting, realization FinancialMetrics, eggBudgeting, eggRealization *FinancialMetrics) HPPSummary {
return HPPSummary{ return HPPSummary{
Label: label, 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 { func ToHPPSection(items []HPPItem, summary HPPSummary) HPPSection {
return HPPSection{ return HPPSection{
Items: items, 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 { func ToProfitLossItem(code, label, itemType string, rpPerBird, rpPerKg, amount float64) ProfitLossItem {
return ProfitLossItem{ return ProfitLossItem{
Code: code, 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 { func ToProfitLossSummary(grossProfit, subTotal, netProfit FinancialMetrics) ProfitLossSummary {
return ProfitLossSummary{ return ProfitLossSummary{
GrossProfit: grossProfit, 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 { func ToProfitLossSection(items []ProfitLossItem, summary ProfitLossSummary) ProfitLossSection {
return ProfitLossSection{ return ProfitLossSection{
Items: items, 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 { func ToClosingKeuanganData(hpp HPPSection, profitLoss ProfitLossSection) ClosingKeuanganData {
return ClosingKeuanganData{ return ClosingKeuanganData{
HPP: hpp, HPP: hpp,
@@ -154,72 +174,12 @@ func ToClosingKeuanganData(hpp HPPSection, profitLoss ProfitLossSection) Closing
} }
} }
func (mc *MetricsCalculator) CalculateMetrics(amount float64) (rpPerBird, rpPerKg float64) { // ToSuccessClosingKeuanganResponse creates success response
if mc.ActualPopulation > 0 { func ToSuccessClosingKeuanganResponse(data ClosingKeuanganData) ClosingKeuanganResponse {
rpPerBird = amount / mc.ActualPopulation 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" customerDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/dto"
kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/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" productDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/dto"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
) )
// === Response DTO === // === Response DTO ===
type SalesDTO struct { type SalesDTO struct {
Id uint `json:"id"` Id uint `json:"id"`
RealizationDate time.Time `json:"realization_date"` RealizationDate time.Time `json:"realization_date"`
Age int `json:"age"` Age int `json:"age"`
Week int `json:"week"` DoNumber string `json:"do_number"`
DoNumber string `json:"do_number"` Product *productDTO.ProductRelationDTO `json:"product,omitempty"`
Product *productDTO.ProductRelationDTO `json:"product,omitempty"` Customer *customerDTO.CustomerRelationDTO `json:"customer,omitempty"`
Customer *customerDTO.CustomerRelationDTO `json:"customer,omitempty"` Qty float64 `json:"qty"`
Qty float64 `json:"qty"` Weight float64 `json:"weight"`
Weight float64 `json:"weight"` AvgWeight float64 `json:"avg_weight"`
AvgWeight float64 `json:"avg_weight"` Price float64 `json:"price"`
SalesPrice float64 `json:"sales_price"` TotalPrice float64 `json:"total_price"`
TotalSalesPrice float64 `json:"total_sales_price"` Kandang *kandangDTO.KandangRelationDTO `json:"kandang,omitempty"`
ActualPrice float64 `json:"actual_price"` PaymentStatus string `json:"payment_status"`
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"`
} }
type PenjualanRealisasiResponseDTO struct { type PenjualanRealisasiResponseDTO struct {
Sales []SalesDTO `json:"sales"` Sales []SalesDTO `json:"sales"`
Summary SummaryDTO `json:"summary"`
} }
// === Mapper Functions === // === Mapper Functions ===
func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO { func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO {
productFlags := make([]string, len(e.MarketingProduct.ProductWarehouse.Product.Flags)) age := calculateAgeFromChickin(e.MarketingProduct.ProductWarehouse.ProjectFlockKandang, e.DeliveryDate)
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)
var product *productDTO.ProductRelationDTO var product *productDTO.ProductRelationDTO
if e.MarketingProduct.ProductWarehouse.Product.Id != 0 { if e.MarketingProduct.ProductWarehouse.Product.Id != 0 {
@@ -83,69 +63,19 @@ func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO {
doNumber := deliveryOrdersDTO.GenerateDeliveryOrderNumber(e.MarketingProduct.Marketing.SoNumber, e.DeliveryDate, e.MarketingProduct.ProductWarehouse.Warehouse.Id) doNumber := deliveryOrdersDTO.GenerateDeliveryOrderNumber(e.MarketingProduct.Marketing.SoNumber, e.DeliveryDate, e.MarketingProduct.ProductWarehouse.Warehouse.Id)
return SalesDTO{ return SalesDTO{
Id: e.Id, Id: e.Id,
RealizationDate: realizationDate, RealizationDate: realizationDate,
Age: ageInDay, Age: age,
Week: ageInWeeks, DoNumber: doNumber,
DoNumber: doNumber, Product: product,
Product: product, Customer: customer,
Customer: customer, Qty: e.UsageQty,
Qty: e.UsageQty, Weight: e.TotalWeight,
Weight: e.TotalWeight, AvgWeight: e.AvgWeight,
AvgWeight: e.AvgWeight, Price: e.UnitPrice,
SalesPrice: e.MarketingProduct.UnitPrice, TotalPrice: e.TotalPrice,
TotalSalesPrice: e.MarketingProduct.TotalPrice, Kandang: kandang,
ActualPrice: e.UnitPrice, PaymentStatus: "Paid",
TotalActualPrice: e.TotalPrice,
Kandang: kandang,
}
}
func ToSalesAgeDTO(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, _ := calculateAgeFromChickin(e.MarketingProduct.ProductWarehouse.ProjectFlockKandang, e.DeliveryDate, productFlags, category)
return SalesDTO{
Age: ageInDay,
Qty: e.UsageQty,
}
}
func ToSummaryDto(e []entity.MarketingDeliveryProduct) SummaryDTO {
var totalSalesPrice, totalActualPrice, sumSales, sumActual float64
count := len(e)
if count == 0 {
return SummaryDTO{
TotalSalesPrice: 0,
TotalActualPrice: 0,
AvgSalesPrice: 0,
AvgActualPrice: 0,
}
}
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),
} }
} }
@@ -157,36 +87,29 @@ func ToSalesDTOs(e []entity.MarketingDeliveryProduct) []SalesDTO {
return result return result
} }
func ToPenjualanRealisasiResponseDTO(e []entity.MarketingDeliveryProduct) PenjualanRealisasiResponseDTO { func ToPenjualanRealisasiResponseDTO(projectFlockID uint, e []entity.MarketingDeliveryProduct) PenjualanRealisasiResponseDTO {
return 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) { func extractPeriodFromRealisasi(realisasi []entity.MarketingDeliveryProduct) int {
if projectFlockKandang == nil || deliveryDate == nil || len(projectFlockKandang.Chickins) == 0 { if len(realisasi) > 0 {
return 0, 0 for _, item := range realisasi {
} if item.MarketingProduct.ProductWarehouse.ProjectFlockKandang != nil {
return item.MarketingProduct.ProductWarehouse.ProjectFlockKandang.Period
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
} }
} }
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 earliestChickinDate := projectFlockKandang.Chickins[0].ChickInDate
for _, chickin := range projectFlockKandang.Chickins { for _, chickin := range projectFlockKandang.Chickins {
@@ -195,20 +118,7 @@ func calculateAgeFromChickin(projectFlockKandang *entity.ProjectFlockKandang, de
} }
} }
diff := deliveryDate.Sub(earliestChickinDate) ageInDays := int(deliveryDate.Sub(earliestChickinDate).Hours() / 24)
ageInDays := int(diff.Hours() / 24) ageInWeeks := ageInDays / 7
return ageInWeeks
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
} }
@@ -1,7 +1,6 @@
package dto package dto
import ( import (
"sort"
"strings" "strings"
"time" "time"
) )
@@ -115,20 +114,9 @@ type ClosingSapronakDTO struct {
OutgoingSapronak []ClosingSapronakItemDTO `json:"outgoing_sapronak"` 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 === // === Mapper Functions for Aggregated Sapronak Response ===
func ToSapronakProjectAggregatedFromReports(reports []SapronakReportDTO, flag string, productFlags map[uint][]string) SapronakProjectAggregatedDTO { func ToSapronakProjectAggregatedFromReports(reports []SapronakReportDTO, flag string) SapronakProjectAggregatedDTO {
result := SapronakProjectAggregatedDTO{} result := SapronakProjectAggregatedDTO{}
if len(reports) == 0 { if len(reports) == 0 {
@@ -136,10 +124,10 @@ func ToSapronakProjectAggregatedFromReports(reports []SapronakReportDTO, flag st
} }
rep := reports[0] rep := reports[0]
return ToSapronakProjectAggregatedFromReport(&rep, flag, productFlags) return ToSapronakProjectAggregatedFromReport(&rep, flag)
} }
func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag string, productFlags map[uint][]string) SapronakProjectAggregatedDTO { func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag string) SapronakProjectAggregatedDTO {
result := SapronakProjectAggregatedDTO{} result := SapronakProjectAggregatedDTO{}
if report == nil { if report == nil {
@@ -176,53 +164,6 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
return t.Format("02-Jan-2006") return t.Format("02-Jan-2006")
} }
flagOrder := map[string]int{
"DOC": 0,
"PAKAN": 0,
"OVK": 0,
"PULLET": 0,
}
buildFlagList := func(productID uint, fallback string) string {
rawFlags := productFlags[productID]
if len(rawFlags) == 0 {
if fallback == "" {
return ""
}
return fallback
}
seen := make(map[string]struct{}, len(rawFlags))
ordered := make([]string, 0, len(rawFlags))
for _, f := range rawFlags {
flagName := strings.ToUpper(strings.TrimSpace(f))
if flagName == "" {
continue
}
if _, ok := seen[flagName]; ok {
continue
}
seen[flagName] = struct{}{}
ordered = append(ordered, flagName)
}
sort.SliceStable(ordered, func(i, j int) bool {
li := ordered[i]
lj := ordered[j]
ri, iok := flagOrder[li]
rj, jok := flagOrder[lj]
if iok != jok {
if iok {
return true
}
return false
}
if iok && jok && ri != rj {
return ri < rj
}
return li < lj
})
return strings.Join(ordered, " ")
}
for _, group := range report.Groups { for _, group := range report.Groups {
flagKey := normalizeFlag(group.Flag) flagKey := normalizeFlag(group.Flag)
ptr := byFlag[flagKey] ptr := byFlag[flagKey]
@@ -244,17 +185,13 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
} }
for idx, item := range group.Items { for idx, item := range group.Items {
refKey := strings.TrimSpace(item.NoReferensi) productKey := strings.ToUpper(flagKey + "|" + item.ProductName)
productKey := strings.ToUpper(flagKey + "|" + item.ProductName + "|" + refKey)
if refKey == "" {
productKey = strings.ToUpper(flagKey + "|" + item.ProductName + "|" + formatDate(item.Tanggal))
}
baseRow := SapronakCategoryRowDTO{ baseRow := SapronakCategoryRowDTO{
ID: idx + 1, ID: idx + 1,
Date: formatDate(item.Tanggal), Date: formatDate(item.Tanggal),
ReferenceNumber: item.NoReferensi, ReferenceNumber: item.NoReferensi,
Description: item.ProductName, Description: item.ProductName,
ProductCategory: buildFlagList(item.ProductID, flagKey), ProductCategory: item.ProductName,
UnitPrice: item.Harga, UnitPrice: item.Harga,
Notes: "-", Notes: "-",
} }
@@ -264,51 +201,18 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
switch strings.ToLower(item.JenisTransaksi) { switch strings.ToLower(item.JenisTransaksi) {
case "pembelian", "adjustment masuk", "mutasi masuk": case "pembelian", "adjustment masuk", "mutasi masuk":
row.QtyIn += item.QtyMasuk row.QtyIn += item.QtyMasuk
if item.Tanggal != nil { row.TotalAmount += item.Nilai
row.Date = formatDate(item.Tanggal) case "pemakaian", "adjustment keluar":
}
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"
}
}
case "pemakaian":
price := row.UnitPrice
if price == 0 {
price = item.Harga
}
row.QtyUsed += item.QtyKeluar row.QtyUsed += item.QtyKeluar
row.TotalAmount += item.QtyKeluar * price case "mutasi keluar":
case "adjustment keluar", "mutasi keluar", "penjualan":
price := row.UnitPrice
if price == 0 {
price = item.Harga
}
row.QtyOut += item.QtyKeluar 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: default:
row.QtyIn += item.QtyMasuk row.QtyIn += item.QtyMasuk
row.TotalAmount += item.Nilai row.TotalAmount += item.Nilai
if row.QtyIn > 0 { }
row.UnitPrice = row.TotalAmount / row.QtyIn
} if row.QtyIn > 0 {
row.UnitPrice = row.TotalAmount / row.QtyIn
} }
} }
@@ -329,8 +233,8 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
total += r.TotalAmount total += r.TotalAmount
} }
avg := 0.0 avg := 0.0
if qtyUsed > 0 { if qtyIn > 0 {
avg = total / qtyUsed avg = total / qtyIn
} }
cat.Total = SapronakCategoryTotalDTO{ cat.Total = SapronakCategoryTotalDTO{
Label: label, 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) { func (ClosingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) {
closingRepo := rClosing.NewClosingRepository(db) closingRepo := rClosing.NewClosingRepository(db)
closingKeuanganRepo := rClosing.NewClosingKeuanganRepository(db)
userRepo := rUser.NewUserRepository(db) userRepo := rUser.NewUserRepository(db)
projectFlockRepo := rProjectFlock.NewProjectflockRepository(db) projectFlockRepo := rProjectFlock.NewProjectflockRepository(db)
projectFlockKandangRepo := rProjectFlock.NewProjectFlockKandangRepository(db) projectFlockKandangRepo := rProjectFlock.NewProjectFlockKandangRepository(db)
@@ -39,11 +40,9 @@ func (ClosingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *
purchaseRepo := rPurchase.NewPurchaseRepository(db) purchaseRepo := rPurchase.NewPurchaseRepository(db)
approvalRepo := commonRepo.NewApprovalRepository(db) approvalRepo := commonRepo.NewApprovalRepository(db)
approvalService := commonSvc.NewApprovalService(approvalRepo) 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) 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) sapronakService := sClosing.NewSapronakService(closingRepo, projectFlockKandangRepo, validate)
userService := sUser.NewUserService(userRepo, validate) userService := sUser.NewUserService(userRepo, validate)
@@ -17,13 +17,13 @@ import (
type ClosingRepository interface { type ClosingRepository interface {
repository.BaseRepository[entity.ProjectFlock] repository.BaseRepository[entity.ProjectFlock]
GetSapronak(ctx context.Context, params SapronakQueryParams) ([]SapronakRow, int64, error) 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) SumFeedPurchaseAndUsedByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) (float64, float64, error)
SumProjectChickinUsageByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) SumProjectChickinUsageByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) (float64, error)
SumClaimCullingByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) SumClaimCullingByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) (float64, error)
SumMarketingWeightAndQtyByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) (float64, float64, float64, error) SumMarketingWeightAndQtyByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) (float64, float64, float64, error)
SumMarketingWeightAndQtyByProjectFlockKandangIDsAndFlagNames(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string) (float64, float64, float64, error) SumMarketingWeightAndQtyByProjectFlockKandangIDsAndFlagNames(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string) (float64, float64, float64, error)
SumRecordingEggQtyByProjectFlockKandangIDsAndFlagNames(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string) (float64, error) SumRecordingEggQtyByProjectFlockKandangIDsAndFlagNames(ctx context.Context, projectFlockKandangIDs []uint, flagNames []string) (float64, error)
GetFcrStandardsByFcrID(ctx context.Context, fcrID uint) ([]entity.FcrStandard, error)
GetExpeditionHPP(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]ExpeditionHPPRow, error) GetExpeditionHPP(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]ExpeditionHPPRow, error)
FetchSapronakIncoming(ctx context.Context, kandangID uint) ([]SapronakIncomingRow, error) FetchSapronakIncoming(ctx context.Context, kandangID uint) ([]SapronakIncomingRow, error)
FetchSapronakIncomingDetails(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, error) FetchSapronakIncomingDetails(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, error)
@@ -31,11 +31,8 @@ type ClosingRepository interface {
FetchSapronakUsageDetails(ctx context.Context, pfkID uint) (map[uint][]SapronakDetailRow, error) FetchSapronakUsageDetails(ctx context.Context, pfkID uint) (map[uint][]SapronakDetailRow, error)
FetchSapronakChickinUsage(ctx context.Context, pfkID uint) ([]SapronakUsageRow, error) FetchSapronakChickinUsage(ctx context.Context, pfkID uint) ([]SapronakUsageRow, error)
FetchSapronakChickinUsageDetails(ctx context.Context, pfkID uint) (map[uint][]SapronakDetailRow, 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) 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) FetchSapronakTransfers(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error)
FetchSapronakSales(ctx context.Context, projectFlockKandangID uint) (map[uint][]SapronakDetailRow, error)
FetchSapronakSalesAllocatedDetails(ctx context.Context, projectFlockKandangID uint) (map[uint][]SapronakDetailRow, error)
GetProductsWithFlagsByIDs(ctx context.Context, productIDs []uint) ([]entity.Product, error) GetProductsWithFlagsByIDs(ctx context.Context, productIDs []uint) ([]entity.Product, error)
} }
@@ -62,18 +59,10 @@ type SapronakRow struct {
DestinationWarehouse string `gorm:"column:destination_warehouse"` DestinationWarehouse string `gorm:"column:destination_warehouse"`
Destination string `gorm:"column:destination"` Destination string `gorm:"column:destination"`
Quantity float64 `gorm:"column:quantity"` Quantity float64 `gorm:"column:quantity"`
UnitID uint `gorm:"column:unit_id"`
Unit string `gorm:"column:unit"` Unit string `gorm:"column:unit"`
Notes string `gorm:"column:notes"` 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 { type ExpeditionHPPRow struct {
SupplierName string `gorm:"column:supplier_name"` SupplierName string `gorm:"column:supplier_name"`
TotalAmount float64 `gorm:"column:total_amount"` TotalAmount float64 `gorm:"column:total_amount"`
@@ -85,7 +74,6 @@ type SapronakQueryParams struct {
ProjectFlockKandangIDs []uint ProjectFlockKandangIDs []uint
Limit int Limit int
Offset int Offset int
Search string
} }
func (r *ClosingRepositoryImpl) GetSapronak(ctx context.Context, params SapronakQueryParams) ([]SapronakRow, int64, error) { func (r *ClosingRepositoryImpl) GetSapronak(ctx context.Context, params SapronakQueryParams) ([]SapronakRow, int64, error) {
@@ -101,12 +89,12 @@ func (r *ClosingRepositoryImpl) GetSapronak(ctx context.Context, params Sapronak
if len(params.WarehouseIDs) == 0 { if len(params.WarehouseIDs) == 0 {
return []SapronakRow{}, 0, nil return []SapronakRow{}, 0, nil
} }
unionParts = append(unionParts, sapronakIncomingPurchasesSQL, sapronakIncomingTransfersSQL, sapronakIncomingAdjustmentsSQL) unionParts = append(unionParts, sapronakIncomingPurchasesSQL, sapronakIncomingTransfersSQL)
args = append(args, params.WarehouseIDs, params.WarehouseIDs, params.WarehouseIDs) args = append(args, params.WarehouseIDs, params.WarehouseIDs)
case validation.SapronakTypeOutgoing: case validation.SapronakTypeOutgoing:
if len(params.WarehouseIDs) > 0 { if len(params.WarehouseIDs) > 0 {
unionParts = append(unionParts, sapronakOutgoingTransfersSQL, sapronakOutgoingAdjustmentsSQL) unionParts = append(unionParts, sapronakOutgoingTransfersSQL)
args = append(args, params.WarehouseIDs, params.WarehouseIDs) args = append(args, params.WarehouseIDs)
} }
if len(params.ProjectFlockKandangIDs) > 0 { if len(params.ProjectFlockKandangIDs) > 0 {
unionParts = append(unionParts, sapronakOutgoingMarketingsSQL) unionParts = append(unionParts, sapronakOutgoingMarketingsSQL)
@@ -121,36 +109,14 @@ func (r *ClosingRepositoryImpl) GetSapronak(ctx context.Context, params Sapronak
unionSQL := strings.Join(unionParts, " UNION ALL ") 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 var totalResults int64
countSQL := fmt.Sprintf("SELECT COUNT(*) FROM (%s) AS combined%s", unionSQL, searchClause) countSQL := fmt.Sprintf("SELECT COUNT(*) FROM (%s) AS combined", unionSQL)
countArgs := append(append([]any{}, args...), searchArgs...) if err := db.Raw(countSQL, args...).Scan(&totalResults).Error; err != nil {
if err := db.Raw(countSQL, countArgs...).Scan(&totalResults).Error; err != nil {
return nil, 0, err return nil, 0, err
} }
dataArgs := append(append([]any{}, args...), searchArgs...) dataArgs := append(append([]any{}, args...), params.Limit, params.Offset)
dataArgs = append(dataArgs, params.Limit, params.Offset) dataSQL := fmt.Sprintf("SELECT * FROM (%s) AS combined ORDER BY sort_date ASC, id ASC LIMIT ? OFFSET ?", unionSQL)
dataSQL := fmt.Sprintf("SELECT * FROM (%s) AS combined%s ORDER BY sort_date ASC, id ASC LIMIT ? OFFSET ?", unionSQL, searchClause)
var rows []SapronakRow var rows []SapronakRow
if err := db.Raw(dataSQL, dataArgs...).Scan(&rows).Error; err != nil { 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 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, sapronakIncomingAdjustmentsSQL)
args = append(args, params.WarehouseIDs, params.WarehouseIDs, params.WarehouseIDs)
case validation.SapronakTypeOutgoing:
if len(params.WarehouseIDs) > 0 {
unionParts = append(unionParts, sapronakOutgoingTransfersSQL, sapronakOutgoingAdjustmentsSQL)
args = append(args, params.WarehouseIDs, 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) { func (r *ClosingRepositoryImpl) SumFeedPurchaseAndUsedByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) (float64, float64, error) {
if len(projectFlockKandangIDs) == 0 { if len(projectFlockKandangIDs) == 0 {
return 0, 0, nil return 0, 0, nil
@@ -355,10 +248,9 @@ func (r *ClosingRepositoryImpl) SumMarketingWeightAndQtyByProjectFlockKandangIDs
Joins("JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id"). Joins("JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id").
Joins("JOIN products prod ON prod.id = pw.product_id"). Joins("JOIN products prod ON prod.id = pw.product_id").
Joins("JOIN flags f ON f.flagable_id = prod.id AND f.flagable_type = ?", "products"). Joins("JOIN flags f ON f.flagable_id = prod.id AND f.flagable_type = ?", "products").
Joins("JOIN marketing_delivery_products mdp ON mdp.marketing_product_id = mp.id").
Where("pw.project_flock_kandang_id IN ?", projectFlockKandangIDs). Where("pw.project_flock_kandang_id IN ?", projectFlockKandangIDs).
Where("f.name IN ?", flagNames). Where("f.name IN ?", flagNames).
Select("COALESCE(SUM(mdp.total_weight), 0) AS total_weight, COALESCE(SUM(mdp.usage_qty), 0) AS total_qty, COALESCE(SUM(mdp.total_price), 0) AS total_price"). Select("COALESCE(SUM(mp.total_weight), 0) AS total_weight, COALESCE(SUM(mp.qty), 0) AS total_qty, COALESCE(SUM(mp.total_price), 0) AS total_price").
Scan(&agg).Error Scan(&agg).Error
if err != nil { if err != nil {
return 0, 0, 0, err return 0, 0, 0, err
@@ -392,6 +284,22 @@ func (r *ClosingRepositoryImpl) SumRecordingEggQtyByProjectFlockKandangIDsAndFla
return agg.TotalQty, nil return agg.TotalQty, nil
} }
func (r *ClosingRepositoryImpl) GetFcrStandardsByFcrID(ctx context.Context, fcrID uint) ([]entity.FcrStandard, error) {
if fcrID == 0 {
return []entity.FcrStandard{}, nil
}
var standards []entity.FcrStandard
if err := r.DB().WithContext(ctx).
Where("fcr_id = ?", fcrID).
Order("weight ASC").
Find(&standards).Error; err != nil {
return nil, err
}
return standards, nil
}
func (r *ClosingRepositoryImpl) GetExpeditionHPP(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]ExpeditionHPPRow, error) { func (r *ClosingRepositoryImpl) GetExpeditionHPP(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]ExpeditionHPPRow, error) {
db := r.DB().WithContext(ctx) db := r.DB().WithContext(ctx)
@@ -439,7 +347,7 @@ SELECT
COALESCE(pi.received_date, '1970-01-01') AS sort_date, COALESCE(pi.received_date, '1970-01-01') AS sort_date,
COALESCE(TO_CHAR(pi.received_date, 'DD-Mon-YYYY'), '') AS date_text, COALESCE(TO_CHAR(pi.received_date, 'DD-Mon-YYYY'), '') AS date_text,
COALESCE(p.po_number, '') AS reference_number, COALESCE(p.po_number, '') AS reference_number,
'Pembelian' AS transaction_type, 'Purchase' AS transaction_type,
prod.name AS product_name, prod.name AS product_name,
COALESCE(( COALESCE((
SELECT string_agg( SELECT string_agg(
@@ -471,7 +379,6 @@ SELECT
w.name AS destination_warehouse, w.name AS destination_warehouse,
'' AS destination, '' AS destination,
pi.total_qty AS quantity, pi.total_qty AS quantity,
u.id AS unit_id,
u.name AS unit, u.name AS unit,
COALESCE(p.notes, '') AS notes COALESCE(p.notes, '') AS notes
FROM purchase_items pi FROM purchase_items pi
@@ -488,7 +395,7 @@ SELECT
st.transfer_date AS sort_date, st.transfer_date AS sort_date,
TO_CHAR(st.transfer_date, 'DD-Mon-YYYY') AS date_text, TO_CHAR(st.transfer_date, 'DD-Mon-YYYY') AS date_text,
st.movement_number AS reference_number, st.movement_number AS reference_number,
'Mutasi' AS transaction_type, 'Internal Transfer In' AS transaction_type,
prod.name AS product_name, prod.name AS product_name,
COALESCE(( COALESCE((
SELECT string_agg( SELECT string_agg(
@@ -520,9 +427,8 @@ SELECT
COALESCE(tw.name, '') AS destination_warehouse, COALESCE(tw.name, '') AS destination_warehouse,
'' AS destination, '' AS destination,
std.usage_qty AS quantity, std.usage_qty AS quantity,
u.id AS unit_id,
u.name AS unit, u.name AS unit,
st.reason AS notes 'Stock Refill' AS notes
FROM stock_transfer_details std FROM stock_transfer_details std
JOIN stock_transfers st ON st.id = std.stock_transfer_id JOIN stock_transfers st ON st.id = std.stock_transfer_id
LEFT JOIN warehouses fw ON fw.id = st.from_warehouse_id LEFT JOIN warehouses fw ON fw.id = st.from_warehouse_id
@@ -532,63 +438,13 @@ JOIN uoms u ON u.id = prod.uom_id
WHERE st.to_warehouse_id IN ? WHERE st.to_warehouse_id IN ?
` `
sapronakIncomingAdjustmentsSQL = `
SELECT
CAST(ast.id AS BIGINT) AS id,
ast.created_at AS sort_date,
COALESCE(TO_CHAR(ast.created_at, 'DD-Mon-YYYY'), '') AS date_text,
COALESCE(ast.adj_number, '') AS reference_number,
'Adjustment stock' AS transaction_type,
prod.name AS product_name,
COALESCE((
SELECT string_agg(
f.name,
' ' ORDER BY
CASE
WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0
ELSE 1
END,
f.name
)
FROM flags f
WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id
), '') AS product_category,
COALESCE((
SELECT string_agg(
f.name,
' ' ORDER BY
CASE
WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0
ELSE 1
END,
f.name
)
FROM flags f
WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id
), '') AS product_sub_category,
'-' AS source_warehouse,
COALESCE(w.name, '') AS destination_warehouse,
'' AS destination,
COALESCE(ast.total_qty, 0) AS quantity,
u.id AS unit_id,
u.name AS unit,
'-' AS notes
FROM adjustment_stocks ast
JOIN product_warehouses pw ON pw.id = ast.product_warehouse_id
JOIN warehouses w ON w.id = pw.warehouse_id
JOIN products prod ON prod.id = pw.product_id
JOIN uoms u ON u.id = prod.uom_id
WHERE pw.warehouse_id IN ?
AND COALESCE(ast.total_qty, 0) <> 0
`
sapronakOutgoingTransfersSQL = ` sapronakOutgoingTransfersSQL = `
SELECT SELECT
CAST(st.id AS BIGINT) AS id, CAST(st.id AS BIGINT) AS id,
st.transfer_date AS sort_date, st.transfer_date AS sort_date,
TO_CHAR(st.transfer_date, 'DD-Mon-YYYY') AS date_text, TO_CHAR(st.transfer_date, 'DD-Mon-YYYY') AS date_text,
st.movement_number AS reference_number, st.movement_number AS reference_number,
'Mutasi' AS transaction_type, 'Internal Transfer Out' AS transaction_type,
prod.name AS product_name, prod.name AS product_name,
COALESCE(( COALESCE((
SELECT string_agg( SELECT string_agg(
@@ -620,9 +476,8 @@ SELECT
COALESCE(tw.name, '') AS destination_warehouse, COALESCE(tw.name, '') AS destination_warehouse,
'' AS destination, '' AS destination,
std.usage_qty AS quantity, std.usage_qty AS quantity,
u.id AS unit_id,
u.name AS unit, u.name AS unit,
st.reason AS notes 'Transfer to other unit' AS notes
FROM stock_transfer_details std FROM stock_transfer_details std
JOIN stock_transfers st ON st.id = std.stock_transfer_id JOIN stock_transfers st ON st.id = std.stock_transfer_id
LEFT JOIN warehouses fw ON fw.id = st.from_warehouse_id LEFT JOIN warehouses fw ON fw.id = st.from_warehouse_id
@@ -632,70 +487,13 @@ JOIN uoms u ON u.id = prod.uom_id
WHERE st.from_warehouse_id IN ? WHERE st.from_warehouse_id IN ?
` `
sapronakOutgoingAdjustmentsSQL = `
SELECT
CAST(ast.id AS BIGINT) AS id,
ast.created_at AS sort_date,
COALESCE(TO_CHAR(ast.created_at, 'DD-Mon-YYYY'), '') AS date_text,
COALESCE(ast.adj_number, '') AS reference_number,
'Adjustment stock' AS transaction_type,
prod.name AS product_name,
COALESCE((
SELECT string_agg(
f.name,
' ' ORDER BY
CASE
WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0
ELSE 1
END,
f.name
)
FROM flags f
WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id
), '') AS product_category,
COALESCE((
SELECT string_agg(
f.name,
' ' ORDER BY
CASE
WHEN UPPER(f.name) IN ('DOC', 'PAKAN', 'OVK', 'PULLET') THEN 0
ELSE 1
END,
f.name
)
FROM flags f
WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id
), '') AS product_sub_category,
COALESCE(w.name, '') AS source_warehouse,
'-' AS destination_warehouse,
'' AS destination,
COALESCE(ast.usage_qty, 0) AS quantity,
u.id AS unit_id,
u.name AS unit,
'-' AS notes
FROM adjustment_stocks ast
JOIN product_warehouses pw ON pw.id = ast.product_warehouse_id
JOIN warehouses w ON w.id = pw.warehouse_id
JOIN products prod ON prod.id = pw.product_id
JOIN uoms u ON u.id = prod.uom_id
WHERE pw.warehouse_id IN ?
AND COALESCE(ast.usage_qty, 0) <> 0
AND EXISTS (
SELECT 1
FROM flags f
WHERE f.flagable_id = pw.product_id
AND f.flagable_type = 'products'
AND UPPER(f.name) NOT IN ('DOC', 'LAYER', 'PULLET', 'AYAM-AFKIR', 'AYAM-MATI', 'AYAM-CULLING', 'TELUR-UTUH', 'TELUR-PECAH', 'TELUR-PUTIH', 'TELUR-RETAK')
)
`
sapronakOutgoingMarketingsSQL = ` sapronakOutgoingMarketingsSQL = `
SELECT SELECT
CAST(mp.id AS BIGINT) AS id, CAST(mp.id AS BIGINT) AS id,
m.so_date AS sort_date, m.so_date AS sort_date,
TO_CHAR(m.so_date, 'DD-Mon-YYYY') AS date_text, TO_CHAR(m.so_date, 'DD-Mon-YYYY') AS date_text,
m.so_number AS reference_number, m.so_number AS reference_number,
'Penjualan' AS transaction_type, 'Trading Sales' AS transaction_type,
prod.name AS product_name, prod.name AS product_name,
COALESCE(( COALESCE((
SELECT string_agg( SELECT string_agg(
@@ -724,15 +522,13 @@ SELECT
WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id WHERE f.flagable_type = 'products' AND f.flagable_id = prod.id
), '') AS product_sub_category, ), '') AS product_sub_category,
w.name AS source_warehouse, w.name AS source_warehouse,
COALESCE(c.name, '') AS destination_warehouse, 'RETAIL CUSTOMER' AS destination_warehouse,
'' AS destination, '' AS destination,
mp.qty AS quantity, mp.qty AS quantity,
u.id AS unit_id,
u.name AS unit, u.name AS unit,
m.notes AS notes m.notes AS notes
FROM marketing_products mp FROM marketing_products mp
JOIN marketings m ON m.id = mp.marketing_id 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 product_warehouses pw ON pw.id = mp.product_warehouse_id
JOIN products prod ON prod.id = pw.product_id JOIN products prod ON prod.id = pw.product_id
JOIN uoms u ON u.id = prod.uom_id JOIN uoms u ON u.id = prod.uom_id
@@ -743,7 +539,7 @@ WHERE pw.project_flock_kandang_id IN ?
FROM flags f FROM flags f
WHERE f.flagable_id = pw.product_id WHERE f.flagable_id = pw.product_id
AND f.flagable_type = 'products' AND f.flagable_type = 'products'
AND UPPER(f.name) NOT IN ('DOC', 'LAYER', 'PULLET', 'AYAM-AFKIR', 'AYAM-MATI', 'AYAM-CULLING', 'TELUR-UTUH', 'TELUR-PECAH', 'TELUR-PUTIH', 'TELUR-RETAK') AND UPPER(f.name) NOT IN ('DOC', 'LAYER', 'PULLET')
) )
` `
) )
@@ -801,23 +597,6 @@ var (
sapronakFlagsChickin = sapronakFlags(utils.FlagDOC, utils.FlagPullet) 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 { func groupSapronakDetails(rows []SapronakDetailRow) map[uint][]SapronakDetailRow {
m := make(map[uint][]SapronakDetailRow) m := make(map[uint][]SapronakDetailRow)
for _, row := range rows { for _, row := range rows {
@@ -854,12 +633,11 @@ func (r *ClosingRepositoryImpl) usageQuery(
COALESCE(p.product_price, 0) AS default_price COALESCE(p.product_price, 0) AS default_price
`) `)
db = applyJoins(db, joins...) db = applyJoins(db, joins...)
db = db. return db.
Joins("JOIN product_warehouses pw ON "+pwJoinCond). 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).
Where(where, args...) Where(where, args...)
db = r.joinSapronakProductFlag(db, "p")
return db
} }
func (r *ClosingRepositoryImpl) fetchSapronakUsage( func (r *ClosingRepositoryImpl) fetchSapronakUsage(
@@ -889,11 +667,11 @@ func (r *ClosingRepositoryImpl) detailQuery(
) *gorm.DB { ) *gorm.DB {
db := r.withCtx(ctx). db := r.withCtx(ctx).
Table(table). Table(table).
Joins("JOIN product_warehouses pw ON " + pwJoinCond). 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 = applyJoins(db, joins...)
db = r.joinSapronakProductFlag(db, "p")
return db.Select(selectSQL).Where(where, args...) return db.Select(selectSQL).Where(where, args...)
} }
@@ -983,85 +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(`
p_resolve.id AS product_id,
p_resolve.name AS product_name,
f.name AS flag,
COALESCE(
pi.received_date,
st.transfer_date,
lt.transfer_date,
ast.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_resolve.product_price, 0) AS price
`).
Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_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 product_warehouses pw_ltt ON pw_ltt.id = ltt.product_warehouse_id").
Joins("LEFT JOIN adjustment_stocks ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyAdjustmentIn.String()).
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").
Joins("LEFT JOIN products p_resolve ON p_resolve.id = COALESCE(pi.product_id, pw_ltt.product_id, pw.product_id)").
Where("sa.status = ?", entity.StockAllocationStatusActive).
Where("sa.stockable_type <> ?", fifo.StockableKeyProjectFlockPopulation.String()).
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_resolve").
Group(`
p_resolve.id, p_resolve.name, f.name,
pi.received_date, st.transfer_date, lt.transfer_date, ast.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_resolve.product_price
`)
return scanAndGroupDetails(query)
}
func (r *ClosingRepositoryImpl) incomingPurchaseBase(ctx context.Context, kandangID uint) *gorm.DB { func (r *ClosingRepositoryImpl) incomingPurchaseBase(ctx context.Context, kandangID uint) *gorm.DB {
db := r.withCtx(ctx). return r.withCtx(ctx).
Table("purchase_items AS pi"). Table("purchase_items AS pi").
Joins("JOIN purchases po ON po.id = pi.purchase_id AND po.deleted_at IS NULL"). 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 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"). Joins("JOIN warehouses w ON w.id = pi.warehouse_id").
Where("w.kandang_id = ?", kandangID). Where("w.kandang_id = ?", kandangID).
Where("f.name IN ?", sapronakFlagsAll). Where("f.name IN ?", sapronakFlagsAll).
Where("pi.received_date IS NOT NULL") Where("pi.received_date IS NOT NULL")
return r.joinSapronakProductFlag(db, "p")
} }
func (r *ClosingRepositoryImpl) FetchSapronakIncoming(ctx context.Context, kandangID uint) ([]SapronakIncomingRow, error) { func (r *ClosingRepositoryImpl) FetchSapronakIncoming(ctx context.Context, kandangID uint) ([]SapronakIncomingRow, error) {
@@ -1128,14 +837,14 @@ func (r *ClosingRepositoryImpl) fetchStockLogs(ctx context.Context, kandangID ui
COALESCE(sl.increase,0) AS increase, COALESCE(sl.increase,0) AS increase,
COALESCE(sl.decrease,0) AS decrease, COALESCE(sl.decrease,0) AS decrease,
COALESCE(p.product_price,0) AS price, COALESCE(p.product_price,0) AS price,
` + movementSelect + ` `+movementSelect+`
`). `).
Joins("JOIN product_warehouses pw ON pw.id = sl.product_warehouse_id"). Joins("JOIN product_warehouses pw ON pw.id = sl.product_warehouse_id").
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).
Joins("JOIN warehouses w ON w.id = pw.warehouse_id") Joins("JOIN warehouses w ON w.id = pw.warehouse_id")
db = applyJoins(db, joins...) db = applyJoins(db, joins...)
db = r.joinSapronakProductFlag(db, "p")
if err := db. if err := db.
Where("sl.loggable_type = ?", logType). Where("sl.loggable_type = ?", logType).
@@ -1178,75 +887,12 @@ func splitStockLogs(rows []stockLogSapronakRow, refFn func(stockLogSapronakRow)
} }
func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) { func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) {
poByWarehouse := r.DB(). rows, err := r.fetchStockLogs(ctx, kandangID, string(utils.StockLogTypeAdjustment), false)
Table("purchase_items pi").
Select("DISTINCT ON (pi.product_warehouse_id) pi.product_warehouse_id, po.po_number, pi.received_date").
Joins("JOIN purchases po ON po.id = pi.purchase_id").
Where("pi.received_date IS NOT NULL").
Order("pi.product_warehouse_id, pi.received_date ASC")
incomingQuery := r.withCtx(ctx).
Table("adjustment_stocks AS ast").
Select(`
pw.product_id AS product_id,
p.name AS product_name,
f.name AS flag,
ast.created_at AS date,
CONCAT('ADJ-', ast.id) AS reference,
COALESCE(ast.total_qty, 0) AS qty_in,
0 AS qty_out,
COALESCE(p.product_price, 0) AS price
`).
Joins("JOIN product_warehouses pw ON pw.id = ast.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("f.name IN ?", sapronakFlagsAll).
Where("COALESCE(ast.total_qty, 0) > 0")
incomingQuery = r.joinSapronakProductFlag(incomingQuery, "p")
incoming, err := scanAndGroupDetails(incomingQuery)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
in, out := splitStockLogs(rows, func(row stockLogSapronakRow) string { return fmt.Sprintf("ADJ-%d", row.ID) })
outgoingQuery := r.withCtx(ctx). return in, out, nil
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, pfp_po.received_date, pc.chick_in_date, ast_in.created_at, ast.created_at) AS date,
COALESCE(po.po_number, st.movement_number, lt.transfer_number, pfp_po.po_number, CONCAT('CHICKIN-', pc.id), CONCAT('ADJ-', ast_in.id), CONCAT('ADJ-', ast.id)) AS reference,
0 AS qty_in,
COALESCE(SUM(sa.qty), 0) AS qty_out,
COALESCE(p.product_price, 0) AS price
`).
Joins("JOIN adjustment_stocks ast ON ast.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyAdjustmentOut.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_in ON ast_in.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyAdjustmentIn.String()).
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").
Joins("LEFT JOIN (?) pfp_po ON pfp_po.product_warehouse_id = pfp.product_warehouse_id", poByWarehouse).
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("f.name IN ?", sapronakFlagsAll).
Where("f.name NOT IN ?", sapronakFlags(utils.FlagDOC, utils.FlagPullet)).
Group("pw.product_id, p.name, f.name, pi.received_date, st.transfer_date, lt.transfer_date, pfp_po.received_date, pc.chick_in_date, ast_in.created_at, ast.created_at, po.po_number, st.movement_number, lt.transfer_number, pfp_po.po_number, pc.id, ast_in.id, ast.id, p.product_price")
outgoingQuery = r.joinSapronakProductFlag(outgoingQuery, "p")
outgoing, err := scanAndGroupDetails(outgoingQuery)
if err != nil {
return nil, nil, err
}
return incoming, outgoing, nil
} }
func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) { func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error) {
@@ -1263,50 +909,17 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand
COALESCE(p.product_price, 0) AS price COALESCE(p.product_price, 0) AS price
`). `).
Joins("JOIN stock_transfers st ON st.id = std.stock_transfer_id"). 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 product_warehouses pw ON pw.id = std.dest_product_warehouse_id").
Joins("JOIN warehouses w ON w.id = pw.warehouse_id"). Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
Joins("JOIN products p ON p.id = std.product_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("w.kandang_id = ?", kandangID).
Where("(fw.kandang_id IS NULL OR fw.kandang_id <> w.kandang_id)").
Where("f.name IN ?", sapronakFlagsAll) Where("f.name IN ?", sapronakFlagsAll)
incomingQuery = r.joinSapronakProductFlag(incomingQuery, "p")
incoming, err := scanAndGroupDetails(incomingQuery) incoming, err := scanAndGroupDetails(incomingQuery)
if err != nil { if err != nil {
return nil, nil, err 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). outgoingQuery := r.withCtx(ctx).
Table("stock_allocations AS sa"). Table("stock_allocations AS sa").
Select(` Select(`
@@ -1323,178 +936,20 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand
Joins("JOIN stock_transfers st ON st.id = std.stock_transfer_id"). 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 product_warehouses pw ON pw.id = sa.product_warehouse_id").
Joins("JOIN warehouses w ON w.id = pw.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 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("sa.status = ?", entity.StockAllocationStatusActive).
Where("w.kandang_id = ?", kandangID). 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). Where("f.name IN ?", sapronakFlagsAll).
Group("std.id, std.product_id, p.name, f.name, st.transfer_date, st.movement_number, p.product_price") 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) outgoing, err := scanAndGroupDetails(outgoingQuery)
if err != nil { if err != nil {
return nil, nil, err 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 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) FetchSapronakSalesAllocatedDetails(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,
ast.created_at
) AS date,
COALESCE(
po.po_number,
st.movement_number,
lt.transfer_number,
CONCAT('ADJ-', ast.id),
''
) 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("JOIN marketing_delivery_products mdp ON mdp.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyMarketingDelivery.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()).
Where("sa.status = ?", entity.StockAllocationStatusActive).
Where("sa.stockable_type <> ?", fifo.StockableKeyProjectFlockPopulation.String()).
Where("pw.project_flock_kandang_id = ?", projectFlockKandangID).
Where("f.name IN ?", sapronakFlagsAll).
Group(`
pw.product_id, p.name, f.name,
pi.received_date, st.transfer_date, lt.transfer_date, ast.created_at,
po.po_number, st.movement_number, lt.transfer_number, ast.id,
pi.price, p.product_price
`)
query = r.joinSapronakProductFlag(query, "p")
return scanAndGroupDetails(query)
}
func (r *ClosingRepositoryImpl) GetProductsWithFlagsByIDs(ctx context.Context, productIDs []uint) ([]entity.Product, error) { func (r *ClosingRepositoryImpl) GetProductsWithFlagsByIDs(ctx context.Context, productIDs []uint) ([]entity.Product, error) {
if len(productIDs) == 0 { if len(productIDs) == 0 {
return []entity.Product{}, nil 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/: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("/: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", 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/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("/: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) route.Get("/:projectFlockId/production-data", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetClosingDataProduksi)
@@ -12,7 +12,6 @@ import (
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
"gitlab.com/mbugroup/lti-api.git/internal/modules/closings/dto" "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/dto"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/repositories" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/validations" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/validations"
@@ -41,7 +40,6 @@ type ClosingService interface {
GetClosingDataProduksi(ctx *fiber.Ctx, projectFlockID uint, kandangID *uint) (*dto.ClosingProductionReportDTO, error) GetClosingDataProduksi(ctx *fiber.Ctx, projectFlockID uint, kandangID *uint) (*dto.ClosingProductionReportDTO, error)
GetOverhead(ctx *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) (*dto.OverheadListDTO, 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) 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) GetExpeditionHPP(ctx *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) (*dto.ExpeditionHPPDTO, error)
} }
@@ -99,43 +97,10 @@ func (s closingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]dto.Cl
return nil, 0, err return nil, 0, err
} }
scope, err := m.ResolveLocationScope(c, s.Repository.DB())
if err != nil {
return nil, 0, err
}
offset := (params.Page - 1) * params.Limit 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 { closings, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
db = s.withClosingRelations(db) db = s.withClosingRelations(db)
if scope.Restrict {
if len(scope.IDs) == 0 {
return db.Where("1 = 0")
}
db = m.ApplyScopeFilter(db, scope, "project_flocks.location_id")
}
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 != "" { if params.Search != "" {
return db.Where("flock_name ILIKE ?", "%"+params.Search+"%") return db.Where("flock_name ILIKE ?", "%"+params.Search+"%")
} }
@@ -162,10 +127,6 @@ func (s closingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]dto.Cl
} }
func (s closingService) GetProjectFlockByID(c *fiber.Ctx, id uint) (*entity.ProjectFlock, error) { func (s closingService) GetProjectFlockByID(c *fiber.Ctx, id uint) (*entity.ProjectFlock, error) {
if err := m.EnsureProjectFlockAccess(c, s.Repository.DB(), id); err != nil {
return nil, err
}
projectFlock, err := s.ProjectFlockRepo.GetByID(c.Context(), id, s.withRelations) projectFlock, err := s.ProjectFlockRepo.GetByID(c.Context(), id, s.withRelations)
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Project Flock not found") return nil, fiber.NewError(fiber.StatusNotFound, "Project Flock not found")
@@ -177,13 +138,6 @@ 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) { func (s closingService) GetPenjualan(c *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) ([]entity.MarketingDeliveryProduct, error) {
if projectFlockKandangID != nil {
if err := m.EnsureProjectFlockKandangAccess(c, s.Repository.DB(), projectFlockID, *projectFlockKandangID); err != nil {
return nil, err
}
} else if err := m.EnsureProjectFlockAccess(c, s.Repository.DB(), projectFlockID); err != nil {
return nil, err
}
realisasi, err := s.MarketingDeliveryProductRepo.GetClosingPenjualan(c.Context(), projectFlockID, projectFlockKandangID) realisasi, err := s.MarketingDeliveryProductRepo.GetClosingPenjualan(c.Context(), projectFlockID, projectFlockKandangID)
if err != nil { if err != nil {
@@ -197,8 +151,8 @@ func (s closingService) GetPenjualan(c *fiber.Ctx, projectFlockID uint, projectF
} }
func (s closingService) GetClosingSummary(c *fiber.Ctx, projectFlockID uint, kandangID *uint) (any, error) { func (s closingService) GetClosingSummary(c *fiber.Ctx, projectFlockID uint, kandangID *uint) (any, error) {
if err := m.EnsureProjectFlockAccess(c, s.Repository.DB(), projectFlockID); err != nil { if projectFlockID == 0 {
return nil, err return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project flock id")
} }
if kandangID != nil { if kandangID != nil {
@@ -282,7 +236,7 @@ func (s closingService) getClosingSummaryByKandang(ctx context.Context, projectF
statusProject := "Belum Selesai" statusProject := "Belum Selesai"
var approvalDate string var approvalDate string
if s.ApprovalSvc != nil { if s.ApprovalSvc != nil {
records, _, err := s.ApprovalSvc.List(ctx, utils.ApprovalWorkflowProjectFlockKandang.String(), &kandang.Id, 1, 1000, "", "") records, _, err := s.ApprovalSvc.List(ctx, utils.ApprovalWorkflowProjectFlockKandang.String(), &kandang.Id, 1, 1000, "")
if err != nil { if err != nil {
s.Log.Errorf("Failed to fetch approvals for project flock kandang %d: %+v", kandang.Id, err) s.Log.Errorf("Failed to fetch approvals for project flock kandang %d: %+v", kandang.Id, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch approval data") return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch approval data")
@@ -344,8 +298,8 @@ func (s closingService) getClosingSummaryByKandang(ctx context.Context, projectF
} }
func (s closingService) GetClosingSapronak(c *fiber.Ctx, projectFlockID uint, params *validation.ClosingSapronakQuery) ([]dto.ClosingSapronakItemDTO, int64, error) { func (s closingService) GetClosingSapronak(c *fiber.Ctx, projectFlockID uint, params *validation.ClosingSapronakQuery) ([]dto.ClosingSapronakItemDTO, int64, error) {
if err := m.EnsureProjectFlockAccess(c, s.Repository.DB(), projectFlockID); err != nil { if projectFlockID == 0 {
return nil, 0, err return nil, 0, fiber.NewError(fiber.StatusBadRequest, "Invalid project flock id")
} }
if params == nil { if params == nil {
@@ -367,7 +321,15 @@ func (s closingService) GetClosingSapronak(c *fiber.Ctx, projectFlockID uint, pa
return nil, 0, fiber.NewError(fiber.StatusBadRequest, "type must be either incoming or outgoing") return nil, 0, fiber.NewError(fiber.StatusBadRequest, "type must be either incoming or outgoing")
} }
warehouseIDs, err := s.getWarehouseIDsByProjectFlock(c.Context(), projectFlockID, params.KandangID) if _, err := s.Repository.GetByID(c.Context(), projectFlockID, nil); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, 0, fiber.NewError(fiber.StatusNotFound, "Project flock tidak ditemukan")
}
s.Log.Errorf("Failed get project flock %d for sapronak closing: %+v", projectFlockID, err)
return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock")
}
warehouseIDs, err := s.getWarehouseIDsByProjectFlock(c.Context(), projectFlockID)
if err != nil { if err != nil {
s.Log.Errorf("Failed to fetch warehouses for project flock %d: %+v", projectFlockID, err) s.Log.Errorf("Failed to fetch warehouses for project flock %d: %+v", projectFlockID, err)
return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch warehouses for project flock") return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch warehouses for project flock")
@@ -391,7 +353,6 @@ func (s closingService) GetClosingSapronak(c *fiber.Ctx, projectFlockID uint, pa
ProjectFlockKandangIDs: projectFlockKandangIDs, ProjectFlockKandangIDs: projectFlockKandangIDs,
Limit: params.Limit, Limit: params.Limit,
Offset: offset, Offset: offset,
Search: params.Search,
}) })
if err != nil { if err != nil {
s.Log.Errorf("Failed to fetch sapronak %s for project flock %d: %+v", params.Type, projectFlockID, err) s.Log.Errorf("Failed to fetch sapronak %s for project flock %d: %+v", params.Type, projectFlockID, err)
@@ -426,84 +387,13 @@ func (s closingService) GetClosingSapronak(c *fiber.Ctx, projectFlockID uint, pa
return items, totalResults, nil return items, totalResults, nil
} }
func (s closingService) GetClosingSapronakSummary(c *fiber.Ctx, projectFlockID uint, params *validation.ClosingSapronakQuery) ([]dto.ClosingSapronakSummaryItemDTO, error) { func (s closingService) getWarehouseIDsByProjectFlock(ctx context.Context, projectFlockID uint) ([]uint, 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, params.KandangID)
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, kandangID *uint) ([]uint, error) {
var kandangIDs []uint var kandangIDs []uint
db := s.Repository.DB().WithContext(ctx) db := s.Repository.DB().WithContext(ctx)
query := db.Model(&entity.ProjectFlockKandang{}). if err := db.Model(&entity.ProjectFlockKandang{}).
Where("project_flock_id = ?", projectFlockID) Where("project_flock_id = ?", projectFlockID).
if kandangID != nil && *kandangID > 0 { Pluck("kandang_id", &kandangIDs).Error; err != nil {
query = query.Where("id = ?", *kandangID)
}
if err := query.Pluck("kandang_id", &kandangIDs).Error; err != nil {
return nil, err return nil, err
} }
@@ -555,7 +445,7 @@ func (s closingService) getApprovalStatuses(ctx context.Context, projectFlockID
return "", "Belum Selesai", nil return "", "Belum Selesai", nil
} }
records, _, err := s.ApprovalSvc.List(ctx, utils.ApprovalWorkflowProjectFlock.String(), &projectFlockID, 1, 1000, "", "") records, _, err := s.ApprovalSvc.List(ctx, utils.ApprovalWorkflowProjectFlock.String(), &projectFlockID, 1, 1000, "")
if err != nil { if err != nil {
return "", "", err return "", "", err
} }
@@ -598,14 +488,6 @@ func (s closingService) getApprovalStatuses(ctx context.Context, projectFlockID
} }
func (s closingService) GetOverhead(c *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) (*dto.OverheadListDTO, error) { func (s closingService) GetOverhead(c *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) (*dto.OverheadListDTO, error) {
if projectFlockKandangID != nil {
if err := m.EnsureProjectFlockKandangAccess(c, s.Repository.DB(), projectFlockID, *projectFlockKandangID); err != nil {
return nil, err
}
} else if err := m.EnsureProjectFlockAccess(c, s.Repository.DB(), projectFlockID); err != nil {
return nil, err
}
budgets, err := s.ProjectBudgetRepo.GetByProjectFlockID(c.Context(), projectFlockID) budgets, err := s.ProjectBudgetRepo.GetByProjectFlockID(c.Context(), projectFlockID)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -694,12 +576,8 @@ func (s closingService) GetOverhead(c *fiber.Ctx, projectFlockID uint, projectFl
} }
func (s closingService) GetExpeditionHPP(c *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) (*dto.ExpeditionHPPDTO, error) { func (s closingService) GetExpeditionHPP(c *fiber.Ctx, projectFlockID uint, projectFlockKandangID *uint) (*dto.ExpeditionHPPDTO, error) {
if projectFlockKandangID != nil { if projectFlockID == 0 {
if err := m.EnsureProjectFlockKandangAccess(c, s.Repository.DB(), projectFlockID, *projectFlockKandangID); err != nil { return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project flock id")
return nil, err
}
} else if err := m.EnsureProjectFlockAccess(c, s.Repository.DB(), projectFlockID); err != nil {
return nil, err
} }
rows, err := s.Repository.GetExpeditionHPP(c.Context(), projectFlockID, projectFlockKandangID) rows, err := s.Repository.GetExpeditionHPP(c.Context(), projectFlockID, projectFlockKandangID)
@@ -773,10 +651,6 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to determine production week") 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) targetAverages, err := s.RecordingRepo.GetAverageTargetMetricsByProjectFlockKandangID(c.Context(), projectFlockKandangIDs[0], !isGrowing)
if err != nil { if err != nil {
s.Log.Errorf("Failed to calculate target metrics for project flock %d: %+v", projectFlockID, err) s.Log.Errorf("Failed to calculate target metrics for project flock %d: %+v", projectFlockID, err)
@@ -836,7 +710,15 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint
finalPopulation := population - claimCulling finalPopulation := population - claimCulling
age, err := s.calculateAverageSalesAge(c.Context(), projectFlockID, kandangID) var standards []entity.FcrStandard
if project.FcrId > 0 {
standards, err = s.Repository.GetFcrStandardsByFcrID(c.Context(), project.FcrId)
if err != nil {
s.Log.Errorf("Failed to fetch FCR standards for project flock %d: %+v", projectFlockID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch FCR standard data")
}
}
age, err := s.calculateAverageSalesAge(c.Context(), projectFlockID)
if err != nil { if err != nil {
s.Log.Errorf("Failed to calculate sales age for project flock %d: %+v", projectFlockID, err) s.Log.Errorf("Failed to calculate sales age for project flock %d: %+v", projectFlockID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch sales age data") return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch sales age data")
@@ -856,7 +738,7 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint
// FeedUsedPerHead: feedUsedPerHead, // FeedUsedPerHead: feedUsedPerHead,
} }
chickenFlagNames := []string{string(utils.FlagPullet), string(utils.FlagAyamAfkir), string(utils.FlagAyamCulling), string(utils.FlagLayer)} chickenFlagNames := []string{string(utils.FlagPullet)}
chickenSalesWeight, chickenSalesQty, chickenSalesPrice, err := s.Repository.SumMarketingWeightAndQtyByProjectFlockKandangIDsAndFlagNames(c.Context(), projectFlockKandangIDs, chickenFlagNames) chickenSalesWeight, chickenSalesQty, chickenSalesPrice, err := s.Repository.SumMarketingWeightAndQtyByProjectFlockKandangIDsAndFlagNames(c.Context(), projectFlockKandangIDs, chickenFlagNames)
if err != nil { if err != nil {
s.Log.Errorf("Failed to fetch chicken sales data for project flock %d: %+v", projectFlockID, err) s.Log.Errorf("Failed to fetch chicken sales data for project flock %d: %+v", projectFlockID, err)
@@ -885,7 +767,7 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint
chickenDepletion = 0 chickenDepletion = 0
} }
chickenPerformance := calculatePerformanceMetrics(chickenAverageWeight, chickenSalesWeight, feedUsed, population, chickenDepletion, age) chickenPerformance := calculatePerformanceMetrics(chickenAverageWeight, chickenSalesWeight, feedUsed, population, chickenDepletion, age, standards)
if fcrActFromRecording != nil { if fcrActFromRecording != nil {
chickenPerformance.FcrAct = *fcrActFromRecording chickenPerformance.FcrAct = *fcrActFromRecording
} }
@@ -935,7 +817,7 @@ chickenPerformance := calculatePerformanceMetrics(chickenAverageWeight, chickenS
eggDepletion = 0 eggDepletion = 0
} }
eggPerf := calculatePerformanceMetrics(averageEggWeight, eggSalesWeight, feedUsed, harvestEggQty, eggDepletion, age) eggPerf := calculatePerformanceMetrics(averageEggWeight, eggSalesWeight, feedUsed, harvestEggQty, eggDepletion, age, standards)
if fcrActFromRecording != nil { if fcrActFromRecording != nil {
eggPerf.FcrAct = *fcrActFromRecording eggPerf.FcrAct = *fcrActFromRecording
} }
@@ -978,25 +860,25 @@ chickenPerformance := calculatePerformanceMetrics(chickenAverageWeight, chickenS
if !isGrowing { if !isGrowing {
if targetAverages.HenDayCount > 0 { if targetAverages.HenDayCount > 0 {
henDayAct := targetAverages.HenDayAvg henDayAct := targetAverages.HenDayAvg
performance.HenDayAct = henDayAct performance.HenDayAct = &henDayAct
} }
if targetAverages.HenHouseCount > 0 { if targetAverages.HenHouseCount > 0 {
henHouseAct := targetAverages.HenHouseAvg henHouseAct := targetAverages.HenHouseAvg
performance.HenHouseAct = henHouseAct performance.HenHouseAct = &henHouseAct
} }
if targetAverages.EggWeightCount > 0 { if targetAverages.EggWeightCount > 0 {
eggWeight := targetAverages.EggWeightAvg eggWeight := targetAverages.EggWeightAvg
performance.EggWeight = eggWeight performance.EggWeight = &eggWeight
} }
if targetAverages.EggMassCount > 0 { if targetAverages.EggMassCount > 0 {
eggMass := targetAverages.EggMassAvg eggMass := targetAverages.EggMassAvg
performance.EggMass = eggMass performance.EggMass = &eggMass
} }
} }
performance.DeffFcr = performance.FcrStd - performance.FcrAct
if productionStandardDetail != nil { if productionStandardDetail != nil {
if productionStandardDetail.StandardFCR != nil { if productionStandardDetail.StandardFCR != nil {
performance.FcrStd = *productionStandardDetail.StandardFCR performance.FcrStd = *productionStandardDetail.StandardFCR
performance.DeffFcr = performance.FcrStd - performance.FcrAct
} }
if !isGrowing { if !isGrowing {
if productionStandardDetail.TargetHenDayProduction != nil { if productionStandardDetail.TargetHenDayProduction != nil {
@@ -1023,24 +905,38 @@ chickenPerformance := calculatePerformanceMetrics(chickenAverageWeight, chickenS
return &result, nil return &result, nil
} }
func (s closingService) calculateAverageSalesAge(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) (float64, error) { func (s closingService) calculateAverageSalesAge(ctx context.Context, projectFlockID uint) (float64, error) {
penjualan, err := s.MarketingDeliveryProductRepo.GetClosingPenjualanForAgeChickDataProduction(ctx, projectFlockID, projectFlockKandangID) deliveryProducts, err := s.MarketingDeliveryProductRepo.GetDeliveryProductsByProjectFlockID(ctx, projectFlockID, func(db *gorm.DB) *gorm.DB {
return db.
Preload("MarketingProduct").
Preload("MarketingProduct.ProductWarehouse").
Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang").
Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang.Chickins")
})
if err != nil { if err != nil {
return 0, err return 0, err
} }
acumulateAgeQty := 0.0
totalQty := 0.0 var (
for _, v := range penjualan { totalQty float64
sale := dto.ToSalesAgeDTO(v) totalAgeWeeks float64
acumulateAgeQty += float64(sale.Age) * sale.Qty )
totalQty += sale.Qty
} for _, product := range deliveryProducts {
if totalQty > 0 { if product.UsageQty == 0 {
averageAge := acumulateAgeQty / totalQty continue
return averageAge, nil }
projectFlockKandang := product.MarketingProduct.ProductWarehouse.ProjectFlockKandang
ageWeeks := dto.CalculateAgeFromChickinDataProduksi(projectFlockKandang, product.DeliveryDate)
totalAgeWeeks += float64(ageWeeks) * product.UsageQty
totalQty += product.UsageQty
} }
return 0, err if totalQty == 0 {
return 0, nil
}
return totalAgeWeeks / totalQty, nil
} }
func (s closingService) determineProductionWeek(ctx context.Context, projectFlockKandangIDs []uint) (int, error) { func (s closingService) determineProductionWeek(ctx context.Context, projectFlockKandangIDs []uint) (int, error) {
@@ -1083,8 +979,8 @@ func (s closingService) determineProductionWeek(ctx context.Context, projectFloc
return week, nil return week, nil
} }
func calculatePerformanceMetrics(averageWeight, totalWeight, feedUsed, basePopulation, depletion, age float64) dto.ClosingPerformanceDTO { func calculatePerformanceMetrics(averageWeight, totalWeight, feedUsed, basePopulation, depletion, age float64, standards []entity.FcrStandard) dto.ClosingPerformanceDTO {
mortalityStd, fcrStd := 0.0, 0.0 mortalityStd, fcrStd := closestFcrValues(standards, averageWeight)
fcrAct := 0.0 fcrAct := 0.0
if totalWeight > 0 { if totalWeight > 0 {
@@ -1116,3 +1012,22 @@ func calculatePerformanceMetrics(averageWeight, totalWeight, feedUsed, basePopul
AwgAct: awg, AwgAct: awg,
} }
} }
func closestFcrValues(standards []entity.FcrStandard, averageWeight float64) (float64, float64) {
if len(standards) == 0 || averageWeight <= 0 {
return 0, 0
}
closest := standards[0]
minDiff := math.Abs(closest.Weight - averageWeight)
for _, std := range standards[1:] {
diff := math.Abs(std.Weight - averageWeight)
if diff < minDiff {
minDiff = diff
closest = std
}
}
return closest.Mortality, closest.FcrNumber
}
@@ -2,19 +2,20 @@ package service
import ( import (
"errors" "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" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/modules/closings/dto" "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" expenseRealizationRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories"
marketingDeliveryProductRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" marketingDeliveryProductRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories"
chickinRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/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" 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" recordingRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils"
"github.com/gofiber/fiber/v2"
"github.com/sirupsen/logrus"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -24,29 +25,9 @@ type ClosingKeuanganService interface {
GetClosingKeuanganByKandang(ctx *fiber.Ctx, projectFlockID uint, projectFlockKandangID uint) (*dto.ClosingKeuanganData, error) 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
TotalBirdSold float64
TotalSalesAmount float64
}
type closingKeuanganService struct { type closingKeuanganService struct {
Log *logrus.Logger Log *logrus.Logger
ClosingKeuanganRepo repository.ClosingKeuanganRepository
ProjectFlockRepo projectflockRepository.ProjectflockRepository ProjectFlockRepo projectflockRepository.ProjectflockRepository
ProjectFlockKandangRepo projectflockRepository.ProjectFlockKandangRepository ProjectFlockKandangRepo projectflockRepository.ProjectFlockKandangRepository
MarketingDeliveryProductRepo marketingDeliveryProductRepository.MarketingDeliveryProductRepository MarketingDeliveryProductRepo marketingDeliveryProductRepository.MarketingDeliveryProductRepository
@@ -54,11 +35,10 @@ type closingKeuanganService struct {
ProjectBudgetRepo projectflockRepository.ProjectBudgetRepository ProjectBudgetRepo projectflockRepository.ProjectBudgetRepository
ChickinRepo chickinRepository.ProjectChickinRepository ChickinRepo chickinRepository.ProjectChickinRepository
RecordingRepo recordingRepository.RecordingRepository RecordingRepo recordingRepository.RecordingRepository
HppSvc commonSvc.HppService
HppRepo commonRepo.HppCostRepository
} }
func NewClosingKeuanganService( func NewClosingKeuanganService(
closingKeuanganRepo repository.ClosingKeuanganRepository,
projectFlockRepo projectflockRepository.ProjectflockRepository, projectFlockRepo projectflockRepository.ProjectflockRepository,
projectFlockKandangRepo projectflockRepository.ProjectFlockKandangRepository, projectFlockKandangRepo projectflockRepository.ProjectFlockKandangRepository,
marketingDeliveryProductRepo marketingDeliveryProductRepository.MarketingDeliveryProductRepository, marketingDeliveryProductRepo marketingDeliveryProductRepository.MarketingDeliveryProductRepository,
@@ -66,11 +46,10 @@ func NewClosingKeuanganService(
projectBudgetRepo projectflockRepository.ProjectBudgetRepository, projectBudgetRepo projectflockRepository.ProjectBudgetRepository,
chickinRepo chickinRepository.ProjectChickinRepository, chickinRepo chickinRepository.ProjectChickinRepository,
recordingRepo recordingRepository.RecordingRepository, recordingRepo recordingRepository.RecordingRepository,
hppSvc commonSvc.HppService,
hppRepo commonRepo.HppCostRepository,
) ClosingKeuanganService { ) ClosingKeuanganService {
return &closingKeuanganService{ return &closingKeuanganService{
Log: utils.Log, Log: utils.Log,
ClosingKeuanganRepo: closingKeuanganRepo,
ProjectFlockRepo: projectFlockRepo, ProjectFlockRepo: projectFlockRepo,
ProjectFlockKandangRepo: projectFlockKandangRepo, ProjectFlockKandangRepo: projectFlockKandangRepo,
MarketingDeliveryProductRepo: marketingDeliveryProductRepo, MarketingDeliveryProductRepo: marketingDeliveryProductRepo,
@@ -78,8 +57,6 @@ func NewClosingKeuanganService(
ProjectBudgetRepo: projectBudgetRepo, ProjectBudgetRepo: projectBudgetRepo,
ChickinRepo: chickinRepo, ChickinRepo: chickinRepo,
RecordingRepo: recordingRepo, RecordingRepo: recordingRepo,
HppSvc: hppSvc,
HppRepo: hppRepo,
} }
} }
@@ -96,12 +73,30 @@ func (s closingKeuanganService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock") 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) { if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch kandangs") 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) { func (s closingKeuanganService) GetClosingKeuanganByKandang(c *fiber.Ctx, projectFlockID uint, projectFlockKandangID uint) (*dto.ClosingKeuanganData, error) {
@@ -112,11 +107,12 @@ func (s closingKeuanganService) GetClosingKeuanganByKandang(c *fiber.Ctx, projec
return nil, err 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 { if err != nil {
return nil, fiber.NewError(fiber.StatusNotFound, "Project flock kandang not found") 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") return nil, fiber.NewError(fiber.StatusBadRequest, "Project flock kandang does not belong to this project flock")
} }
@@ -125,253 +121,417 @@ func (s closingKeuanganService) GetClosingKeuanganByKandang(c *fiber.Ctx, projec
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock") return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock")
} }
projectFlockKandangs := []entity.ProjectFlockKandang{*projectFlockKandang} budgets, err := s.ProjectBudgetRepo.GetByProjectFlockID(c.Context(), projectFlockID)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return s.calculateClosingKeuangan(c, projectFlock, projectFlockKandangs) return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch budgets")
}
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)
} }
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 var projectFlockKandangID *uint
if isPerKandang { if isPerKandang {
kandangID := projectFlockKandangs[0].Id kandangID := kandangs[0].Id
projectFlockKandangID = &kandangID 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 var err error
costs.FeedCost, err = s.HppRepo.GetFeedUsageCost(c.Context(), projectFlockKandangIDs, nil) // Fetch realizations
if err != nil { var realizations []entity.ExpenseRealization
costs.FeedCost = 0 if isPerKandang && projectFlockKandangID != nil {
} realizations, err = s.ExpenseRealizationRepo.GetClosingOverhead(c.Context(), projectFlock.Id, projectFlockKandangID)
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
}
}
} else { } else {
for _, projectFlockKandang := range projectFlockKandangs { realizations, err = s.ExpenseRealizationRepo.GetClosingOverhead(c.Context(), projectFlock.Id, nil)
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)
} }
if err != nil { if err != nil {
data.TotalDepletion = 0 return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch realizations")
} }
if projectFlockKandangID != nil { deliveryProducts, err := s.MarketingDeliveryProductRepo.GetDeliveryProductsByProjectFlockID(c.Context(), projectFlock.Id, func(db *gorm.DB) *gorm.DB {
data.TotalWeightProduced, err = s.RecordingRepo.GetTotalWeightProducedFromUniformityByProjectFlockKandangID(c.Context(), *projectFlockKandangID) db = db.Preload("MarketingProduct").
} else { Preload("MarketingProduct.ProductWarehouse").
data.TotalWeightProduced, err = s.RecordingRepo.GetTotalWeightProducedFromUniformityByProjectFlockID(c.Context(), projectFlock.Id) Preload("MarketingProduct.ProductWarehouse.Product")
} return db
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.GetClosingPenjualanByCategory(c.Context(), projectFlock.Id, projectFlockKandangID, projectFlock.Category)
} else {
deliveryProducts, err = s.MarketingDeliveryProductRepo.GetClosingPenjualanByCategory(c.Context(), projectFlock.Id, nil, projectFlock.Category)
}
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { 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 { for _, delivery := range deliveryProducts {
// Get product from delivery
if delivery.MarketingProduct.ProductWarehouse.Product.Id == 0 { if delivery.MarketingProduct.ProductWarehouse.Product.Id == 0 {
continue continue
} }
data.TotalWeightSold += delivery.TotalWeight
data.TotalBirdSold += delivery.UsageQty product := delivery.MarketingProduct.ProductWarehouse.Product
data.TotalSalesAmount += delivery.TotalPrice 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 { // Calculate total weight sold and sales amount from filtered products
var totalWeightSold float64
actualPopulation := production.TotalPopulationIn - production.TotalDepletion var totalSalesAmount float64
if lastPopulation, ok := s.getLastPopulationFromRecordings(c, projectFlockKandangs); ok { for _, delivery := range filteredDeliveryProducts {
actualPopulation = lastPopulation totalWeightSold += delivery.TotalWeight
} totalSalesAmount += delivery.TotalPrice
totalWeightProduced := production.TotalWeightProduced
totalEggWeightKg := production.TotalEggWeightKg
weightForCalculation := totalWeightProduced
if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) {
weightForCalculation = totalEggWeightKg
} }
// Calculate metrics - always use kg ayam for rp_per_kg
calculateMetrics := func(amount float64) (rpPerBird, rpPerKg float64) { calculateMetrics := func(amount float64) (rpPerBird, rpPerKg float64) {
if actualPopulation > 0 { if actualPopulation > 0 {
rpPerBird = amount / actualPopulation rpPerBird = amount / actualPopulation // Use actual population
} }
if weightForCalculation > 0 { if totalWeightProduced > 0 {
rpPerKg = amount / weightForCalculation rpPerKg = amount / totalWeightProduced
} }
return return
} }
createHPPItem := func(id uint, category, code, label string, budgetAmount, realizationAmount float64) dto.HPPItem { // Calculate metrics for profit loss (use total population and total weight produced)
budgetRpPerBird, budgetRpPerKg := calculateMetrics(budgetAmount) calculateProfitLossMetrics := func(amount float64) (rpPerBird, rpPerKg float64) {
realizationRpPerBird, realizationRpPerKg := calculateMetrics(realizationAmount) if totalPopulation > 0 {
return dto.ToHPPItem( rpPerBird = amount / totalPopulation
id, }
category, if totalWeightProduced > 0 {
code, rpPerKg = amount / totalWeightProduced
label, }
dto.ToFinancialMetrics(budgetRpPerBird, budgetRpPerKg, budgetAmount), return
dto.ToFinancialMetrics(realizationRpPerBird, realizationRpPerKg, realizationAmount),
)
} }
// Build HPP Items using constants
hppItems := []dto.HPPItem{} hppItems := []dto.HPPItem{}
hppItems = append(hppItems, createHPPItem(1, "purchase", string(dto.HPPCodePakan), "Pembelian Pakan", costs.FeedCost, costs.FeedCost)) // PAKAN item
hppItems = append(hppItems, createHPPItem(2, "purchase", string(dto.HPPCodeOVK), "Pembelian OVK", costs.OvkCost, costs.OvkCost)) 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) docCode := string(dto.HPPCodeDOC)
docLabel := "Pembelian DOC" docLabel := "Pembelian DOC"
if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) { if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) {
docCode = string(dto.HPPCodeDepresiasi) docCode = string(dto.HPPCodeDepresiasi)
docLabel = "Depresiasi" docLabel = "Depresiasi"
} }
hppItems = append(hppItems, createHPPItem(3, "purchase", docCode, docLabel, costs.ChickenCost, costs.ChickenCost)) docBudgetRpPerBird, docBudgetRpPerKg := calculateMetrics(budgetAyam)
hppItems = append(hppItems, createHPPItem(4, "overhead", string(dto.HPPCodeOverhead), "Pengeluaran Overhead", costs.BudgetOperational, costs.RealizationOperational)) docRealizationRpPerBird, docRealizationRpPerKg := calculateMetrics(totalAyamPrice)
hppItems = append(hppItems, createHPPItem(5, "overhead", string(dto.HPPCodeEkspedisi), "Beban Ekspedisi", costs.ExpeditionCost, costs.ExpeditionCost)) 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 // OVERHEAD item
totalRealizationHpp := costs.FeedCost + costs.OvkCost + costs.ChickenCost + costs.RealizationOperational + costs.ExpeditionCost 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) hppBudgetRpPerBird, hppBudgetRpPerKg := calculateMetrics(totalBudgetHpp)
hppRealizationRpPerBird, hppRealizationRpPerKg := calculateMetrics(totalRealizationHpp) hppRealizationRpPerBird, hppRealizationRpPerKg := calculateMetrics(totalRealizationHpp)
var eggBudgeting, eggRealization *dto.FinancialMetrics var eggBudgeting, eggRealization *dto.FinancialMetrics
if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) { if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) && totalEggWeightKg > 0 {
accumulateEggMetrics := func(metrics **dto.FinancialMetrics, amount, rpPerKg float64) { eggBudgetRpPerKg := totalBudgetHpp / totalEggWeightKg
if *metrics == nil { eggRealizationRpPerKg := totalRealizationHpp / totalEggWeightKg
*metrics = &dto.FinancialMetrics{ eggBudgeting = &dto.FinancialMetrics{
RpPerBird: 0, RpPerBird: 0,
RpPerKg: rpPerKg, RpPerKg: eggBudgetRpPerKg,
Amount: amount, Amount: totalBudgetHpp,
}
} else {
(*metrics).Amount += amount
if totalEggWeightKg > 0 {
(*metrics).RpPerKg = (*metrics).Amount / totalEggWeightKg
}
}
} }
eggRealization = &dto.FinancialMetrics{
for _, projectFlockKandang := range projectFlockKandangs { RpPerBird: 0,
hppResponse, err := s.HppSvc.CalculateHppCost(projectFlockKandang.Id, nil) RpPerKg: eggRealizationRpPerKg,
if err == nil { Amount: totalRealizationHpp,
accumulateEggMetrics(&eggBudgeting, hppResponse.Estimation.Total, hppResponse.Estimation.HargaKg)
accumulateEggMetrics(&eggRealization, hppResponse.Real.Total, hppResponse.Real.HargaKg)
}
} }
} }
@@ -383,82 +543,15 @@ func (s closingKeuanganService) buildHPPSection(c *fiber.Ctx, projectFlock *enti
eggRealization, eggRealization,
) )
return dto.ToHPPSection(hppItems, hppSummary) hppSection := dto.ToHPPSection(hppItems, hppSummary)
}
func (s closingKeuanganService) buildProfitLossSection(projectFlock *entity.ProjectFlock, costs *CostData, production *ProductionData) dto.ProfitLossSection {
totalWeightProduced := production.TotalWeightProduced
totalEggWeightKg := production.TotalEggWeightKg
totalSalesAmount := production.TotalSalesAmount
totalWeightSold := production.TotalWeightSold
totalBirdSold := production.TotalBirdSold
actualPopulation := production.TotalPopulationIn - production.TotalDepletion
isLaying := projectFlock.Category == string(utils.ProjectFlockCategoryLaying)
// Fungsi untuk sales: LAYING = populasi aktual, GROWING = ekor terjual
calculateSalesMetrics := func(amount float64) (rpPerBird, rpPerKg float64) {
if isLaying {
if actualPopulation > 0 {
rpPerBird = amount / actualPopulation
}
if totalWeightSold > 0 {
rpPerKg = amount / totalWeightSold
}
} else {
if totalBirdSold > 0 {
rpPerBird = amount / totalBirdSold
}
if totalWeightSold > 0 {
rpPerKg = amount / totalWeightSold
}
}
return
}
// Fungsi untuk cost: per ekor = populasi aktual, per kg = LAYING telur produksi / GROWING ayam produksi
calculateCostMetrics := func(amount float64) (rpPerBird, rpPerKg float64) {
if actualPopulation > 0 {
rpPerBird = amount / actualPopulation
}
if isLaying {
if totalEggWeightKg > 0 {
rpPerKg = amount / totalEggWeightKg
}
} else {
if totalWeightProduced > 0 {
rpPerKg = amount / totalWeightProduced
}
}
return
}
// Fungsi untuk overhead/ekspedisi: LAYING = populasi aktual, GROWING = ekor terjual
calculateOverheadMetrics := func(amount float64) (rpPerBird, rpPerKg float64) {
if isLaying {
if actualPopulation > 0 {
rpPerBird = amount / actualPopulation
}
if totalWeightSold > 0 {
rpPerKg = amount / totalWeightSold
}
} else {
if totalBirdSold > 0 {
rpPerBird = amount / totalBirdSold
}
if totalWeightSold > 0 {
rpPerKg = amount / totalWeightSold
}
}
return
}
// Build Profit Loss Items using constants
plItems := []dto.ProfitLossItem{} plItems := []dto.ProfitLossItem{}
salesRpPerBird, salesRpPerKg := calculateSalesMetrics(totalSalesAmount) // SALES item
salesRpPerBird, salesRpPerKg := calculateProfitLossMetrics(totalSalesAmount)
salesLabel := "Penjualan Ayam" salesLabel := "Penjualan Ayam"
if isLaying { if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) {
salesLabel = "Penjualan Telur" salesLabel = "Penjualan Telur"
} }
plItems = append(plItems, dto.ToProfitLossItem( plItems = append(plItems, dto.ToProfitLossItem(
@@ -470,100 +563,76 @@ func (s closingKeuanganService) buildProfitLossSection(projectFlock *entity.Proj
totalSalesAmount, totalSalesAmount,
)) ))
totalSapronakAmount := costs.ChickenCost + costs.FeedCost + costs.OvkCost // SAPRONAK item - combines DOC/Depresiasi + PAKAN + OVK
sapronakRpPerBird := 0.0 totalSapronakAmount := totalAyamPrice + totalPakanPrice + totalOvkPrice
sapronakRpPerKg := 0.0 sapronakRpPerBird := docRealizationRpPerBird + pakanRealizationRpPerBird + ovkRealizationRpPerBird
for _, amount := range []float64{costs.ChickenCost, costs.FeedCost, costs.OvkCost} { sapronakRpPerKg := docRealizationRpPerKg + pakanRealizationRpPerKg + ovkRealizationRpPerKg
rpPerBird, rpPerKg := calculateCostMetrics(amount) sapronakLabel := "Pengeluaran Sapronak"
sapronakRpPerBird += rpPerBird
sapronakRpPerKg += rpPerKg
}
plItems = append(plItems, dto.ToProfitLossItem( plItems = append(plItems, dto.ToProfitLossItem(
string(dto.PLCodeSapronak), string(dto.PLCodeSapronak),
"Pengeluaran Sapronak", sapronakLabel,
"purchase", "purchase",
sapronakRpPerBird, sapronakRpPerBird,
sapronakRpPerKg, sapronakRpPerKg,
totalSapronakAmount, totalSapronakAmount,
)) ))
overheadRpPerBird, overheadRpPerKg := calculateOverheadMetrics(costs.RealizationOperational) // OVERHEAD item
overheadRpPerBird, overheadRpPerKg := calculateMetrics(totalOperationalRealization)
plItems = append(plItems, dto.ToProfitLossItem( plItems = append(plItems, dto.ToProfitLossItem(
string(dto.PLCodeOverhead), string(dto.PLCodeOverhead),
"Overhead", "Overhead",
"overhead", "overhead",
overheadRpPerBird, overheadRpPerBird,
overheadRpPerKg, overheadRpPerKg,
costs.RealizationOperational, totalOperationalRealization,
)) ))
ekspedisiRpPerBird, ekspedisiRpPerKg := calculateOverheadMetrics(costs.ExpeditionCost) // EKSPEDISI item
plItems = append(plItems, dto.ToProfitLossItem( plItems = append(plItems, dto.ToProfitLossItem(
string(dto.PLCodeEkspedisi), string(dto.PLCodeEkspedisi),
"Ekspedisi", "Ekspedisi",
"overhead", "overhead",
ekspedisiRpPerBird, ekspedisiRealizationRpPerBird,
ekspedisiRpPerKg, ekspedisiRealizationRpPerKg,
costs.ExpeditionCost, 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 costOfGoodsSoldRpPerBird := sapronakRpPerBird
costOfGoodsSoldRpPerKg := sapronakRpPerKg
grossProfit := totalSalesAmount - costOfGoodsSold grossProfit := totalSalesAmount - costOfGoodsSold
grossProfitRpPerBird := salesRpPerBird - costOfGoodsSoldRpPerBird grossProfitRpPerBird := salesRpPerBird - costOfGoodsSoldRpPerBird
grossProfitRpPerKg := salesRpPerKg - costOfGoodsSoldRpPerKg
totalOperatingExpenses := costs.RealizationOperational + costs.ExpeditionCost // Operating Expenses (Overhead + Ekspedisi)
totalOperatingExpensesRpPerBird := overheadRpPerBird + ekspedisiRpPerBird totalOperatingExpenses := totalOperationalRealization + totalEkspedisiRealization
totalOperatingExpensesRpPerKg := overheadRpPerKg + ekspedisiRpPerKg totalOperatingExpensesRpPerBird := overheadRpPerBird + ekspedisiRealizationRpPerBird
// Net Profit = Gross Profit - Operating Expenses
netProfit := grossProfit - totalOperatingExpenses netProfit := grossProfit - totalOperatingExpenses
netProfitRpPerBird := grossProfitRpPerBird - totalOperatingExpensesRpPerBird netProfitRpPerBird := grossProfitRpPerBird - totalOperatingExpensesRpPerBird
netProfitRpPerKg := grossProfitRpPerKg - totalOperatingExpensesRpPerKg
plSummary := dto.ToProfitLossSummary( plSummary := dto.ToProfitLossSummary(
dto.ToFinancialMetrics(grossProfitRpPerBird, grossProfitRpPerKg, grossProfit), dto.ToFinancialMetrics(grossProfitRpPerBird, 0, grossProfit),
dto.ToFinancialMetrics(totalOperatingExpensesRpPerBird, totalOperatingExpensesRpPerKg, totalOperatingExpenses), dto.ToFinancialMetrics(totalOperatingExpensesRpPerBird, 0, totalOperatingExpenses),
dto.ToFinancialMetrics(netProfitRpPerBird, netProfitRpPerKg, netProfit), 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 (s closingKeuanganService) getLastPopulationFromRecordings(c *fiber.Ctx, projectFlockKandangs []entity.ProjectFlockKandang) (float64, bool) { // containsItem checks if a string exists in a slice
if s.RecordingRepo == nil || len(projectFlockKandangs) == 0 { func containsItem(slice []string, item string) bool {
return 0, false for _, s := range slice {
} if strings.EqualFold(s, item) {
total := 0.0
recordedCount := 0
for _, kandang := range projectFlockKandangs {
latest, err := s.RecordingRepo.GetLatestByProjectFlockKandangID(c.Context(), kandang.Id)
if err != nil {
s.Log.Errorf("Failed to fetch latest recording for project_flock_kandang_id=%d: %+v", kandang.Id, err)
return 0, false
}
if latest == nil || latest.TotalChickQty == nil {
continue
}
recordedCount++
if *latest.TotalChickQty > 0 {
total += *latest.TotalChickQty
}
}
if recordedCount != len(projectFlockKandangs) {
return 0, false
}
return total, true
}
func containsFlag(flags []entity.Flag, name string) bool {
for _, flag := range flags {
if flag.Name == name {
return true return true
} }
} }
@@ -2,7 +2,6 @@ package service
import ( import (
"context" "context"
"fmt"
"strings" "strings"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
@@ -18,8 +17,8 @@ import (
) )
type SapronakService interface { type SapronakService interface {
GetSapronakByProject(ctx *fiber.Ctx, projectFlockID uint, flag string) ([]dto.SapronakReportDTO, map[uint][]string, error) GetSapronakByProject(ctx *fiber.Ctx, projectFlockID uint, flag string) ([]dto.SapronakReportDTO, error)
GetSapronakByKandang(ctx *fiber.Ctx, projectFlockID uint, pfkID uint, flag string) (*dto.SapronakReportDTO, map[uint][]string, error) GetSapronakByKandang(ctx *fiber.Ctx, projectFlockID uint, pfkID uint, flag string) (*dto.SapronakReportDTO, error)
} }
type sapronakService struct { type sapronakService struct {
@@ -42,9 +41,9 @@ func NewSapronakService(
} }
} }
func (s sapronakService) GetSapronakByProject(c *fiber.Ctx, projectFlockID uint, flag string) ([]dto.SapronakReportDTO, map[uint][]string, error) { func (s sapronakService) GetSapronakByProject(c *fiber.Ctx, projectFlockID uint, flag string) ([]dto.SapronakReportDTO, error) {
if projectFlockID == 0 { if projectFlockID == 0 {
return nil, nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_id is required") return nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_id is required")
} }
reports, err := s.computeSapronakReports(c.Context(), &validation.CountSapronakQuery{ reports, err := s.computeSapronakReports(c.Context(), &validation.CountSapronakQuery{
ProjectFlockID: projectFlockID, ProjectFlockID: projectFlockID,
@@ -52,27 +51,19 @@ func (s sapronakService) GetSapronakByProject(c *fiber.Ctx, projectFlockID uint,
Flag: flag, Flag: flag,
}) })
if err != nil { if err != nil {
return nil, nil, err return nil, err
} }
if len(reports) <= 1 { if len(reports) <= 1 {
flags, err := s.collectProductFlags(c.Context(), reports) return reports, nil
if err != nil {
return nil, nil, err
}
return reports, flags, nil
} }
combined := s.combineSapronakReports(reports, projectFlockID) combined := s.combineSapronakReports(reports, projectFlockID)
flags, err := s.collectProductFlags(c.Context(), []dto.SapronakReportDTO{combined}) return []dto.SapronakReportDTO{combined}, nil
if err != nil {
return nil, nil, err
}
return []dto.SapronakReportDTO{combined}, flags, nil
} }
func (s sapronakService) GetSapronakByKandang(c *fiber.Ctx, projectFlockID uint, pfkID uint, flag string) (*dto.SapronakReportDTO, map[uint][]string, error) { func (s sapronakService) GetSapronakByKandang(c *fiber.Ctx, projectFlockID uint, pfkID uint, flag string) (*dto.SapronakReportDTO, error) {
if projectFlockID == 0 || pfkID == 0 { if projectFlockID == 0 || pfkID == 0 {
return nil, nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_id and project_flock_kandang_id are required") return nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_id and project_flock_kandang_id are required")
} }
results, err := s.computeSapronakReports(c.Context(), &validation.CountSapronakQuery{ results, err := s.computeSapronakReports(c.Context(), &validation.CountSapronakQuery{
@@ -82,20 +73,16 @@ func (s sapronakService) GetSapronakByKandang(c *fiber.Ctx, projectFlockID uint,
Flag: flag, Flag: flag,
}) })
if err != nil { if err != nil {
return nil, nil, err return nil, err
} }
for _, res := range results { for _, res := range results {
if res.ProjectFlockID == projectFlockID && res.ProjectFlockKandangID == pfkID { if res.ProjectFlockID == projectFlockID && res.ProjectFlockKandangID == pfkID {
flags, err := s.collectProductFlags(c.Context(), []dto.SapronakReportDTO{res}) return &res, nil
if err != nil {
return nil, nil, err
}
return &res, flags, nil
} }
} }
return nil, nil, fiber.NewError(fiber.StatusNotFound, "Sapronak for kandang not found") return nil, fiber.NewError(fiber.StatusNotFound, "Sapronak for kandang not found")
} }
func (s sapronakService) computeSapronakReports(ctx context.Context, params *validation.CountSapronakQuery) ([]dto.SapronakReportDTO, error) { func (s sapronakService) computeSapronakReports(ctx context.Context, params *validation.CountSapronakQuery) ([]dto.SapronakReportDTO, error) {
@@ -124,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. // 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 { if err != nil {
s.Log.Errorf("Failed to build sapronak items for pfk %d: %+v", pfk.Id, err) 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") return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to calculate sapronak report")
@@ -148,52 +135,6 @@ func (s sapronakService) computeSapronakReports(ctx context.Context, params *val
return results, nil return results, nil
} }
func (s sapronakService) collectProductFlags(ctx context.Context, reports []dto.SapronakReportDTO) (map[uint][]string, error) {
productIDs := make(map[uint]struct{})
for _, report := range reports {
for _, group := range report.Groups {
for _, item := range group.Items {
if item.ProductID > 0 {
productIDs[item.ProductID] = struct{}{}
}
}
}
}
if len(productIDs) == 0 {
return map[uint][]string{}, nil
}
ids := make([]uint, 0, len(productIDs))
for id := range productIDs {
ids = append(ids, id)
}
products, err := s.Repository.GetProductsWithFlagsByIDs(ctx, ids)
if err != nil {
return nil, err
}
result := make(map[uint][]string, len(products))
for _, product := range products {
if len(product.Flags) == 0 {
continue
}
flags := make([]string, 0, len(product.Flags))
for _, flag := range product.Flags {
name := strings.TrimSpace(flag.Name)
if name == "" {
continue
}
flags = append(flags, strings.ToUpper(name))
}
if len(flags) > 0 {
result[product.Id] = flags
}
}
return result, nil
}
func (s sapronakService) loadProjectFlockKandangs(ctx context.Context, params *validation.CountSapronakQuery) ([]entity.ProjectFlockKandang, error) { func (s sapronakService) loadProjectFlockKandangs(ctx context.Context, params *validation.CountSapronakQuery) ([]entity.ProjectFlockKandang, error) {
db := s.ProjectFlockKandangRepo.DB().WithContext(ctx). db := s.ProjectFlockKandangRepo.DB().WithContext(ctx).
Preload("ProjectFlock"). Preload("ProjectFlock").
@@ -321,7 +262,6 @@ type sapronakDetailMaps struct {
AdjOutgoing map[uint][]dto.SapronakDetailDTO AdjOutgoing map[uint][]dto.SapronakDetailDTO
TransferIn map[uint][]dto.SapronakDetailDTO TransferIn map[uint][]dto.SapronakDetailDTO
TransferOut map[uint][]dto.SapronakDetailDTO TransferOut map[uint][]dto.SapronakDetailDTO
SalesOut map[uint][]dto.SapronakDetailDTO
} }
func buildSapronakDetails( func buildSapronakDetails(
@@ -331,7 +271,6 @@ func buildSapronakDetails(
adjOutgoingRows map[uint][]repository.SapronakDetailRow, adjOutgoingRows map[uint][]repository.SapronakDetailRow,
transferInRows map[uint][]repository.SapronakDetailRow, transferInRows map[uint][]repository.SapronakDetailRow,
transferOutRows map[uint][]repository.SapronakDetailRow, transferOutRows map[uint][]repository.SapronakDetailRow,
salesOutRows map[uint][]repository.SapronakDetailRow,
) sapronakDetailMaps { ) sapronakDetailMaps {
result := sapronakDetailMaps{ result := sapronakDetailMaps{
Incoming: make(map[uint][]dto.SapronakDetailDTO), Incoming: make(map[uint][]dto.SapronakDetailDTO),
@@ -340,7 +279,6 @@ func buildSapronakDetails(
AdjOutgoing: make(map[uint][]dto.SapronakDetailDTO), AdjOutgoing: make(map[uint][]dto.SapronakDetailDTO),
TransferIn: make(map[uint][]dto.SapronakDetailDTO), TransferIn: make(map[uint][]dto.SapronakDetailDTO),
TransferOut: 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) { addRows := func(target map[uint][]dto.SapronakDetailDTO, src map[uint][]repository.SapronakDetailRow, jenis string, masuk bool) {
@@ -373,7 +311,6 @@ func buildSapronakDetails(
addRows(result.AdjOutgoing, adjOutgoingRows, "Adjustment Keluar", false) addRows(result.AdjOutgoing, adjOutgoingRows, "Adjustment Keluar", false)
addRows(result.TransferIn, transferInRows, "Mutasi Masuk", true) addRows(result.TransferIn, transferInRows, "Mutasi Masuk", true)
addRows(result.TransferOut, transferOutRows, "Mutasi Keluar", false) addRows(result.TransferOut, transferOutRows, "Mutasi Keluar", false)
addRows(result.SalesOut, salesOutRows, "Penjualan", false)
return result return result
} }
@@ -405,14 +342,6 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
if err != nil { if err != nil {
return nil, nil, 0, 0, err 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) adjIncomingRows, adjOutgoingRows, err := s.Repository.FetchSapronakAdjustments(ctx, pfk.KandangId)
if err != nil { if err != nil {
return nil, nil, 0, 0, err return nil, nil, 0, 0, err
@@ -421,10 +350,6 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
if err != nil { if err != nil {
return nil, nil, 0, 0, err return nil, nil, 0, 0, err
} }
salesOutRows, err := s.Repository.FetchSapronakSalesAllocatedDetails(ctx, pfk.Id)
if err != nil {
return nil, nil, 0, 0, err
}
filterFlag := strings.ToUpper(strings.TrimSpace(flagFilter)) filterFlag := strings.ToUpper(strings.TrimSpace(flagFilter))
matchesFlag := func(f string) bool { matchesFlag := func(f string) bool {
@@ -437,34 +362,6 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
} }
return candidate == filterFlag 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 // For project flocks with category GROWING, pullet usage from chickin
// should not be counted yet. Only when category is LAYING we allow // should not be counted yet. Only when category is LAYING we allow
@@ -503,17 +400,13 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
usageDetailsRows[pid] = append(usageDetailsRows[pid], rows...) 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 incomingDetails := detailMaps.Incoming
usageDetails := detailMaps.Usage usageDetails := detailMaps.Usage
adjIncoming := detailMaps.AdjIncoming adjIncoming := detailMaps.AdjIncoming
adjOutgoing := detailMaps.AdjOutgoing adjOutgoing := detailMaps.AdjOutgoing
transIncoming := detailMaps.TransferIn transIncoming := detailMaps.TransferIn
transOutgoing := detailMaps.TransferOut transOutgoing := detailMaps.TransferOut
salesOutgoing := detailMaps.SalesOut
transIncoming = dedupTransfers(transIncoming)
transOutgoing = dedupTransfers(transOutgoing)
ensureGroup := func(flag string) *dto.SapronakGroupDTO { ensureGroup := func(flag string) *dto.SapronakGroupDTO {
if g, ok := groupMap[flag]; ok { if g, ok := groupMap[flag]; ok {
@@ -628,12 +521,13 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
if existing.ProductName == "" { if existing.ProductName == "" {
existing.ProductName = d.ProductName existing.ProductName = d.ProductName
} }
// Adjustment keluar should reduce stock without inflating usage-based HPP. existing.UsageQty += d.QtyKeluar
remaining := existing.IncomingQty - existing.UsageQty - d.QtyKeluar existing.UsageValue += d.Nilai
if remaining < 0 { if existing.IncomingQty >= existing.UsageQty {
remaining = 0 existing.RemainingQty = existing.IncomingQty - existing.UsageQty
} else {
existing.RemainingQty = 0
} }
existing.RemainingQty = remaining
itemMap[productID] = existing itemMap[productID] = existing
} }
} }
@@ -789,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)) groups := make([]dto.SapronakGroupDTO, 0, len(groupMap))
for _, g := range groupMap { for _, g := range groupMap {
groups = append(groups, *g) groups = append(groups, *g)
@@ -9,11 +9,9 @@ type Update struct {
} }
type Query struct { type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1,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"` Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"`
Search string `query:"search" validate:"omitempty,max=50"` 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"`
} }
const ( const (
@@ -26,5 +24,4 @@ type ClosingSapronakQuery struct {
Page int `query:"page" validate:"omitempty,number,min=1,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"` Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"`
KandangID *uint `query:"kandang_id" validate:"omitempty,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", "KANDANG",
}, },
"stock_log": map[string][]string{ "stock_log": map[string][]string{
"log_types": []string{"TRANSFER", "ADJUSTMENT", "MARKETING", "CHICKIN", "PURCHASE", "RECORDING"}, "log_types": []string{"TRANSFER", "ADJUSTMENT"},
"transaction_types": []string{"INCREASE", "DECREASE"}, "transaction_types": []string{"INCREASE", "DECREASE"},
}, },
"supplier_categories": []string{ "supplier_categories": []string{
@@ -2,7 +2,6 @@ package controller
import ( import (
"math" "math"
"mime/multipart"
"strconv" "strconv"
"gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/dto" "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/dto"
@@ -363,9 +362,6 @@ func (u *DailyChecklistController) UpdateOne(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadRequest, "Invalid multipart form") return fiber.NewError(fiber.StatusBadRequest, "Invalid multipart form")
} }
req.Documents = form.File["documents"] req.Documents = form.File["documents"]
if err := validateDailyChecklistDocumentSizes(req.Documents); err != nil {
return err
}
if err := c.BodyParser(req); err != nil { if err := c.BodyParser(req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
@@ -385,16 +381,6 @@ func (u *DailyChecklistController) UpdateOne(c *fiber.Ctx) error {
}) })
} }
func validateDailyChecklistDocumentSizes(files []*multipart.FileHeader) error {
const maxDailyChecklistDocumentBytes = 5 * 1024 * 1024 // 5MB
for _, file := range files {
if file != nil && file.Size > maxDailyChecklistDocumentBytes {
return fiber.NewError(fiber.StatusRequestEntityTooLarge, "Document size must be <= 5MB")
}
}
return nil
}
func (u *DailyChecklistController) DeleteOne(c *fiber.Ctx) error { func (u *DailyChecklistController) DeleteOne(c *fiber.Ctx) error {
param := c.Params("idDailyChecklist") param := c.Params("idDailyChecklist")
@@ -3,14 +3,13 @@ package service
import ( import (
"errors" "errors"
"math" "math"
"regexp"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
"time" "time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware" middleware "gitlab.com/mbugroup/lti-api.git/internal/middleware"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/repositories" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/validations" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/validations"
phaseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phasess/repositories" phaseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/phasess/repositories"
@@ -135,87 +134,6 @@ func (s dailyChecklistService) withRelations(db *gorm.DB) *gorm.DB {
return db.Preload("Kandang") return db.Preload("Kandang")
} }
func (s dailyChecklistService) ensureChecklistAccess(c *fiber.Ctx, checklistID uint) error {
if checklistID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid checklist id")
}
db := s.Repository.DB().WithContext(c.Context()).
Table("daily_checklists dc").
Joins("JOIN kandangs k ON k.id = dc.kandang_id").
Joins("JOIN locations loc ON loc.id = k.location_id").
Joins("JOIN areas a ON a.id = loc.area_id").
Where("dc.id = ?", checklistID)
scopedDB, err := m.ApplyLocationAreaScope(c, db, "loc.id", "a.id")
if err != nil {
return err
}
var count int64
if err := scopedDB.Count(&count).Error; err != nil {
return err
}
if count == 0 {
return fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found")
}
return nil
}
func (s dailyChecklistService) ensureKandangAccess(c *fiber.Ctx, kandangID uint) error {
if kandangID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid kandang id")
}
db := s.Repository.DB().WithContext(c.Context()).
Table("kandangs k").
Joins("JOIN locations loc ON loc.id = k.location_id").
Joins("JOIN areas a ON a.id = loc.area_id").
Where("k.id = ?", kandangID)
scopedDB, err := m.ApplyLocationAreaScope(c, db, "loc.id", "a.id")
if err != nil {
return err
}
var count int64
if err := scopedDB.Count(&count).Error; err != nil {
return err
}
if count == 0 {
return fiber.NewError(fiber.StatusNotFound, "Kandang not found")
}
return nil
}
func (s dailyChecklistService) ensureTaskAccess(c *fiber.Ctx, taskID uint) error {
if taskID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid task id")
}
db := s.Repository.DB().WithContext(c.Context()).
Table("daily_checklist_activity_tasks t").
Joins("JOIN daily_checklists dc ON dc.id = t.checklist_id").
Joins("JOIN kandangs k ON k.id = dc.kandang_id").
Joins("JOIN locations loc ON loc.id = k.location_id").
Joins("JOIN areas a ON a.id = loc.area_id").
Where("t.id = ?", taskID)
scopedDB, err := m.ApplyLocationAreaScope(c, db, "loc.id", "a.id")
if err != nil {
return err
}
var count int64
if err := scopedDB.Count(&count).Error; err != nil {
return err
}
if count == 0 {
return fiber.NewError(fiber.StatusNotFound, "Task not found")
}
return nil
}
func (s dailyChecklistService) GetAll(c *fiber.Ctx, params *validation.Query) ([]DailyChecklistListItem, int64, error) { func (s dailyChecklistService) GetAll(c *fiber.Ctx, params *validation.Query) ([]DailyChecklistListItem, int64, error) {
if err := s.Validate.Struct(params); err != nil { if err := s.Validate.Struct(params); err != nil {
return nil, 0, err return nil, 0, err
@@ -225,15 +143,7 @@ func (s dailyChecklistService) GetAll(c *fiber.Ctx, params *validation.Query) ([
db := s.Repository.DB().WithContext(c.Context()). db := s.Repository.DB().WithContext(c.Context()).
Table("daily_checklists dc"). Table("daily_checklists dc").
Joins("JOIN kandangs k ON k.id = dc.kandang_id"). Joins("JOIN kandangs k ON k.id = dc.kandang_id")
Joins("JOIN locations loc ON loc.id = k.location_id").
Joins("JOIN areas a ON a.id = loc.area_id")
var scopeErr error
db, scopeErr = m.ApplyLocationAreaScope(c, db, "loc.id", "a.id")
if scopeErr != nil {
return nil, 0, scopeErr
}
if params.DateFrom != "" { if params.DateFrom != "" {
dateFrom, err := time.Parse("2006-01-02", params.DateFrom) dateFrom, err := time.Parse("2006-01-02", params.DateFrom)
@@ -260,9 +170,8 @@ func (s dailyChecklistService) GetAll(c *fiber.Ctx, params *validation.Query) ([
} }
if params.Search != "" { if params.Search != "" {
re := regexp.MustCompile("[^a-zA-Z0-9]") like := "%" + params.Search + "%"
like := re.ReplaceAll([]byte("%"+params.Search+"%"), []byte("")) db = db.Where("(k.name ILIKE ? OR dc.category::text ILIKE ?)", like, like)
db = db.Where("(regexp_replace(k.name, '[^a-zA-Z0-9]', '', 'g') ILIKE ? OR regexp_replace(dc.category::text, '[^a-zA-Z0-9]', '', 'g') ILIKE ?)", string(like), string(like))
} }
countDB := db.Session(&gorm.Session{}) countDB := db.Session(&gorm.Session{})
@@ -385,9 +294,6 @@ func (s dailyChecklistService) GetAll(c *fiber.Ctx, params *validation.Query) ([
} }
func (s dailyChecklistService) GetOne(c *fiber.Ctx, id uint) (*entity.DailyChecklist, error) { func (s dailyChecklistService) GetOne(c *fiber.Ctx, id uint) (*entity.DailyChecklist, error) {
if err := s.ensureChecklistAccess(c, id); err != nil {
return nil, err
}
dailyChecklist, err := s.Repository.GetByID(c.Context(), id, s.withRelations) dailyChecklist, err := s.Repository.GetByID(c.Context(), id, s.withRelations)
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found") return nil, fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found")
@@ -493,9 +399,6 @@ func (s *dailyChecklistService) CreateOne(c *fiber.Ctx, req *validation.Create)
if err := s.Validate.Struct(req); err != nil { if err := s.Validate.Struct(req); err != nil {
return nil, err return nil, err
} }
if err := s.ensureKandangAccess(c, req.KandangId); err != nil {
return nil, err
}
date, err := time.Parse("2006-01-02", req.Date) date, err := time.Parse("2006-01-02", req.Date)
if err != nil { if err != nil {
@@ -528,9 +431,6 @@ func (s dailyChecklistService) UpdateOne(c *fiber.Ctx, req *validation.Update, i
if err := s.Validate.Struct(req); err != nil { if err := s.Validate.Struct(req); err != nil {
return nil, err return nil, err
} }
if err := s.ensureChecklistAccess(c, id); err != nil {
return nil, err
}
deletedIDs := make([]uint, 0) deletedIDs := make([]uint, 0)
if req.DeletedDocumentIDs != nil { if req.DeletedDocumentIDs != nil {
@@ -556,7 +456,7 @@ func (s dailyChecklistService) UpdateOne(c *fiber.Ctx, req *validation.Update, i
updateBody["reject_reason"] = *req.RejectReason updateBody["reject_reason"] = *req.RejectReason
} }
actorID, err := m.ActorIDFromContext(c) actorID, err := middleware.ActorIDFromContext(c)
if err != nil { if err != nil {
return &entity.DailyChecklist{}, fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context") return &entity.DailyChecklist{}, fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context")
} }
@@ -602,9 +502,6 @@ func (s dailyChecklistService) UpdateOne(c *fiber.Ctx, req *validation.Update, i
} }
func (s dailyChecklistService) DeleteOne(c *fiber.Ctx, id uint) error { func (s dailyChecklistService) DeleteOne(c *fiber.Ctx, id uint) error {
if err := s.ensureChecklistAccess(c, id); err != nil {
return err
}
if err := s.Repository.DeleteOne(c.Context(), id); err != nil { if err := s.Repository.DeleteOne(c.Context(), id); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found") return fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found")
@@ -619,9 +516,6 @@ func (s dailyChecklistService) AssignPhases(c *fiber.Ctx, id uint, req *validati
if err := s.Validate.Struct(req); err != nil { if err := s.Validate.Struct(req); err != nil {
return err return err
} }
if err := s.ensureChecklistAccess(c, id); err != nil {
return err
}
if _, err := s.Repository.GetByID(c.Context(), id, nil); err != nil { if _, err := s.Repository.GetByID(c.Context(), id, nil); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -703,9 +597,6 @@ func (s dailyChecklistService) AssignPhases(c *fiber.Ctx, id uint, req *validati
} }
func (s dailyChecklistService) RemoveAssignment(c *fiber.Ctx, id uint, employeeID uint) error { func (s dailyChecklistService) RemoveAssignment(c *fiber.Ctx, id uint, employeeID uint) error {
if err := s.ensureChecklistAccess(c, id); err != nil {
return err
}
if _, err := s.Repository.GetByID(c.Context(), id, nil); err != nil { if _, err := s.Repository.GetByID(c.Context(), id, nil); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found") return fiber.NewError(fiber.StatusNotFound, "DailyChecklist not found")
@@ -743,9 +634,6 @@ func (s dailyChecklistService) GetTasks(c *fiber.Ctx, checklistID uint) ([]entit
if checklistID == 0 { if checklistID == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "checklist_id is required") return nil, fiber.NewError(fiber.StatusBadRequest, "checklist_id is required")
} }
if err := s.ensureChecklistAccess(c, checklistID); err != nil {
return nil, err
}
if _, err := s.Repository.GetByID(c.Context(), checklistID, nil); err != nil { if _, err := s.Repository.GetByID(c.Context(), checklistID, nil); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -770,9 +658,6 @@ func (s dailyChecklistService) GetChecklistPhaseIDs(c *fiber.Ctx, checklistID ui
if checklistID == 0 { if checklistID == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "checklist_id is required") return nil, fiber.NewError(fiber.StatusBadRequest, "checklist_id is required")
} }
if err := s.ensureChecklistAccess(c, checklistID); err != nil {
return nil, err
}
if _, err := s.Repository.GetByID(c.Context(), checklistID, nil); err != nil { if _, err := s.Repository.GetByID(c.Context(), checklistID, nil); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -802,9 +687,6 @@ func (s dailyChecklistService) UpdateAssignment(c *fiber.Ctx, req *validation.Up
if err := s.Validate.Struct(req); err != nil { if err := s.Validate.Struct(req); err != nil {
return err return err
} }
if err := s.ensureTaskAccess(c, req.TaskID); err != nil {
return err
}
task := new(entity.DailyChecklistActivityTask) task := new(entity.DailyChecklistActivityTask)
if err := s.Repository.DB().WithContext(c.Context()).First(task, req.TaskID).Error; err != nil { if err := s.Repository.DB().WithContext(c.Context()).First(task, req.TaskID).Error; err != nil {
@@ -926,9 +808,6 @@ func (s dailyChecklistService) AssignTasks(c *fiber.Ctx, id uint, req *validatio
if err := s.Validate.Struct(req); err != nil { if err := s.Validate.Struct(req); err != nil {
return err return err
} }
if err := s.ensureChecklistAccess(c, id); err != nil {
return err
}
employeeIDs, err := parseIDs(req.EmployeeIDs) employeeIDs, err := parseIDs(req.EmployeeIDs)
if err != nil { if err != nil {
@@ -1021,16 +900,8 @@ func (s dailyChecklistService) GetSummary(c *fiber.Ctx, params *validation.Summa
Joins("JOIN daily_checklists d ON d.id = t.checklist_id"). Joins("JOIN daily_checklists d ON d.id = t.checklist_id").
Joins("JOIN kandangs k ON k.id = d.kandang_id"). Joins("JOIN kandangs k ON k.id = d.kandang_id").
Joins("JOIN employees e ON e.id = a.employee_id"). Joins("JOIN employees e ON e.id = a.employee_id").
Joins("JOIN locations loc ON loc.id = k.location_id").
Joins("JOIN areas ar ON ar.id = loc.area_id").
Where("d.date BETWEEN ? AND ? AND d.status = ?", dateFrom, dateTo, "APPROVED") Where("d.date BETWEEN ? AND ? AND d.status = ?", dateFrom, dateTo, "APPROVED")
var scopeErr error
db, scopeErr = m.ApplyLocationAreaScope(c, db, "loc.id", "ar.id")
if scopeErr != nil {
return nil, scopeErr
}
if params.Category != "" { if params.Category != "" {
db = db.Where("d.category = ?", params.Category) db = db.Where("d.category = ?", params.Category)
} }
@@ -1075,15 +946,6 @@ func (s dailyChecklistService) GetReport(c *fiber.Ctx, params *validation.Report
return nil, 0, err return nil, 0, err
} }
locationScope, err := m.ResolveLocationScope(c, s.Repository.DB())
if err != nil {
return nil, 0, err
}
areaScope, err := m.ResolveAreaScope(c, s.Repository.DB())
if err != nil {
return nil, 0, err
}
offset := (params.Page - 1) * params.Limit offset := (params.Page - 1) * params.Limit
buildBase := func() *gorm.DB { buildBase := func() *gorm.DB {
@@ -1100,9 +962,6 @@ func (s dailyChecklistService) GetReport(c *fiber.Ctx, params *validation.Report
Where("EXTRACT(YEAR FROM dc.date) = ?", params.Year). Where("EXTRACT(YEAR FROM dc.date) = ?", params.Year).
Where("dc.status = ?", "APPROVED") Where("dc.status = ?", "APPROVED")
db = m.ApplyScopeFilter(db, locationScope, "loc.id")
db = m.ApplyScopeFilter(db, areaScope, "a.id")
if params.AreaID != nil { if params.AreaID != nil {
db = db.Where("a.id = ?", *params.AreaID) db = db.Where("a.id = ?", *params.AreaID)
} }
@@ -29,7 +29,7 @@ type Query struct {
} }
type AssignPhases struct { type AssignPhases struct {
PhaseIDs string `json:"phase_ids" validate:"omitempty"` PhaseIDs string `json:"phase_ids" validate:"required"`
} }
type AssignTask struct { type AssignTask struct {
@@ -52,7 +52,7 @@ type SummaryQuery struct {
type ReportQuery struct { type ReportQuery struct {
Page int `query:"page" validate:"required,number,min=1,gt=0"` 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"` Month int `query:"bulan" validate:"required,number,min=1,max=12"`
Year int `query:"tahun" validate:"required,number,min=1900"` Year int `query:"tahun" validate:"required,number,min=1900"`
AreaID *uint `query:"area_id" validate:"omitempty"` AreaID *uint `query:"area_id" validate:"omitempty"`
@@ -6,7 +6,6 @@ import (
"strings" "strings"
"time" "time"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
"gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/dto" "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/dto"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/services" service "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/services"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/validations" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/validations"
@@ -82,20 +81,6 @@ func (u *DashboardController) GetAll(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadRequest, "Invalid include") return fiber.NewError(fiber.StatusBadRequest, "Invalid include")
} }
scope, err := m.ResolveLocationScope(c, u.DashboardService.DB())
if err != nil {
return err
}
if scope.Restrict {
if len(scope.IDs) == 0 {
lokasiIds = []uint{}
} else if len(lokasiIds) > 0 {
lokasiIds = intersectUint(lokasiIds, scope.IDs)
} else {
lokasiIds = scope.IDs
}
}
analysisMode := strings.ToUpper(strings.TrimSpace(c.Query("analysis_mode", validation.AnalysisModeOverview))) analysisMode := strings.ToUpper(strings.TrimSpace(c.Query("analysis_mode", validation.AnalysisModeOverview)))
metric := strings.ToLower(strings.TrimSpace(c.Query("metric", ""))) metric := strings.ToLower(strings.TrimSpace(c.Query("metric", "")))
@@ -191,23 +176,6 @@ func defaultUintSlice(values []uint) []uint {
return values return values
} }
func intersectUint(a, b []uint) []uint {
if len(a) == 0 || len(b) == 0 {
return nil
}
set := make(map[uint]struct{}, len(b))
for _, id := range b {
set[id] = struct{}{}
}
out := make([]uint, 0, len(a))
for _, id := range a {
if _, ok := set[id]; ok {
out = append(out, id)
}
}
return out
}
func parsePeriodDates(startDateRaw, endDateRaw string, location *time.Location) (time.Time, time.Time, time.Time, error) { func parsePeriodDates(startDateRaw, endDateRaw string, location *time.Location) (time.Time, time.Time, time.Time, error) {
now := time.Now().In(location) now := time.Now().In(location)
startDate := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, location) startDate := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, location)
+2 -5
View File
@@ -5,8 +5,6 @@ import (
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"gorm.io/gorm" "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" rDashboard "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/repositories"
sDashboard "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/services" 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) { func (DashboardModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) {
dashboardRepo := rDashboard.NewDashboardRepository(db) dashboardRepo := rDashboard.NewDashboardRepository(db)
hppCostRepo := commonRepo.NewHppCostRepository(db)
userRepo := rUser.NewUserRepository(db) userRepo := rUser.NewUserRepository(db)
hppSvc := commonService.NewHppService(hppCostRepo) dashboardService := sDashboard.NewDashboardService(dashboardRepo, validate)
dashboardService := sDashboard.NewDashboardService(dashboardRepo, validate, hppSvc)
userService := sUser.NewUserService(userRepo, validate) userService := sUser.NewUserService(userRepo, validate)
DashboardRoutes(router, userService, dashboardService) DashboardRoutes(router, userService, dashboardService)
} }
@@ -21,7 +21,6 @@ type DashboardRepository interface {
SumSellingPrice(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) (SellingPriceAggregate, error) 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) 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) 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) 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) GetUniformityWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]UniformityWeeklyMetric, error)
GetStandardWeeklyMetrics(ctx context.Context, weeks []int, filters *validation.DashboardFilter) ([]StandardWeeklyMetric, error) GetStandardWeeklyMetrics(ctx context.Context, weeks []int, filters *validation.DashboardFilter) ([]StandardWeeklyMetric, error)
@@ -107,23 +107,16 @@ func applyDashboardFilters(db *gorm.DB, filters *validation.DashboardFilter) *go
func (r *DashboardRepositoryImpl) GetRecordingWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]RecordingWeeklyMetric, error) { func (r *DashboardRepositoryImpl) GetRecordingWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]RecordingWeeklyMetric, error) {
var rows []RecordingWeeklyMetric var rows []RecordingWeeklyMetric
weekExpr := `CASE
WHEN r.day IS NULL OR r.day <= 0 THEN 1
WHEN UPPER(pf.category) = 'LAYING' THEN ((r.day - 1) / 7 + 1) + 17
ELSE ((r.day - 1) / 7 + 1)
END`
db := r.DB().WithContext(ctx). db := r.DB().WithContext(ctx).
Table("recordings AS r"). Table("recordings AS r").
Select(fmt.Sprintf(`%s AS week, Select(`((r.day - 1) / 7 + 1) AS week,
COALESCE(AVG(r.hen_day), 0) AS hen_day, COALESCE(AVG(r.hen_day), 0) AS hen_day,
COALESCE(AVG(r.egg_weight), 0) AS egg_weight, COALESCE(AVG(r.egg_weight), 0) AS egg_weight,
COALESCE(AVG(r.feed_intake), 0) AS feed_intake, COALESCE(AVG(r.feed_intake), 0) AS feed_intake,
COALESCE(AVG(r.fcr_value), 0) AS fcr_value, COALESCE(AVG(r.fcr_value), 0) AS fcr_value,
COALESCE(AVG(r.cum_depletion_rate), 0) AS cum_depletion_rate`, weekExpr)). COALESCE(AVG(r.cum_depletion_rate), 0) AS cum_depletion_rate`).
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_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"). Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id").
Joins("JOIN project_flocks AS pf ON pf.id = pfk.project_flock_id").
Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). 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") Where("r.day IS NOT NULL AND r.day > 0")
@@ -195,19 +188,92 @@ func (r *DashboardRepositoryImpl) GetStandardFcrWeekly(ctx context.Context, week
return nil, nil return nil, nil
} }
standardIDs := r.standardIDSubquery(filters) filterClause := ""
if standardIDs == nil { filterArgs := make([]interface{}, 0)
return nil, nil if filters != nil {
if len(filters.FlockIds) > 0 {
filterClause += " AND pf.id IN ?"
filterArgs = append(filterArgs, filters.FlockIds)
}
if len(filters.KandangIds) > 0 {
filterClause += " AND k.id IN ?"
filterArgs = append(filterArgs, filters.KandangIds)
}
if len(filters.LokasiIds) > 0 {
filterClause += " AND k.location_id IN ?"
filterArgs = append(filterArgs, filters.LokasiIds)
}
} }
var rows []StandardWeeklyFcrMetric query := fmt.Sprintf(`
db := r.DB().WithContext(ctx). WITH src AS (
Table("production_standard_details AS psd"). SELECT DISTINCT pf.production_standard_id, pf.fcr_id
Select("psd.week AS week, COALESCE(AVG(psd.standard_fcr), 0) AS std_fcr"). FROM project_flocks pf
Where("psd.week IN ?", weeks). JOIN project_flock_kandangs pfk ON pfk.project_flock_id = pf.id
Where("psd.production_standard_id IN (?)", standardIDs) JOIN kandangs k ON k.id = pfk.kandang_id
WHERE pf.production_standard_id > 0 AND pf.fcr_id > 0
%s
),
actual AS (
SELECT u.week AS week,
pf.fcr_id AS fcr_id,
AVG((u.chart_data->'statistics'->>'average_weight')::numeric) AS avg_weight
FROM project_flock_kandang_uniformity u
JOIN project_flock_kandangs pfk ON pfk.id = u.project_flock_kandang_id
JOIN project_flocks pf ON pf.id = pfk.project_flock_id
JOIN kandangs k ON k.id = pfk.kandang_id
WHERE u.week IN ? AND u.uniform_date IS NOT NULL AND pf.fcr_id > 0
%s
GROUP BY u.week, pf.fcr_id
),
target AS (
SELECT sgd.week AS week,
src.fcr_id AS fcr_id,
AVG(sgd.target_mean_bw) AS target_mean_bw
FROM standard_growth_details sgd
JOIN src ON src.production_standard_id = sgd.production_standard_id
WHERE sgd.week IN ?
GROUP BY sgd.week, src.fcr_id
),
weights AS (
SELECT COALESCE(a.week, t.week) AS week,
COALESCE(a.fcr_id, t.fcr_id) AS fcr_id,
COALESCE(
CASE WHEN a.avg_weight > 10 THEN a.avg_weight / 1000 ELSE a.avg_weight END,
CASE WHEN t.target_mean_bw > 10 THEN t.target_mean_bw / 1000 ELSE t.target_mean_bw END
) AS weight
FROM actual a
FULL OUTER JOIN target t ON t.week = a.week AND t.fcr_id = a.fcr_id
)
SELECT w.week AS week,
COALESCE(AVG(
COALESCE(
(SELECT fs.fcr_number
FROM fcr_standards fs
WHERE fs.fcr_id = w.fcr_id
AND fs.weight >= w.weight
ORDER BY fs.weight ASC
LIMIT 1),
(SELECT fs.fcr_number
FROM fcr_standards fs
WHERE fs.fcr_id = w.fcr_id
ORDER BY fs.weight DESC
LIMIT 1)
)
), 0) AS std_fcr
FROM weights w
GROUP BY w.week
ORDER BY w.week ASC
`, filterClause, filterClause)
if err := db.Group("psd.week").Order("psd.week ASC").Scan(&rows).Error; err != nil { args := make([]interface{}, 0, len(filterArgs)*2+2)
args = append(args, filterArgs...)
args = append(args, weeks)
args = append(args, filterArgs...)
args = append(args, weeks)
var rows []StandardWeeklyFcrMetric
if err := r.DB().WithContext(ctx).Raw(query, args...).Scan(&rows).Error; err != nil {
return nil, err return nil, err
} }
@@ -219,7 +285,7 @@ func (r *DashboardRepositoryImpl) SumEggProductionWeightGrams(ctx context.Contex
db := r.DB().WithContext(ctx). db := r.DB().WithContext(ctx).
Table("recording_eggs AS re"). 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 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 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 kandangs AS k ON k.id = pfk.kandang_id").
@@ -243,27 +309,6 @@ func (r *DashboardRepositoryImpl) SumEggProductionWeightKg(ctx context.Context,
return grams / 1000, nil 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) { func (r *DashboardRepositoryImpl) GetFeedUsageByUom(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]FeedUsageByUom, error) {
var rows []FeedUsageByUom var rows []FeedUsageByUom
@@ -444,6 +489,30 @@ func (r *DashboardRepositoryImpl) standardIDSubquery(filters *validation.Dashboa
return db return db
} }
func (r *DashboardRepositoryImpl) standardSourceSubquery(filters *validation.DashboardFilter) *gorm.DB {
db := r.DB().
Table("project_flocks AS pf").
Select("DISTINCT pf.production_standard_id, pf.fcr_id").
Joins("JOIN project_flock_kandangs AS pfk ON pfk.project_flock_id = pf.id").
Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id").
Where("pf.production_standard_id > 0").
Where("pf.fcr_id > 0")
if filters != nil {
if len(filters.FlockIds) > 0 {
db = db.Where("pf.id IN ?", filters.FlockIds)
}
if len(filters.KandangIds) > 0 {
db = db.Where("k.id IN ?", filters.KandangIds)
}
if len(filters.LokasiIds) > 0 {
db = db.Where("k.location_id IN ?", filters.LokasiIds)
}
}
return db
}
func (r *DashboardRepositoryImpl) GetComparisonSeries(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter, comparisonType string) ([]ComparisonSeries, error) { func (r *DashboardRepositoryImpl) GetComparisonSeries(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter, comparisonType string) ([]ComparisonSeries, error) {
seriesExpr, labelExpr, groupExpr, orderExpr, err := comparisonSeriesColumns(comparisonType) seriesExpr, labelExpr, groupExpr, orderExpr, err := comparisonSeriesColumns(comparisonType)
if err != nil { if err != nil {
@@ -482,23 +551,18 @@ func (r *DashboardRepositoryImpl) GetComparisonWeeklyMetrics(ctx context.Context
} }
var rows []ComparisonWeeklyMetric var rows []ComparisonWeeklyMetric
weekExpr := `CASE
WHEN r.day IS NULL OR r.day <= 0 THEN 1
WHEN UPPER(pf.category) = 'LAYING' THEN ((r.day - 1) / 7 + 1) + 17
ELSE ((r.day - 1) / 7 + 1)
END`
db := r.DB().WithContext(ctx). db := r.DB().WithContext(ctx).
Table("recordings AS r"). Table("recordings AS r").
Select(fmt.Sprintf(`%s AS week, Select(fmt.Sprintf(`((r.day - 1) / 7 + 1) AS week,
%s AS series_id, %s AS series_id,
COALESCE(AVG(%s), 0) AS value`, weekExpr, seriesExpr, metricExpr)). COALESCE(AVG(%s), 0) AS value`, seriesExpr, metricExpr)).
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_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"). Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id").
Joins("JOIN project_flocks AS pf ON pf.id = pfk.project_flock_id"). Joins("JOIN project_flocks AS pf ON pf.id = pfk.project_flock_id").
Joins("JOIN locations AS loc ON loc.id = k.location_id"). Joins("JOIN locations AS loc ON loc.id = k.location_id").
Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). 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) db = applyDashboardFilters(db, filters)
@@ -545,19 +609,13 @@ func (r *DashboardRepositoryImpl) GetComparisonWeeklyUniformityMetrics(ctx conte
func (r *DashboardRepositoryImpl) GetEggQualityWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]EggQualityWeeklyMetric, error) { func (r *DashboardRepositoryImpl) GetEggQualityWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]EggQualityWeeklyMetric, error) {
var rows []EggQualityWeeklyMetric var rows []EggQualityWeeklyMetric
weekExpr := `CASE
WHEN r.day IS NULL OR r.day <= 0 THEN 1
WHEN UPPER(pf.category) = 'LAYING' THEN ((r.day - 1) / 7 + 1) + 17
ELSE ((r.day - 1) / 7 + 1)
END`
db := r.DB().WithContext(ctx). db := r.DB().WithContext(ctx).
Table("recording_eggs AS re"). Table("recording_eggs AS re").
Select(fmt.Sprintf(` Select(`
%s AS week, ((r.day - 1) / 7 + 1) AS week,
COALESCE(SUM(CASE WHEN f.name = ? THEN re.qty ELSE 0 END), 0) AS normal_qty, COALESCE(SUM(CASE WHEN f.name = ? THEN re.qty ELSE 0 END), 0) AS normal_qty,
COALESCE(SUM(CASE WHEN f.name IN (?, ?, ?) THEN re.qty ELSE 0 END), 0) AS abnormal_qty, COALESCE(SUM(CASE WHEN f.name IN (?, ?, ?) THEN re.qty ELSE 0 END), 0) AS abnormal_qty,
COALESCE(SUM(re.qty), 0) AS total_qty`, weekExpr), COALESCE(SUM(re.qty), 0) AS total_qty`,
utils.FlagTelurUtuh, utils.FlagTelurUtuh,
utils.FlagTelurPutih, utils.FlagTelurPutih,
utils.FlagTelurRetak, utils.FlagTelurRetak,
@@ -566,7 +624,6 @@ func (r *DashboardRepositoryImpl) GetEggQualityWeeklyMetrics(ctx context.Context
Joins("JOIN recordings AS r ON r.id = re.recording_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 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 kandangs AS k ON k.id = pfk.kandang_id").
Joins("JOIN project_flocks AS pf ON pf.id = pfk.project_flock_id").
Joins("JOIN product_warehouses AS pw ON pw.id = re.product_warehouse_id"). Joins("JOIN product_warehouses AS pw ON pw.id = re.product_warehouse_id").
Joins("JOIN products AS p ON p.id = pw.product_id"). Joins("JOIN products AS p ON p.id = pw.product_id").
Joins("JOIN flags AS f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct). Joins("JOIN flags AS f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct).
@@ -587,21 +644,14 @@ func (r *DashboardRepositoryImpl) GetEggQualityWeeklyMetrics(ctx context.Context
func (r *DashboardRepositoryImpl) GetEggWeightWeeklyGrams(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]WeeklyEggWeightMetric, error) { func (r *DashboardRepositoryImpl) GetEggWeightWeeklyGrams(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]WeeklyEggWeightMetric, error) {
var rows []WeeklyEggWeightMetric var rows []WeeklyEggWeightMetric
weekExpr := `CASE
WHEN r.day IS NULL OR r.day <= 0 THEN 1
WHEN UPPER(pf.category) = 'LAYING' THEN ((r.day - 1) / 7 + 1) + 17
ELSE ((r.day - 1) / 7 + 1)
END`
db := r.DB().WithContext(ctx). db := r.DB().WithContext(ctx).
Table("recording_eggs AS re"). Table("recording_eggs AS re").
Select(fmt.Sprintf(` Select(`
%s AS week, ((r.day - 1) / 7 + 1) AS week,
COALESCE(SUM(re.weight * 1000), 0) AS egg_weight_grams`, weekExpr)). COALESCE(SUM(re.qty * re.weight), 0) AS egg_weight_grams`).
Joins("JOIN recordings AS r ON r.id = re.recording_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 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 kandangs AS k ON k.id = pfk.kandang_id").
Joins("JOIN project_flocks AS pf ON pf.id = pfk.project_flock_id").
Where("r.record_datetime >= ? AND r.record_datetime < ?", start, end). 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") Where("r.day IS NOT NULL AND r.day > 0")
@@ -618,22 +668,15 @@ func (r *DashboardRepositoryImpl) GetEggWeightWeeklyGrams(ctx context.Context, s
func (r *DashboardRepositoryImpl) GetFeedUsageWeeklyByUom(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]WeeklyFeedUsageMetric, error) { func (r *DashboardRepositoryImpl) GetFeedUsageWeeklyByUom(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]WeeklyFeedUsageMetric, error) {
var rows []WeeklyFeedUsageMetric var rows []WeeklyFeedUsageMetric
weekExpr := `CASE
WHEN r.day IS NULL OR r.day <= 0 THEN 1
WHEN UPPER(pf.category) = 'LAYING' THEN ((r.day - 1) / 7 + 1) + 17
ELSE ((r.day - 1) / 7 + 1)
END`
db := r.DB().WithContext(ctx). db := r.DB().WithContext(ctx).
Table("recording_stocks AS rs"). Table("recording_stocks AS rs").
Select(fmt.Sprintf(` Select(`
%s AS week, ((r.day - 1) / 7 + 1) AS week,
COALESCE(SUM(rs.usage_qty), 0) + COALESCE(SUM(rs.pending_qty), 0) AS total_qty, COALESCE(SUM(rs.usage_qty), 0) + COALESCE(SUM(rs.pending_qty), 0) AS total_qty,
LOWER(uoms.name) AS uom_name`, weekExpr)). LOWER(uoms.name) AS uom_name`).
Joins("JOIN recordings AS r ON r.id = rs.recording_id"). Joins("JOIN recordings AS r ON r.id = rs.recording_id").
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = r.project_flock_kandangs_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"). Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id").
Joins("JOIN project_flocks AS pf ON pf.id = pfk.project_flock_id").
Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id"). Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id").
Joins("JOIN products AS p ON p.id = pw.product_id"). Joins("JOIN products AS p ON p.id = pw.product_id").
Joins("JOIN uoms ON uoms.id = p.uom_id"). Joins("JOIN uoms ON uoms.id = p.uom_id").
@@ -10,7 +10,6 @@ import (
"strings" "strings"
"time" "time"
commonService "gitlab.com/mbugroup/lti-api.git/internal/common/service"
"gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/dto" "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/dto"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/repositories" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/validations" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/validations"
@@ -18,34 +17,26 @@ import (
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"gorm.io/gorm"
) )
type DashboardService interface { type DashboardService interface {
GetAll(ctx context.Context, params *validation.Query) (dto.DashboardPerformanceOverviewDTO, int64, error) GetAll(ctx context.Context, params *validation.Query) (dto.DashboardPerformanceOverviewDTO, int64, error)
DB() *gorm.DB
} }
type dashboardService struct { type dashboardService struct {
Log *logrus.Logger Log *logrus.Logger
Validate *validator.Validate Validate *validator.Validate
Repository repository.DashboardRepository 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{ return &dashboardService{
Log: utils.Log, Log: utils.Log,
Validate: validate, Validate: validate,
Repository: repo, Repository: repo,
HppSvc: hppSvc,
} }
} }
func (s dashboardService) DB() *gorm.DB {
return s.Repository.DB()
}
func (s dashboardService) GetAll(ctx context.Context, params *validation.Query) (dto.DashboardPerformanceOverviewDTO, int64, error) { func (s dashboardService) GetAll(ctx context.Context, params *validation.Query) (dto.DashboardPerformanceOverviewDTO, int64, error) {
if err := s.Validate.Struct(params); err != nil { if err := s.Validate.Struct(params); err != nil {
return dto.DashboardPerformanceOverviewDTO{}, 0, err return dto.DashboardPerformanceOverviewDTO{}, 0, err
@@ -601,13 +592,13 @@ func buildAggregateComparisonPercent(weeks []int, seriesRows []repository.Compar
count++ count++
} }
if count == 0 {
continue
}
if result[week] == nil { if result[week] == nil {
result[week] = map[uint]float64{} result[week] = map[uint]float64{}
} }
if count == 0 {
result[week][series.Id] = 0
continue
}
result[week][series.Id] = sum / count result[week][series.Id] = sum / count
} }
} }
@@ -855,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) { 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) totalEggKg, err := s.Repository.SumEggProductionWeightKg(ctx, startDate, endExclusive, nil)
if err != nil { if err != nil {
return 0, 0, err return 0, 0, err
@@ -902,37 +878,6 @@ func (s dashboardService) calculateHppGlobal(ctx context.Context, startDate, end
return hppCurrent, hppLast, nil 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) { 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) startPrevMonth, endPrevMonthExclusive := monthRange(endDate.AddDate(0, -1, 0), location)
currentEndExclusive := endDate.AddDate(0, 0, 1) currentEndExclusive := endDate.AddDate(0, 0, 1)
@@ -328,7 +328,6 @@ func ToKandangGroupDTO(pengajuans []ExpenseNonstockDTO, realisasi []ExpenseReali
} }
kandangMap[kandangId].Realisasi = append(kandangMap[kandangId].Realisasi, r) kandangMap[kandangId].Realisasi = append(kandangMap[kandangId].Realisasi, r)
} else { } else {
directRealisasi = append(directRealisasi, r)
} }
} }
@@ -70,8 +70,7 @@ func (r *ExpenseRealizationRepositoryImpl) GetClosingOverhead(ctx context.Contex
Joins("JOIN expenses ON expenses.id = expense_nonstocks.expense_id"). Joins("JOIN expenses ON expenses.id = expense_nonstocks.expense_id").
Joins("LEFT JOIN project_flock_kandangs ON project_flock_kandangs.id = expense_nonstocks.project_flock_kandang_id"). Joins("LEFT JOIN project_flock_kandangs ON project_flock_kandangs.id = expense_nonstocks.project_flock_kandang_id").
Joins("LEFT JOIN kandangs ON kandangs.id = expense_nonstocks.kandang_id"). Joins("LEFT JOIN kandangs ON kandangs.id = expense_nonstocks.kandang_id").
Where("expenses.realization_date IS NOT NULL"). Where("expenses.realization_date IS NOT NULL")
Where("expenses.category = ?", "BOP")
if projectFlockKandangID != nil { if projectFlockKandangID != nil {
db = db.Where(`( db = db.Where(`(
@@ -139,28 +138,9 @@ func (r *ExpenseRealizationRepositoryImpl) GetAllWithFilters(ctx context.Context
locationID := filters.LocationId locationID := filters.LocationId
areaID := filters.AreaId areaID := filters.AreaId
if filters.AllowedLocationIDs != nil || filters.AllowedAreaIDs != nil || locationID > 0 || areaID > 0 {
db = db.Joins("JOIN kandangs ON kandangs.id = expense_nonstocks.kandang_id")
}
if filters.AllowedLocationIDs != nil {
if len(filters.AllowedLocationIDs) == 0 {
db = db.Where("1 = 0")
} else {
db = db.Where("kandangs.location_id IN ?", filters.AllowedLocationIDs)
}
}
if filters.AllowedAreaIDs != nil {
if len(filters.AllowedAreaIDs) == 0 {
db = db.Where("1 = 0")
} else {
db = db.Joins("JOIN locations ON locations.id = kandangs.location_id").
Where("locations.area_id IN ?", filters.AllowedAreaIDs)
}
}
if locationID > 0 || areaID > 0 { if locationID > 0 || areaID > 0 {
db = db.Joins("JOIN kandangs ON kandangs.id = expense_nonstocks.kandang_id")
if locationID > 0 { if locationID > 0 {
db = db.Where("kandangs.location_id = ?", uint(locationID)) db = db.Where("kandangs.location_id = ?", uint(locationID))
} }
@@ -87,22 +87,16 @@ func (s expenseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]expens
return nil, 0, err return nil, 0, err
} }
var scopeErr error
offset := (params.Page - 1) * params.Limit offset := (params.Page - 1) * params.Limit
expenses, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { expenses, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db) db = s.withRelations(db)
db, scopeErr = middleware.ApplyLocationScope(c, db, "expenses.location_id")
if params.Search != "" { if params.Search != "" {
return db.Where("category ILIKE ?", "%"+params.Search+"%") return db.Where("category ILIKE ?", "%"+params.Search+"%")
} }
return db.Order("created_at DESC").Order("updated_at DESC") return db.Order("created_at DESC").Order("updated_at DESC")
}) })
if scopeErr != nil {
return nil, 0, scopeErr
}
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
@@ -123,16 +117,7 @@ func (s expenseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]expens
} }
func (s expenseService) GetOne(c *fiber.Ctx, id uint) (*expenseDto.ExpenseDetailDTO, error) { func (s expenseService) GetOne(c *fiber.Ctx, id uint) (*expenseDto.ExpenseDetailDTO, error) {
var scopeErr error expense, err := s.Repository.GetByID(c.Context(), id, s.withRelations)
expense, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db)
db, scopeErr = middleware.ApplyLocationScope(c, db, "expenses.location_id")
return db
})
if scopeErr != nil {
return nil, scopeErr
}
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -20,7 +20,7 @@ type InitialRelationDTO struct {
InitialBalanceType string `json:"initial_balance_type"` InitialBalanceType string `json:"initial_balance_type"`
InitialBalanceTypeLabel string `json:"initial_balance_type_label"` InitialBalanceTypeLabel string `json:"initial_balance_type_label"`
Party Party `json:"party"` Party Party `json:"party"`
Bank *bankDTO.BankRelationDTO `json:"bank"` Bank bankDTO.BankRelationDTO `json:"bank,omitempty"`
Direction string `json:"direction"` Direction string `json:"direction"`
Nominal float64 `json:"nominal"` Nominal float64 `json:"nominal"`
Notes string `json:"notes"` Notes string `json:"notes"`
@@ -128,12 +128,11 @@ func partyFromInitial(e entity.Payment) Party {
return party return party
} }
func bankFromInitial(e entity.Payment) *bankDTO.BankRelationDTO { func bankFromInitial(e entity.Payment) bankDTO.BankRelationDTO {
if e.BankWarehouse.Id == 0 { if e.BankWarehouse.Id == 0 {
return nil return bankDTO.BankRelationDTO{}
} }
bank := bankDTO.ToBankRelationDTO(e.BankWarehouse) return bankDTO.ToBankRelationDTO(e.BankWarehouse)
return &bank
} }
func userFromInitial(e entity.Payment) userDTO.UserRelationDTO { func userFromInitial(e entity.Payment) userDTO.UserRelationDTO {
@@ -162,7 +161,7 @@ func initialBalanceLabel(balanceType string) string {
} }
func initialBalanceTypeFromPayment(e entity.Payment) string { func initialBalanceTypeFromPayment(e entity.Payment) string {
if e.Nominal < 0 { if strings.EqualFold(e.Direction, "OUT") || e.Nominal < 0 {
return "NEGATIVE" return "NEGATIVE"
} }
return "POSITIVE" 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) { func (s *initialService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Payment, error) {
normalizeOptionalBankId(&req.BankId)
if err := s.Validate.Struct(req); err != nil { if err := s.Validate.Struct(req); err != nil {
return nil, err return nil, err
} }
@@ -125,7 +124,7 @@ func (s *initialService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entit
PaymentDate: time.Now(), PaymentDate: time.Now(),
PaymentMethod: string(utils.PaymentMethodSaldo), PaymentMethod: string(utils.PaymentMethodSaldo),
BankId: req.BankId, BankId: req.BankId,
Direction: directionForInitialType(party, balanceType), Direction: directionForInitialType(balanceType),
Nominal: signedNominal(balanceType, req.Nominal), Nominal: signedNominal(balanceType, req.Nominal),
Notes: req.Note, Notes: req.Note,
CreatedBy: actorID, 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) { 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 { if err := s.Validate.Struct(req); err != nil {
return nil, err 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 requiresExisting := req.PartyType != nil || req.PartyId != nil || req.InitialBalanceType != nil || req.Nominal != nil
requiresVerification := requiresExisting || req.ReferenceNumber != nil || req.Note != nil || req.BankId != nil requiresVerification := requiresExisting || req.ReferenceNumber != nil || req.Note != nil || req.BankId != nil
var existing *entity.Payment var existing *entity.Payment
var resolvedPartyType string
var resolvedPartyId uint
if requiresVerification { if requiresVerification {
current, err := s.Repository.GetByID(c.Context(), id, nil) current, err := s.Repository.GetByID(c.Context(), id, nil)
if errors.Is(err, gorm.ErrRecordNotFound) { 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") return nil, fiber.NewError(fiber.StatusNotFound, "Initial not found")
} }
existing = current existing = current
resolvedPartyType = existing.PartyType
resolvedPartyId = existing.PartyId
} }
if req.PartyType != nil || req.PartyId != nil { if req.PartyType != nil || req.PartyId != nil {
partyType := existing.PartyType
partyId := existing.PartyId
if req.PartyType != nil { if req.PartyType != nil {
normalized, err := normalizePartyType(*req.PartyType) normalized, err := normalizePartyType(*req.PartyType)
if err != nil { if err != nil {
return nil, err return nil, err
} }
resolvedPartyType = normalized partyType = normalized
updateBody["party_type"] = resolvedPartyType updateBody["party_type"] = partyType
} }
if req.PartyId != nil { if req.PartyId != nil {
resolvedPartyId = *req.PartyId partyId = *req.PartyId
updateBody["party_id"] = resolvedPartyId 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 return nil, err
} }
} }
@@ -241,11 +238,8 @@ func (s initialService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
nominal = *req.Nominal nominal = *req.Nominal
} }
updateBody["direction"] = directionForInitialType(resolvedPartyType, balanceType) updateBody["direction"] = directionForInitialType(balanceType)
updateBody["nominal"] = signedNominal(balanceType, nominal) updateBody["nominal"] = signedNominal(balanceType, nominal)
} else if req.PartyType != nil {
balanceType := balanceTypeFromPayment(existing)
updateBody["direction"] = directionForInitialType(resolvedPartyType, balanceType)
} }
if len(updateBody) == 0 { if len(updateBody) == 0 {
@@ -268,7 +262,7 @@ func isInitialTransaction(transactionType string) bool {
} }
func balanceTypeFromPayment(payment *entity.Payment) string { func balanceTypeFromPayment(payment *entity.Payment) string {
if payment.Nominal < 0 { if strings.EqualFold(payment.Direction, "OUT") || payment.Nominal < 0 {
return "NEGATIVE" return "NEGATIVE"
} }
return "POSITIVE" return "POSITIVE"
@@ -292,24 +286,11 @@ func normalizeInitialBalanceType(balanceType string) (string, error) {
} }
} }
func directionForInitialType(partyType string, balanceType string) string { func directionForInitialType(balanceType string) string {
switch utils.PaymentParty(strings.ToUpper(strings.TrimSpace(partyType))) { if strings.EqualFold(balanceType, "NEGATIVE") {
case utils.PaymentPartySupplier: return "OUT"
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"
} }
return "IN"
} }
func signedNominal(balanceType string, nominal float64) float64 { 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}, 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 { type Create struct {
PartyType string `json:"party_type" validate:"required_strict,max=50"` PartyType string `json:"party_type" validate:"required_strict,max=50"`
PartyId uint `json:"party_id" validate:"required_strict,number,gt=0"` 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"` ReferenceNumber string `json:"reference_number" validate:"required_strict,max=100"`
InitialBalanceType string `json:"initial_balance_type" validate:"required_strict,oneof=NEGATIVE POSITIVE"` InitialBalanceType string `json:"initial_balance_type" validate:"required_strict,oneof=NEGATIVE POSITIVE"`
Nominal float64 `json:"nominal" validate:"required_strict,gt=0"` 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, PaymentDate: adjustmentDate,
PaymentMethod: string(utils.PaymentMethodSaldo), PaymentMethod: string(utils.PaymentMethodSaldo),
BankId: req.BankId, BankId: req.BankId,
Direction: directionForInjectionNominal(req.Nominal), Direction: "IN",
Nominal: req.Nominal, Nominal: req.Nominal,
Notes: req.Notes, Notes: req.Notes,
CreatedBy: actorID, CreatedBy: actorID,
@@ -186,7 +186,6 @@ func (s injectionService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
} }
if req.Nominal != nil { if req.Nominal != nil {
updateBody["nominal"] = *req.Nominal updateBody["nominal"] = *req.Nominal
updateBody["direction"] = directionForInjectionNominal(*req.Nominal)
} }
if req.Notes != nil { if req.Notes != nil {
updateBody["notes"] = *req.Notes updateBody["notes"] = *req.Notes
@@ -211,13 +210,6 @@ func isInjectionTransaction(transactionType string) bool {
return strings.EqualFold(transactionType, string(utils.TransactionTypeInjection)) 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) { func (s injectionService) generateInjectionCode(ctx context.Context) (string, error) {
sequence, err := s.Repository.NextPaymentSequence(ctx) sequence, err := s.Repository.NextPaymentSequence(ctx)
if err != nil { if err != nil {
@@ -3,14 +3,14 @@ package validation
type Create struct { type Create struct {
BankId *uint `json:"bank_id" validate:"required_strict,number,gt=0"` BankId *uint `json:"bank_id" validate:"required_strict,number,gt=0"`
AdjustmentDate string `json:"adjustment_date" validate:"required_strict"` 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"` Notes string `json:"notes" validate:"required_strict,max=500"`
} }
type Update struct { type Update struct {
BankId *uint `json:"bank_id,omitempty" validate:"omitempty,number,gt=0"` BankId *uint `json:"bank_id,omitempty" validate:"omitempty,number,gt=0"`
AdjustmentDate *string `json:"adjustment_date,omitempty" validate:"omitempty"` 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"` Notes *string `json:"notes,omitempty" validate:"omitempty,max=500"`
} }
@@ -3,7 +3,6 @@ package controller
import ( import (
"math" "math"
"strconv" "strconv"
"strings"
"gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/dto" "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/dto"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/services" service "gitlab.com/mbugroup/lti-api.git/internal/modules/finance/transactions/services"
@@ -24,81 +23,10 @@ func NewTransactionController(transactionService service.TransactionService) *Tr
} }
func (u *TransactionController) GetAll(c *fiber.Ctx) error { func (u *TransactionController) GetAll(c *fiber.Ctx) error {
parseUintListParam := func(key string) ([]uint, error) {
raw := strings.TrimSpace(c.Query(key, ""))
if raw == "" {
return nil, nil
}
parts := strings.Split(raw, ",")
ids := make([]uint, 0, len(parts))
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed == "" {
return nil, strconv.ErrSyntax
}
parsed, err := strconv.ParseUint(trimmed, 10, 64)
if err != nil {
return nil, err
}
if parsed == 0 {
continue
}
ids = append(ids, uint(parsed))
}
if len(ids) == 0 {
return nil, nil
}
return ids, nil
}
parseStringListParam := func(key string) ([]string, error) {
raw := strings.TrimSpace(c.Query(key, ""))
if raw == "" {
return nil, nil
}
parts := strings.Split(raw, ",")
values := make([]string, 0, len(parts))
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed == "" {
return nil, strconv.ErrSyntax
}
values = append(values, trimmed)
}
if len(values) == 0 {
return nil, nil
}
return values, nil
}
bankIDs, err := parseUintListParam("bank_ids")
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid bank_ids")
}
customerIDs, err := parseUintListParam("customer_ids")
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid customer_ids")
}
supplierIDs, err := parseUintListParam("supplier_ids")
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid supplier_ids")
}
transactionTypes, err := parseStringListParam("transaction_types")
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid transaction_types")
}
query := &validation.Query{ query := &validation.Query{
Page: c.QueryInt("page", 1), Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10), Limit: c.QueryInt("limit", 10),
Search: c.Query("search", ""), Search: c.Query("search", ""),
TransactionTypes: transactionTypes,
BankIDs: bankIDs,
CustomerIDs: customerIDs,
SupplierIDs: supplierIDs,
SortDate: c.Query("sort_date", ""),
StartDate: c.Query("start_date", ""),
EndDate: c.Query("end_date", ""),
} }
if query.Page < 1 || query.Limit < 1 { if query.Page < 1 || query.Limit < 1 {
@@ -21,7 +21,7 @@ type TransactionRelationDTO struct {
Party Party `json:"party"` Party Party `json:"party"`
PaymentDate time.Time `json:"payment_date"` PaymentDate time.Time `json:"payment_date"`
PaymentMethod string `json:"payment_method"` PaymentMethod string `json:"payment_method"`
Bank *bankDTO.BankRelationDTO `json:"bank"` Bank bankDTO.BankRelationDTO `json:"bank,omitempty"`
ExpenseAmount float64 `json:"expense_amount"` ExpenseAmount float64 `json:"expense_amount"`
IncomeAmount float64 `json:"income_amount"` IncomeAmount float64 `json:"income_amount"`
Nominal float64 `json:"nominal"` Nominal float64 `json:"nominal"`
@@ -37,7 +37,7 @@ type TransactionListDTO struct {
Party Party `json:"party"` Party Party `json:"party"`
PaymentDate time.Time `json:"payment_date"` PaymentDate time.Time `json:"payment_date"`
PaymentMethod string `json:"payment_method"` PaymentMethod string `json:"payment_method"`
Bank *bankDTO.BankRelationDTO `json:"bank"` Bank bankDTO.BankRelationDTO `json:"bank"`
ExpenseAmount float64 `json:"expense_amount"` ExpenseAmount float64 `json:"expense_amount"`
IncomeAmount float64 `json:"income_amount"` IncomeAmount float64 `json:"income_amount"`
Nominal float64 `json:"nominal"` Nominal float64 `json:"nominal"`
@@ -151,12 +151,11 @@ func partyFromPayment(e entity.Payment) Party {
return party return party
} }
func bankFromPayment(e entity.Payment) *bankDTO.BankRelationDTO { func bankFromPayment(e entity.Payment) bankDTO.BankRelationDTO {
if e.BankWarehouse.Id == 0 { if e.BankWarehouse.Id == 0 {
return nil return bankDTO.BankRelationDTO{}
} }
bank := bankDTO.ToBankRelationDTO(e.BankWarehouse) return bankDTO.ToBankRelationDTO(e.BankWarehouse)
return &bank
} }
func userFromPayment(e entity.Payment) userDTO.UserRelationDTO { func userFromPayment(e entity.Payment) userDTO.UserRelationDTO {
@@ -4,7 +4,6 @@ import (
"context" "context"
"errors" "errors"
"strings" "strings"
"time"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
@@ -62,81 +61,21 @@ func (s transactionService) GetAll(c *fiber.Ctx, params *validation.Query) ([]en
return nil, 0, err 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 offset := (params.Page - 1) * params.Limit
transactions, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { transactions, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db) db = s.withRelations(db)
if params.Search != "" { if params.Search != "" {
like := "%" + strings.ToLower(strings.TrimSpace(params.Search)) + "%" like := "%" + strings.ToLower(strings.TrimSpace(params.Search)) + "%"
db = db.Joins( return db.Where(
"LEFT JOIN customers ON customers.id = payments.party_id AND payments.party_type = ? AND customers.deleted_at IS NULL",
string(utils.PaymentPartyCustomer),
).Joins(
"LEFT JOIN suppliers ON suppliers.id = payments.party_id AND payments.party_type = ? AND suppliers.deleted_at IS NULL",
string(utils.PaymentPartySupplier),
).Joins(
"LEFT JOIN banks ON banks.id = payments.bank_id AND banks.deleted_at IS NULL",
)
db = db.Where(
`LOWER(payment_code) LIKE ? OR `LOWER(payment_code) LIKE ? OR
LOWER(COALESCE(reference_number, '')) LIKE ? OR LOWER(COALESCE(reference_number, '')) LIKE ? OR
LOWER(COALESCE(payment_method, '')) LIKE ? OR
LOWER(COALESCE(transaction_type, '')) LIKE ? OR LOWER(COALESCE(transaction_type, '')) LIKE ? OR
LOWER(COALESCE(notes, '')) LIKE ? OR LOWER(COALESCE(notes, '')) LIKE ?`,
LOWER(COALESCE(customers.name, '')) LIKE ? OR like, like, like, like,
LOWER(COALESCE(suppliers.name, '')) LIKE ? OR
LOWER(COALESCE(banks.name, '')) LIKE ?`,
like, like, like, like, like, like, like, like,
) )
} }
return db.Order("payment_date DESC").Order("created_at DESC")
if len(params.TransactionTypes) > 0 {
types := make([]string, 0, len(params.TransactionTypes))
for _, transactionType := range params.TransactionTypes {
normalized := strings.ToUpper(strings.TrimSpace(transactionType))
if normalized == "" {
continue
}
types = append(types, normalized)
}
if len(types) > 0 {
db = db.Where("transaction_type IN ?", types)
}
}
if len(params.BankIDs) > 0 {
db = db.Where("bank_id IN ?", params.BankIDs)
}
customerIDs := params.CustomerIDs
supplierIDs := params.SupplierIDs
if len(customerIDs) > 0 && len(supplierIDs) > 0 {
db = db.Where(
"(party_type = ? AND party_id IN ?) OR (party_type = ? AND party_id IN ?)",
string(utils.PaymentPartyCustomer), customerIDs,
string(utils.PaymentPartySupplier), supplierIDs,
)
} else if len(customerIDs) > 0 {
db = db.Where("party_type = ? AND party_id IN ?", string(utils.PaymentPartyCustomer), customerIDs)
} else if len(supplierIDs) > 0 {
db = db.Where("party_type = ? AND party_id IN ?", string(utils.PaymentPartySupplier), supplierIDs)
}
if startDate != nil {
db = db.Where("payment_date >= ?", *startDate)
}
if endDate != nil {
db = db.Where("payment_date < ?", *endDate)
}
return applyTransactionSort(db, params.SortDate)
}) })
if err != nil { if err != nil {
@@ -234,47 +173,3 @@ func (s transactionService) approvalQueryModifier() func(*gorm.DB) *gorm.DB {
return db.Preload("ActionUser") 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")
}
}
@@ -1,22 +1,15 @@
package validation package validation
type Create struct { type Create struct {
Name string `json:"name" validate:"required_strict,min=3"` Name string `json:"name" validate:"required_strict,min=3"`
} }
type Update struct { type Update struct {
Name *string `json:"name,omitempty" validate:"omitempty"` Name *string `json:"name,omitempty" validate:"omitempty"`
} }
type Query struct { type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1,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"` Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"`
Search string `query:"search" validate:"omitempty,max=50"` Search string `query:"search" validate:"omitempty,max=50"`
TransactionTypes []string `query:"transaction_types" validate:"omitempty,dive,max=50"`
BankIDs []uint `query:"bank_ids" validate:"omitempty,dive,gt=0"`
CustomerIDs []uint `query:"customer_ids" validate:"omitempty,dive,gt=0"`
SupplierIDs []uint `query:"supplier_ids" validate:"omitempty,dive,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"`
} }
@@ -100,24 +100,26 @@ func ToProductWarehouseDTO(e *entity.ProductWarehouse) *ProductWarehouseDTO {
} }
} }
func ToAdjustmentRelationDTO(e *entity.AdjustmentStock) AdjustmentRelationDTO { func ToAdjustmentRelationDTO(e *entity.StockLog) AdjustmentRelationDTO {
return AdjustmentRelationDTO{ return AdjustmentRelationDTO{
Id: e.Id, Id: e.Id,
Note: "", Note: e.Notes,
Increase: e.TotalQty, Increase: e.Increase,
Decrease: e.UsageQty, Decrease: e.Decrease,
ProductWarehouseId: e.ProductWarehouseId, ProductWarehouseId: e.ProductWarehouseId,
ProductWarehouse: ToProductWarehouseDTO(e.ProductWarehouse), ProductWarehouse: ToProductWarehouseDTO(e.ProductWarehouse),
} }
} }
func ToAdjustmentListDTO(e *entity.AdjustmentStock) AdjustmentListDTO { func ToAdjustmentListDTO(e *entity.StockLog) AdjustmentListDTO {
var createdUser *userDTO.UserRelationDTO var createdUser *userDTO.UserRelationDTO
if e.CreatedUser != nil {
// Get created user from StockLog createdUser = &userDTO.UserRelationDTO{
if e.StockLog != nil && e.StockLog.CreatedUser != nil { Id: e.CreatedUser.Id,
mapped := userDTO.ToUserRelationDTO(*e.StockLog.CreatedUser) IdUser: e.CreatedUser.IdUser,
createdUser = &mapped Email: e.CreatedUser.Email,
Name: e.CreatedUser.Name,
}
} }
return AdjustmentListDTO{ 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{ return AdjustmentDetailDTO{
AdjustmentListDTO: ToAdjustmentListDTO(e), AdjustmentListDTO: ToAdjustmentListDTO(e),
// UpdatedAt: e.UpdatedAt,
} }
} }
@@ -2,21 +2,16 @@ package repositories
import ( import (
"context" "context"
"fmt"
"strconv"
"strings"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
) )
type AdjustmentStockRepository interface { type AdjustmentStockRepository interface {
CreateOne(ctx context.Context, data *entity.AdjustmentStock, modifier func(*gorm.DB) *gorm.DB) error 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 WithTx(tx *gorm.DB) AdjustmentStockRepository
DB() *gorm.DB DB() *gorm.DB
GenerateSequentialNumber(ctx context.Context, prefix string) (string, error)
} }
type adjustmentStockRepositoryImpl struct { type adjustmentStockRepositoryImpl struct {
@@ -35,13 +30,11 @@ func (r *adjustmentStockRepositoryImpl) CreateOne(ctx context.Context, data *ent
return q.Create(data).Error 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 var record entity.AdjustmentStock
q := r.db.WithContext(ctx) err := r.db.WithContext(ctx).
if modifier != nil { Where("stock_log_id = ?", stockLogID).
q = modifier(q) First(&record).Error
}
err := q.First(&record, id).Error
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -55,71 +48,3 @@ func (r *adjustmentStockRepositoryImpl) WithTx(tx *gorm.DB) AdjustmentStockRepos
func (r *adjustmentStockRepositoryImpl) DB() *gorm.DB { func (r *adjustmentStockRepositoryImpl) DB() *gorm.DB {
return r.db return r.db
} }
func (r *adjustmentStockRepositoryImpl) GenerateSequentialNumber(ctx context.Context, prefix string) (string, error) {
var values []string
err := r.db.WithContext(ctx).
Model(&entity.AdjustmentStock{}).
Where(fmt.Sprintf("%s ILIKE ?", "adj_number"), prefix+"%").
Select("adj_number").
Order(fmt.Sprintf("%s DESC", "adj_number")).
Limit(20).
Clauses(clause.Locking{Strength: "UPDATE"}).
Pluck("adj_number", &values).Error
if err != nil {
return "", err
}
next := 1
for _, value := range values {
if number, ok := parseNumericSuffix(value, prefix); ok {
next = number + 1
break
}
}
const maxAttempts = 20
for attempt := 0; attempt < maxAttempts; attempt++ {
candidate := fmt.Sprintf("%s%0*d", prefix, 5, next)
exists, err := r.numberExists(ctx, r.db, candidate)
if err != nil {
return "", err
}
if !exists {
return candidate, nil
}
next++
}
return "", fmt.Errorf("unable to generate unique %s", "adj_number")
}
func (r *adjustmentStockRepositoryImpl) numberExists(ctx context.Context, db *gorm.DB, value string) (bool, error) {
var count int64
if err := db.WithContext(ctx).
Model(&entity.AdjustmentStock{}).
Where(fmt.Sprintf("%s = ?", "adj_number"), value).
Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
func parseNumericSuffix(value, prefix string) (int, bool) {
if !strings.HasPrefix(value, prefix) {
return 0, false
}
suffix := strings.TrimPrefix(value, prefix)
if suffix == "" {
return 0, false
}
trimmed := strings.TrimLeft(suffix, "0")
if trimmed == "" {
trimmed = "0"
}
number, err := strconv.Atoi(trimmed)
if err != nil {
return 0, false
}
return number, true
}
@@ -25,9 +25,9 @@ import (
) )
type AdjustmentService interface { type AdjustmentService interface {
Adjustment(ctx *fiber.Ctx, req *validation.Create) (*entity.AdjustmentStock, error) Adjustment(ctx *fiber.Ctx, req *validation.Create) (*entity.StockLog, error)
GetOne(ctx *fiber.Ctx, id uint) (*entity.AdjustmentStock, error) GetOne(ctx *fiber.Ctx, id uint) (*entity.StockLog, error)
AdjustmentHistory(ctx *fiber.Ctx, query *validation.Query) ([]*entity.AdjustmentStock, int64, error) AdjustmentHistory(ctx *fiber.Ctx, query *validation.Query) ([]*entity.StockLog, int64, error)
} }
type adjustmentService struct { type adjustmentService struct {
@@ -70,15 +70,13 @@ func (s *adjustmentService) withRelations(db *gorm.DB) *gorm.DB {
Preload("ProductWarehouse"). Preload("ProductWarehouse").
Preload("ProductWarehouse.Product"). Preload("ProductWarehouse.Product").
Preload("ProductWarehouse.Warehouse"). Preload("ProductWarehouse.Warehouse").
Preload("StockLog.CreatedUser") Preload("CreatedUser")
} }
func (s *adjustmentService) GetOne(c *fiber.Ctx, id uint) (*entity.AdjustmentStock, error) { func (s *adjustmentService) GetOne(c *fiber.Ctx, id uint) (*entity.StockLog, error) {
if err := m.EnsureStockLogAccess(c, s.StockLogsRepository.DB(), id); err != nil { stockLog, err := s.StockLogsRepository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
return nil, err return s.withRelations(db).Preload("ProductWarehouse.Product.ProductCategory")
} })
adjustmentStock, err := s.AdjustmentStockRepository.GetByID(c.Context(), id, s.withRelations)
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Adjustment not found") return nil, fiber.NewError(fiber.StatusNotFound, "Adjustment not found")
@@ -87,10 +85,14 @@ func (s *adjustmentService) GetOne(c *fiber.Ctx, id uint) (*entity.AdjustmentSto
return nil, err 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 { if err := s.Validate.Struct(req); err != nil {
return nil, err return nil, err
} }
@@ -99,9 +101,6 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err := m.EnsureWarehouseAccess(c, s.WarehouseRepo.DB(), uint(req.WarehouseID)); err != nil {
return nil, err
}
if err := common.EnsureRelations(c.Context(), if err := common.EnsureRelations(c.Context(),
common.RelationCheck{Name: "Product", ID: &req.ProductID, Exists: s.ProductRepo.IdExists}, common.RelationCheck{Name: "Product", ID: &req.ProductID, Exists: s.ProductRepo.IdExists},
common.RelationCheck{Name: "Warehouse", ID: &req.WarehouseID, Exists: s.WarehouseRepo.IdExists}, common.RelationCheck{Name: "Warehouse", ID: &req.WarehouseID, Exists: s.WarehouseRepo.IdExists},
@@ -112,13 +111,12 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
if req.Quantity <= 0 { if req.Quantity <= 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "Quantity must be greater than zero") return nil, fiber.NewError(fiber.StatusBadRequest, "Quantity must be greater than zero")
} }
transactionType := strings.ToUpper(req.TransactionType) transactionType := strings.ToUpper(req.TransactionType)
if transactionType != string(utils.StockLogTransactionTypeIncrease) && transactionType != string(utils.StockLogTransactionTypeDecrease) { if transactionType != string(utils.StockLogTransactionTypeIncrease) && transactionType != string(utils.StockLogTransactionTypeDecrease) {
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid transaction type") return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid transaction type")
} }
var createdAdjustmentStockId uint var createdLogId uint
var projectFlockKandangID *uint var projectFlockKandangID *uint
pfkID, err := s.getActiveProjectFlockKandangID(ctx, uint(req.WarehouseID)) pfkID, err := s.getActiveProjectFlockKandangID(ctx, uint(req.WarehouseID))
@@ -153,13 +151,13 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
return nil, err return nil, err
} }
err = s.StockLogsRepository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { err = s.StockLogsRepository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
productWarehouse, err := s.ProductWarehouseRepo.GetProductWarehouseByProductAndWarehouseID(ctx, uint(req.ProductID), uint(req.WarehouseID))
productWarehouse, err := s.ProductWarehouseRepo.FindByProductWarehouseAndPfk(ctx, uint(req.ProductID), uint(req.WarehouseID), projectFlockKandangID)
if err != nil { if err != nil {
s.Log.Errorf("Failed to get product warehouse: %+v", err) s.Log.Errorf("Failed to get product warehouse: %+v", err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get product warehouse") return fiber.NewError(fiber.StatusInternalServerError, "Failed to get product warehouse")
} }
afterQuantity := productWarehouse.Quantity
newLog := &entity.StockLog{ newLog := &entity.StockLog{
LoggableType: string(utils.StockLogTypeAdjustment), LoggableType: string(utils.StockLogTypeAdjustment),
LoggableId: 0, LoggableId: 0,
@@ -168,54 +166,31 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
CreatedBy: actorID, 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) { if transactionType == string(utils.StockLogTransactionTypeIncrease) {
newLog.Increase = req.Quantity afterQuantity += req.Quantity
newLog.Stock += newLog.Increase newLog.Increase = afterQuantity
} else { } else {
if productWarehouse.Quantity < req.Quantity { 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")
} }
newLog.Decrease = req.Quantity afterQuantity -= req.Quantity
newLog.Stock -= newLog.Decrease newLog.Decrease = afterQuantity
} }
if err := s.StockLogsRepository.WithTx(tx).CreateOne(ctx, newLog, nil); err != nil { if err := s.StockLogsRepository.WithTx(tx).CreateOne(ctx, newLog, nil); err != nil {
s.Log.Errorf("Failed to create stock log: %+v", err)
return err return err
} }
adjustmentStock := &entity.AdjustmentStock{ adjustmentStock := &entity.AdjustmentStock{
StockLogId: newLog.Id,
ProductWarehouseId: productWarehouse.Id, ProductWarehouseId: productWarehouse.Id,
} }
code, err := s.AdjustmentStockRepository.GenerateSequentialNumber(ctx, utils.AdjustmentStockNumberPrefix)
if err != nil {
return err
}
adjustmentStock.AdjNumber = code
if err := s.AdjustmentStockRepository.WithTx(tx).CreateOne(ctx, adjustmentStock, nil); err != nil { 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") 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) { if transactionType == string(utils.StockLogTransactionTypeIncrease) {
note := fmt.Sprintf("Stock Adjustment IN #%d", newLog.Id) note := fmt.Sprintf("Stock Adjustment IN #%d", newLog.Id)
@@ -237,7 +212,7 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
UsableID: adjustmentStock.Id, UsableID: adjustmentStock.Id,
ProductWarehouseID: uint(productWarehouse.Id), ProductWarehouseID: uint(productWarehouse.Id),
Quantity: req.Quantity, Quantity: req.Quantity,
AllowPending: false, AllowPending: false, // Don't allow pending for adjustment
Tx: tx, Tx: tx,
}) })
if err != nil { if err != nil {
@@ -245,20 +220,24 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
} }
} }
createdAdjustmentStockId = adjustmentStock.Id // 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
}
createdLogId = newLog.Id
return nil return nil
}) })
if err != nil { if err != nil {
s.Log.Errorf("Transaction failed in CreateOne: %+v", err) 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 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) { func (s *adjustmentService) getActiveProjectFlockKandangID(ctx context.Context, warehouseID uint) (uint, error) {
@@ -287,15 +266,13 @@ func (s *adjustmentService) getActiveProjectFlockKandangID(ctx context.Context,
return uint(projectFlockKandang.Id), nil 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 { if err := s.Validate.Struct(query); err != nil {
return nil, 0, err return nil, 0, err
} }
offset := (query.Page - 1) * query.Limit offset := (query.Page - 1) * query.Limit
var isProductsExist bool
isWarehousesExist, err := s.WarehouseRepo.IdExists(c.Context(), uint(query.WarehouseID)) isWarehousesExist, err := s.WarehouseRepo.IdExists(c.Context(), uint(query.WarehouseID))
if err != nil { if err != nil {
return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate warehouse") return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate warehouse")
} }
@@ -303,8 +280,7 @@ func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Qu
return nil, 0, fiber.NewError(fiber.StatusNotFound, "Warehouse not found") 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 { if err != nil {
s.Log.Errorf("Failed to check product existence: %+v", err) s.Log.Errorf("Failed to check product existence: %+v", err)
return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product") return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product")
@@ -313,58 +289,28 @@ func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Qu
return nil, 0, fiber.NewError(fiber.StatusNotFound, "Product not found") return nil, 0, fiber.NewError(fiber.StatusNotFound, "Product not found")
} }
var adjustmentStocks []entity.AdjustmentStock stockLogs, total, err := s.StockLogsRepository.GetAll(c.Context(), offset, query.Limit, func(db *gorm.DB) *gorm.DB {
var total int64
q := s.AdjustmentStockRepository.DB().WithContext(c.Context()).Model(&entity.AdjustmentStock{}). db = s.withRelations(db)
Preload("ProductWarehouse").
Preload("ProductWarehouse.Product").
Preload("ProductWarehouse.Warehouse").
Preload("StockLog.CreatedUser")
scope, scopeErr := m.ResolveLocationScope(c, s.AdjustmentStockRepository.DB()) db = db.Where("loggable_type = ?", string(utils.StockLogTypeAdjustment))
if scopeErr != nil {
return nil, 0, scopeErr if query.TransactionType != "" {
} db = db.Where("transaction_type = ?", strings.ToUpper(query.TransactionType))
if scope.Restrict {
if len(scope.IDs) == 0 {
return []*entity.AdjustmentStock{}, 0, nil
} }
q = q.Joins("JOIN product_warehouses pw_scope ON pw_scope.id = adjustment_stocks.product_warehouse_id"). db = s.StockLogsRepository.ApplyProductWarehouseFilters(db, uint(query.ProductID), uint(query.WarehouseID))
Joins("JOIN warehouses w_scope ON w_scope.id = pw_scope.warehouse_id")
q = m.ApplyScopeFilter(q, scope, "w_scope.location_id")
}
if query.ProductID > 0 { return db.Order("created_at DESC")
q = q.Joins("JOIN product_warehouses ON product_warehouses.id = adjustment_stocks.product_warehouse_id"). })
Where("product_warehouses.product_id = ?", query.ProductID)
}
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 != "" {
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
if err != nil { if err != nil {
s.Log.Errorf("Failed to get adjustments: %+v", err) s.Log.Errorf("Failed to get adjustments: %+v", err)
return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to get adjustment history") return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to get adjustment history")
} }
result := make([]*entity.AdjustmentStock, len(adjustmentStocks)) result := make([]*entity.StockLog, len(stockLogs))
for i := range adjustmentStocks { for i, v := range stockLogs {
result[i] = &adjustmentStocks[i] result[i] = &v
} }
return result, total, nil return result, total, nil
@@ -62,7 +62,6 @@ type StockLogDetailDTO struct {
Id uint `json:"id"` Id uint `json:"id"`
Increase float64 `json:"increase"` Increase float64 `json:"increase"`
Decrease float64 `json:"decrease"` Decrease float64 `json:"decrease"`
Stock float64 `json:"stock"`
LoggableType string `json:"loggable_type"` LoggableType string `json:"loggable_type"`
LoggableId uint `json:"loggable_id"` LoggableId uint `json:"loggable_id"`
Notes *string `json:"notes"` Notes *string `json:"notes"`
@@ -196,7 +195,6 @@ func mapStockLogs(src []entity.StockLog) []StockLogDetailDTO {
Id: log.Id, Id: log.Id,
Increase: log.Increase, Increase: log.Increase,
Decrease: log.Decrease, Decrease: log.Decrease,
Stock: log.Stock,
LoggableType: log.LoggableType, LoggableType: log.LoggableType,
LoggableId: log.LoggableId, LoggableId: log.LoggableId,
Notes: notes, Notes: notes,
@@ -4,7 +4,6 @@ import (
"errors" "errors"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-stocks/validations" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-stocks/validations"
productRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories" productRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils"
@@ -37,49 +36,19 @@ func NewProductStockService(
} }
} }
func (s productStockService) withRelations(db *gorm.DB, locationScope, areaScope m.ScopeFilter) *gorm.DB { func (s productStockService) withRelations(db *gorm.DB) *gorm.DB {
warehouseScope := func(db *gorm.DB) *gorm.DB {
if locationScope.Restrict {
db = db.Where("warehouses.location_id IN ?", locationScope.IDs)
}
if areaScope.Restrict {
db = db.Where("warehouses.area_id IN ?", areaScope.IDs)
}
return db
}
productWarehouseScope := func(db *gorm.DB) *gorm.DB {
db = db.Joins("JOIN warehouses w ON w.id = product_warehouses.warehouse_id")
if locationScope.Restrict {
db = db.Where("w.location_id IN ?", locationScope.IDs)
}
if areaScope.Restrict {
db = db.Where("w.area_id IN ?", areaScope.IDs)
}
return db
}
stockLogScope := func(db *gorm.DB) *gorm.DB {
db = db.
Joins("JOIN product_warehouses pw ON pw.id = stock_logs.product_warehouse_id").
Joins("JOIN warehouses w ON w.id = pw.warehouse_id")
if locationScope.Restrict {
db = db.Where("w.location_id IN ?", locationScope.IDs)
}
if areaScope.Restrict {
db = db.Where("w.area_id IN ?", areaScope.IDs)
}
return db.Order("stock_logs.created_at ASC")
}
return db. return db.
Preload("CreatedUser"). Preload("CreatedUser").
Preload("Uom"). Preload("Uom").
Preload("ProductCategory"). Preload("ProductCategory").
Preload("Flags"). Preload("Flags").
Preload("ProductWarehouses", productWarehouseScope). Preload("ProductWarehouses").
Preload("ProductWarehouses.Warehouse", warehouseScope). Preload("ProductWarehouses.Warehouse").
Preload("ProductWarehouses.Warehouse.Location"). Preload("ProductWarehouses.Warehouse.Location").
Preload("ProductWarehouses.Warehouse.Location.Area"). Preload("ProductWarehouses.Warehouse.Location.Area").
Preload("ProductWarehouses.StockLogs", stockLogScope). Preload("ProductWarehouses.StockLogs", func(db *gorm.DB) *gorm.DB {
return db.Order("created_at ASC")
}).
Preload("ProductWarehouses.StockLogs.CreatedUser"). Preload("ProductWarehouses.StockLogs.CreatedUser").
Preload("ProductSuppliers"). Preload("ProductSuppliers").
Preload("ProductSuppliers.Supplier", func(db *gorm.DB) *gorm.DB { Preload("ProductSuppliers.Supplier", func(db *gorm.DB) *gorm.DB {
@@ -92,40 +61,17 @@ func (s productStockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]e
return nil, 0, err return nil, 0, err
} }
locationScope, areaScope, err := m.ResolveLocationAreaScopes(c, s.ProductRepository.DB())
if err != nil {
return nil, 0, err
}
offset := (params.Page - 1) * params.Limit offset := (params.Page - 1) * params.Limit
productStocks, total, err := s.ProductRepository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { productStocks, total, err := s.ProductRepository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
if locationScope.Restrict || areaScope.Restrict { db = db.Where(`EXISTS (
if (locationScope.Restrict && len(locationScope.IDs) == 0) || (areaScope.Restrict && len(areaScope.IDs) == 0) { SELECT 1
return db.Where("1 = 0") FROM product_warehouses pw
} WHERE pw.product_id = products.id
db = db.Where(`EXISTS ( AND pw.qty > 0
SELECT 1 )`)
FROM product_warehouses pw
JOIN warehouses w ON w.id = pw.warehouse_id
WHERE pw.product_id = products.id
AND pw.qty > 0
AND (? OR w.location_id IN ?)
AND (? OR w.area_id IN ?)
)`,
!locationScope.Restrict, locationScope.IDs,
!areaScope.Restrict, areaScope.IDs,
)
} else {
db = db.Where(`EXISTS (
SELECT 1
FROM product_warehouses pw
WHERE pw.product_id = products.id
AND pw.qty > 0
)`)
}
db = s.withRelations(db, locationScope, areaScope) db = s.withRelations(db)
if params.Search != "" { if params.Search != "" {
db = db.Where("products.name ILIKE ?", "%"+params.Search+"%") db = db.Where("products.name ILIKE ?", "%"+params.Search+"%")
} }
@@ -140,34 +86,7 @@ func (s productStockService) GetAll(c *fiber.Ctx, params *validation.Query) ([]e
} }
func (s productStockService) GetOne(c *fiber.Ctx, id uint) (*entity.Product, error) { func (s productStockService) GetOne(c *fiber.Ctx, id uint) (*entity.Product, error) {
locationScope, areaScope, err := m.ResolveLocationAreaScopes(c, s.ProductRepository.DB()) product, err := s.ProductRepository.GetByID(c.Context(), id, s.withRelations)
if err != nil {
return nil, err
}
if locationScope.Restrict || areaScope.Restrict {
if (locationScope.Restrict && len(locationScope.IDs) == 0) || (areaScope.Restrict && len(areaScope.IDs) == 0) {
return nil, fiber.NewError(fiber.StatusNotFound, "Product not found")
}
var count int64
if err := s.ProductRepository.DB().WithContext(c.Context()).
Table("product_warehouses pw").
Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
Where("pw.product_id = ?", id).
Where("pw.qty > 0").
Where("(? OR w.location_id IN ?)", !locationScope.Restrict, locationScope.IDs).
Where("(? OR w.area_id IN ?)", !areaScope.Restrict, areaScope.IDs).
Count(&count).Error; err != nil {
return nil, err
}
if count == 0 {
return nil, fiber.NewError(fiber.StatusNotFound, "Product not found")
}
}
product, err := s.ProductRepository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
return s.withRelations(db, locationScope, areaScope)
})
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Product not found") return nil, fiber.NewError(fiber.StatusNotFound, "Product not found")
} }
@@ -175,47 +94,5 @@ func (s productStockService) GetOne(c *fiber.Ctx, id uint) (*entity.Product, err
s.Log.Errorf("Failed get product by id: %+v", err) s.Log.Errorf("Failed get product by id: %+v", err)
return nil, err return nil, err
} }
if len(product.ProductWarehouses) > 0 {
ids := make([]uint, 0, len(product.ProductWarehouses))
for _, pw := range product.ProductWarehouses {
if pw.Id != 0 {
ids = append(ids, pw.Id)
}
}
if len(ids) > 0 {
type pendingUsageRow struct {
ProductWarehouseId uint
PendingQty float64
}
var rows []pendingUsageRow
if err := s.ProductRepository.DB().WithContext(c.Context()).
Table("recording_stocks").
Select("product_warehouse_id, COALESCE(SUM(pending_qty), 0) AS pending_qty").
Where("pending_qty > 0").
Where("product_warehouse_id IN ?", ids).
Group("product_warehouse_id").
Scan(&rows).Error; err != nil {
s.Log.Errorf("Failed to load pending usage for product warehouses: %+v", err)
return nil, err
}
if len(rows) > 0 {
pendingMap := make(map[uint]float64, len(rows))
for _, row := range rows {
pendingMap[row.ProductWarehouseId] = row.PendingQty
}
for i := range product.ProductWarehouses {
pw := &product.ProductWarehouses[i]
if pending, ok := pendingMap[pw.Id]; ok && pending != 0 {
pw.Quantity -= pending
}
}
}
}
}
return product, nil return product, nil
} }
@@ -8,7 +8,6 @@ import (
service "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/services" service "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/services"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/validations" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/validations"
"gitlab.com/mbugroup/lti-api.git/internal/response" "gitlab.com/mbugroup/lti-api.git/internal/response"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
) )
@@ -25,14 +24,12 @@ func NewProductWarehouseController(productWarehouseService service.ProductWareho
func (u *ProductWarehouseController) GetAll(c *fiber.Ctx) error { func (u *ProductWarehouseController) GetAll(c *fiber.Ctx) error {
query := &validation.Query{ query := &validation.Query{
Page: c.QueryInt("page", 1), Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10), Limit: c.QueryInt("limit", 10),
ProductId: uint(c.QueryInt("product_id", 0)), ProductId: uint(c.QueryInt("product_id", 0)),
WarehouseId: uint(c.QueryInt("warehouse_id", 0)), WarehouseId: uint(c.QueryInt("warehouse_id", 0)),
Flags: c.Query("flags", ""), Flags: c.Query("flags", ""),
KandangId: uint(c.QueryInt("kandang_id", 0)), KandangId: uint(c.QueryInt("kandang_id", 0)),
TransferContext: c.Query(utils.TransferContextKey, ""),
Type: c.Query("type", ""),
} }
if query.Page < 1 || query.Limit < 1 { if query.Page < 1 || query.Limit < 1 {
@@ -5,8 +5,6 @@ import (
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
productDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/dto" productDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/dto"
warehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/dto"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
) )
// === DTO Structs === // === DTO Structs ===
@@ -18,30 +16,60 @@ type ProductWarehouseRelationDTO struct {
Quantity float64 `json:"quantity"` 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 { type ProductWarehouseListDTO struct {
ProductWarehouseRelationDTO ProductWarehouseRelationDTO
Product *productDTO.ProductRelationDTO `json:"product,omitempty"` Product *productDTO.ProductRelationDTO `json:"product,omitempty"`
Warehouse *warehouseDTO.WarehouseRelationDTO `json:"warehouse,omitempty"` Warehouse *WarehouseRelationDTO `json:"warehouse,omitempty"`
ProjectFlockKandang *ProjectFlockKandangRelationDTO `json:"project_flock_kandang,omitempty"` ProjectFlockKandang *ProjectFlockKandangRelationDTO `json:"project_flock_kandang,omitempty"`
Week int `json:"week"` CreatedUser *UserRelationDTO `json:"created_user,omitempty"`
CreatedUser *UserRelationDTO `json:"created_user,omitempty"` CreatedAt time.Time `json:"created_at"`
CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"`
UpdatedAt time.Time `json:"updated_at"` }
type UserRelationDTO struct {
Id uint `json:"id"`
Username string `json:"username"`
} }
type ProductWarehouseDetailDTO struct { type ProductWarehouseDetailDTO struct {
ProductWarehouseListDTO ProductWarehouseListDTO
} }
type ProductWarehousNestedDTO struct { // Nested DTOs for relations
Id uint `json:"id"` type ProductRelationDTO struct {
Product *productDTO.ProductRelationDTO `json:"product,omitempty"` Id uint `json:"id"`
Warehouse *warehouseDTO.WarehouseRelationDTO `json:"warehouse,omitempty"` Name string `json:"name"`
Sku string `json:"sku"`
Flags []string `json:"flags"`
} }
type UserRelationDTO struct { type WarehouseRelationDTO struct {
Id uint `json:"id"` Id uint `json:"id"`
Username string `json:"username"` 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 { type ProjectFlockKandangRelationDTO struct {
@@ -68,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 { func ToProductWarehouseListDTO(e entity.ProductWarehouse) ProductWarehouseListDTO {
dto := ProductWarehouseListDTO{ dto := ProductWarehouseListDTO{
ProductWarehouseRelationDTO: ToProductWarehouseRelationDTO(e), ProductWarehouseRelationDTO: ToProductWarehouseRelationDTO(e),
// CreatedAt: e.CreatedAt,
// UpdatedAt: e.UpdatedAt,
} }
// Map Product relation jika ada // Map Product relation jika ada
if e.Product.Id != 0 { if e.Product.Id != 0 {
product := productDTO.ToProductRelationDTO(e.Product) 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 { if e.ProjectFlockKandang != nil && e.ProjectFlockKandang.ProjectFlock.Id != 0 {
productCopy := product product.Name = product.Name + " (" + e.ProjectFlockKandang.ProjectFlock.FlockName + ")"
productCopy.Name = product.Name + " (" + e.ProjectFlockKandang.ProjectFlock.FlockName + ")"
dto.Product = &productCopy
} else {
dto.Product = &product
} }
dto.Product = &product
} }
// Map Warehouse relation jika ada // Map Warehouse relation jika ada
if e.Warehouse.Id != 0 { 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 dto.Warehouse = &warehouse
} }
@@ -103,6 +168,7 @@ func ToProductWarehouseListDTO(e entity.ProductWarehouse) ProductWarehouseListDT
Period: e.ProjectFlockKandang.Period, Period: e.ProjectFlockKandang.Period,
} }
// Map ProjectFlock jika ada
if e.ProjectFlockKandang.ProjectFlock.Id != 0 { if e.ProjectFlockKandang.ProjectFlock.Id != 0 {
pfkDTO.ProjectFlock = &ProjectFlockRelationDTO{ pfkDTO.ProjectFlock = &ProjectFlockRelationDTO{
Id: e.ProjectFlockKandang.ProjectFlock.Id, Id: e.ProjectFlockKandang.ProjectFlock.Id,
@@ -111,24 +177,17 @@ func ToProductWarehouseListDTO(e entity.ProductWarehouse) ProductWarehouseListDT
} }
dto.ProjectFlockKandang = pfkDTO dto.ProjectFlockKandang = pfkDTO
// Calculate week for AYAM_PULLET/AYAM products
productFlags := make([]string, len(e.Product.Flags))
for i, f := range e.Product.Flags {
productFlags[i] = f.Name
}
var category string
if e.ProjectFlockKandang.ProjectFlock.Id != 0 {
category = e.ProjectFlockKandang.ProjectFlock.Category
}
now := time.Now()
_, ageInWeeks := calculateAgeFromChickin(e.ProjectFlockKandang, &now, productFlags, category)
dto.Week = ageInWeeks
} }
// 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 return dto
} }
@@ -146,68 +205,23 @@ func ToProductWarehouseDetailDTO(e entity.ProductWarehouse) ProductWarehouseDeta
} }
} }
func ToProductWarehouseNestedDTO(e entity.ProductWarehouse) ProductWarehousNestedDTO { func ToKandangRelationDTO(e entity.Kandang) KandangRelationDTO {
product := productDTO.ToProductRelationDTO(e.Product) return KandangRelationDTO{
warehouse := warehouseDTO.ToWarehouseRelationDTO(e.Warehouse) Id: e.Id,
Name: e.Name,
return ProductWarehousNestedDTO{
Id: e.Id,
Product: &product,
Warehouse: &warehouse,
} }
} }
// Helper function to calculate age from chickin (same logic as closingMarketing.dto.go) func ToLocationRelationDTO(e entity.Location) LocationRelationDTO {
func calculateAgeFromChickin(projectFlockKandang *entity.ProjectFlockKandang, currentDate *time.Time, productFlags []string, category string) (int, int) { return LocationRelationDTO{
if projectFlockKandang == nil || currentDate == nil || len(projectFlockKandang.Chickins) == 0 { Id: e.Id,
return 0, 0 Name: e.Name,
}
}
func ToAreaRelationDTO(e entity.Area) AreaRelationDTO {
return AreaRelationDTO{
Id: e.Id,
Name: e.Name,
} }
// Return 0 for TRADING, TELUR, and AYAM flags (only AYAM_PULLET should have week)
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) ||
flag == string(utils.FlagAyamAfkir) ||
flag == string(utils.FlagAyamCulling) ||
flag == string(utils.FlagAyamMati) {
return 0, 0
}
}
// Find earliest chickin date
earliestChickinDate := projectFlockKandang.Chickins[0].ChickInDate
for _, chickin := range projectFlockKandang.Chickins {
if chickin.ChickInDate.Before(earliestChickinDate) {
earliestChickinDate = chickin.ChickInDate
}
}
diff := currentDate.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
} }
@@ -168,10 +168,9 @@ func (r *ProductWarehouseRepositoryImpl) ApplyFlagsFilter(db *gorm.DB, flags []s
} }
return db. return db.
Joins("JOIN products p_flag ON p_flag.id = product_warehouses.product_id"). Joins("JOIN products ON products.id = product_warehouses.product_id").
Joins("JOIN flags f_flag ON f_flag.flagable_id = p_flag.id AND f_flag.flagable_type = ?", "products"). Joins("JOIN flags ON flags.flagable_id = products.id AND flags.flagable_type = ?", "products").
Where("f_flag.name IN ?", flags). Where("flags.name IN ?", flags)
Distinct()
} }
func (r *ProductWarehouseRepositoryImpl) AdjustQuantities(ctx context.Context, deltas map[uint]float64, modifier func(*gorm.DB) *gorm.DB) error { func (r *ProductWarehouseRepositoryImpl) AdjustQuantities(ctx context.Context, deltas map[uint]float64, modifier func(*gorm.DB) *gorm.DB) error {
@@ -3,15 +3,15 @@ package service
import ( import (
"errors" "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" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories" 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" 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" kandangrepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"github.com/sirupsen/logrus"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -40,14 +40,12 @@ func (s productWarehouseService) withRelations(db *gorm.DB) *gorm.DB {
return db. return db.
Preload("Product.Flags"). Preload("Product.Flags").
Preload("Product"). Preload("Product").
Preload("Product.Uom").
Preload("Warehouse"). Preload("Warehouse").
Preload("Warehouse.Location"). Preload("Warehouse.Location").
Preload("Warehouse.Area"). Preload("Warehouse.Area").
Preload("Warehouse.Kandang"). Preload("Warehouse.Kandang").
Preload("ProjectFlockKandang"). Preload("ProjectFlockKandang").
Preload("ProjectFlockKandang.ProjectFlock"). Preload("ProjectFlockKandang.ProjectFlock")
Preload("ProjectFlockKandang.Chickins")
} }
func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProductWarehouse, int64, error) { func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.ProductWarehouse, int64, error) {
@@ -55,19 +53,6 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query)
return nil, 0, err return nil, 0, err
} }
applyScope := true
if params.TransferContext == utils.TransferContextInventoryTransfer {
applyScope = !m.HasPermission(c, m.P_TransferCreateOne)
}
var scope m.ScopeFilter
var err error
if applyScope {
scope, err = m.ResolveLocationScope(c, s.Repository.DB())
if err != nil {
return nil, 0, err
}
}
if params.ProductId > 0 { if params.ProductId > 0 {
isProductExist, err := s.Repository.IsProductExist(c.Context(), params.ProductId) isProductExist, err := s.Repository.IsProductExist(c.Context(), params.ProductId)
if err != nil { if err != nil {
@@ -100,32 +85,11 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query)
offset := (params.Page - 1) * params.Limit offset := (params.Page - 1) * params.Limit
var marketingTypes []string
if params.Type != "" {
marketingTypes = utils.ParseQueryArray(params.Type)
for _, t := range marketingTypes {
if !utils.IsValidMarketingType(t) {
return nil, 0, fiber.NewError(fiber.StatusBadRequest, "Invalid marketing type")
}
}
}
cleanFlags := utils.ParseFlags(params.Flags) cleanFlags := utils.ParseFlags(params.Flags)
productWarehouses, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { productWarehouses, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db) db = s.withRelations(db)
db = db.Joins("JOIN warehouses w_scope ON product_warehouses.warehouse_id = w_scope.id").
Where("w_scope.deleted_at IS NULL")
if applyScope {
if scope.Restrict {
if len(scope.IDs) == 0 {
return db.Where("1 = 0")
}
db = db.Where("w_scope.location_id IN ?", scope.IDs)
}
}
if params.ProductId != 0 { if params.ProductId != 0 {
db = db.Where("product_id = ?", params.ProductId) db = db.Where("product_id = ?", params.ProductId)
} }
@@ -139,48 +103,7 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query)
db = db.Where("warehouse_id = ?", params.WarehouseId) db = db.Where("warehouse_id = ?", params.WarehouseId)
} }
if len(marketingTypes) > 0 { db = s.Repository.ApplyFlagsFilter(db, cleanFlags)
flagSet := make(map[string]struct{})
for _, t := range marketingTypes {
switch t {
case string(utils.MarketingTypeAyamPullet):
flagSet[string(utils.FlagDOC)] = struct{}{}
flagSet[string(utils.FlagPullet)] = struct{}{}
flagSet[string(utils.FlagLayer)] = struct{}{}
case string(utils.MarketingTypeAyam):
flagSet[string(utils.FlagAyamAfkir)] = struct{}{}
flagSet[string(utils.FlagAyamCulling)] = struct{}{}
flagSet[string(utils.FlagAyamMati)] = struct{}{}
case string(utils.MarketingTypeTelur):
flagSet[string(utils.FlagTelur)] = struct{}{}
flagSet[string(utils.FlagTelurUtuh)] = struct{}{}
flagSet[string(utils.FlagTelurPecah)] = struct{}{}
flagSet[string(utils.FlagTelurPutih)] = struct{}{}
flagSet[string(utils.FlagTelurRetak)] = struct{}{}
case string(utils.MarketingTypeTrading):
flagSet[string(utils.FlagPakan)] = struct{}{}
flagSet[string(utils.FlagPreStarter)] = struct{}{}
flagSet[string(utils.FlagStarter)] = struct{}{}
flagSet[string(utils.FlagFinisher)] = struct{}{}
flagSet[string(utils.FlagOVK)] = struct{}{}
flagSet[string(utils.FlagObat)] = struct{}{}
flagSet[string(utils.FlagVitamin)] = struct{}{}
flagSet[string(utils.FlagKimia)] = struct{}{}
flagSet[string(utils.FlagEkspedisi)] = struct{}{}
}
}
if len(flagSet) > 0 {
flags := make([]string, 0, len(flagSet))
for f := range flagSet {
flags = append(flags, f)
}
db = s.Repository.ApplyFlagsFilter(db, flags)
}
}
if len(cleanFlags) > 0 {
db = s.Repository.ApplyFlagsFilter(db, cleanFlags)
}
return db.Order("product_warehouses.id DESC") return db.Order("product_warehouses.id DESC")
}) })
@@ -193,33 +116,7 @@ func (s productWarehouseService) GetAll(c *fiber.Ctx, params *validation.Query)
} }
func (s productWarehouseService) GetOne(c *fiber.Ctx, id uint) (*entity.ProductWarehouse, error) { func (s productWarehouseService) GetOne(c *fiber.Ctx, id uint) (*entity.ProductWarehouse, error) {
applyScope := true productWarehouse, err := s.Repository.GetByID(c.Context(), id, s.withRelations)
if c.Query(utils.TransferContextKey, "") == utils.TransferContextInventoryTransfer {
applyScope = !m.HasPermission(c, m.P_TransferCreateOne)
}
var scope m.ScopeFilter
var err error
if applyScope {
scope, err = m.ResolveLocationScope(c, s.Repository.DB())
if err != nil {
return nil, err
}
}
productWarehouse, err := s.Repository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db)
db = db.Joins("JOIN warehouses w_scope ON product_warehouses.warehouse_id = w_scope.id").
Where("w_scope.deleted_at IS NULL")
if applyScope {
if scope.Restrict {
if len(scope.IDs) == 0 {
return db.Where("1 = 0")
}
db = db.Where("w_scope.location_id IN ?", scope.IDs)
}
}
return db
})
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "ProductWarehouse not found") return nil, fiber.NewError(fiber.StatusNotFound, "ProductWarehouse not found")
} }
@@ -13,12 +13,10 @@ type Update struct {
} }
type Query struct { type Query struct {
Page int `query:"page" validate:"omitempty,number,min=1"` Page int `query:"page" validate:"omitempty,number,min=1"`
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"` Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"`
ProductId uint `query:"product_id" validate:"omitempty,number,min=1"` ProductId uint `query:"product_id" validate:"omitempty,number,min=1"`
WarehouseId uint `query:"warehouse_id" validate:"omitempty,number,min=1"` WarehouseId uint `query:"warehouse_id" validate:"omitempty,number,min=1"`
Flags string `query:"flags" validate:"omitempty"` Flags string `query:"flags" validate:"omitempty"`
KandangId uint `query:"kandang_id" validate:"omitempty,number,min=1"` KandangId uint `query:"kandang_id" validate:"omitempty,number,min=1"`
TransferContext string `query:"transfer_context" validate:"omitempty,oneof=inventory_transfer"`
Type string `query:"type" validate:"omitempty"`
} }
@@ -9,12 +9,12 @@ import (
) )
type TransferRelationDTO struct { type TransferRelationDTO struct {
Id uint64 `json:"id"` Id uint64 `json:"id"`
MovementNumber string `json:"movement_number"` MovementNumber string `json:"movement_number"`
TransferReason string `json:"transfer_reason"` TransferReason string `json:"transfer_reason"`
TransferDate string `json:"transfer_date"` TransferDate string `json:"transfer_date"`
SourceWarehouse *warehouseDTO.WarehouseRelationDTO `json:"source_warehouse,omitempty"` SourceWarehouse *warehouseDTO.WarehouseRelationDTO `json:"source_warehouse,omitempty"`
DestinationWarehouse *warehouseDTO.WarehouseRelationDTO `json:"destination_warehouse,omitempty"` DestinationWarehouse *warehouseDTO.WarehouseRelationDTO `json:"destination_warehouse,omitempty"`
} }
type ProductSimpleDTO struct { type ProductSimpleDTO struct {
@@ -51,16 +51,16 @@ type TransferDetailDTO struct {
} }
type TransferDetailItemDTO struct { type TransferDetailItemDTO struct {
Id uint64 `json:"id"` Id uint64 `json:"id"`
Product ProductSimpleDTO `json:"product"` Product ProductSimpleDTO `json:"product"`
Quantity float64 `json:"quantity"` Quantity float64 `json:"quantity"`
TransportPerItem *float64 `json:"transport_per_item,omitempty"` // Biaya ekspedisi per item TransportPerItem *float64 `json:"transport_per_item,omitempty"` // Biaya ekspedisi per item
ExpeditionVendor *SupplierSimpleDTO `json:"expedition_vendor,omitempty"` // Vendor ekspedisi ExpeditionVendor *SupplierSimpleDTO `json:"expedition_vendor,omitempty"` // Vendor ekspedisi
} }
type TransferDeliveryDTO struct { type TransferDeliveryDTO struct {
Id uint64 `json:"id"` Id uint64 `json:"id"`
Supplier *SupplierSimpleDTO `json:"supplier,omitempty"` Supplier SupplierSimpleDTO `json:"supplier"`
VehiclePlate string `json:"vehicle_plate"` VehiclePlate string `json:"vehicle_plate"`
DriverName string `json:"driver_name"` DriverName string `json:"driver_name"`
DocumentNumber string `json:"document_number"` DocumentNumber string `json:"document_number"`
@@ -115,6 +115,7 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO {
Quantity: d.UsageQty + d.PendingQty, // Total actual quantity allocated Quantity: d.UsageQty + d.PendingQty, // Total actual quantity allocated
} }
if d.ExpenseNonstock != nil { if d.ExpenseNonstock != nil {
priceCopy := d.ExpenseNonstock.Price priceCopy := d.ExpenseNonstock.Price
detailDTO.TransportPerItem = &priceCopy detailDTO.TransportPerItem = &priceCopy
@@ -154,17 +155,12 @@ func ToTransferListDTO(e entity.StockTransfer) TransferListDTO {
} }
} }
var supplier *SupplierSimpleDTO deliveries = append(deliveries, TransferDeliveryDTO{
if del.Supplier != nil { Id: del.Id,
supplier = &SupplierSimpleDTO{ Supplier: SupplierSimpleDTO{
Id: del.Supplier.Id, Id: del.Supplier.Id,
Name: del.Supplier.Name, Name: del.Supplier.Name,
} },
}
deliveries = append(deliveries, TransferDeliveryDTO{
Id: del.Id,
Supplier: supplier,
VehiclePlate: del.VehiclePlate, VehiclePlate: del.VehiclePlate,
DriverName: del.DriverName, DriverName: del.DriverName,
DocumentNumber: del.DocumentNumber, DocumentNumber: del.DocumentNumber,
@@ -205,6 +201,7 @@ func ToTransferDetailDTO(e entity.StockTransfer) TransferDetailDTO {
Quantity: d.UsageQty + d.PendingQty, // Total actual quantity allocated Quantity: d.UsageQty + d.PendingQty, // Total actual quantity allocated
} }
if d.ExpenseNonstock != nil { if d.ExpenseNonstock != nil {
priceCopy := d.ExpenseNonstock.Price priceCopy := d.ExpenseNonstock.Price
detailDTO.TransportPerItem = &priceCopy detailDTO.TransportPerItem = &priceCopy
@@ -244,17 +241,12 @@ func ToTransferDetailDTO(e entity.StockTransfer) TransferDetailDTO {
} }
} }
var supplier *SupplierSimpleDTO deliveries = append(deliveries, TransferDeliveryDTO{
if del.Supplier != nil { Id: del.Id,
supplier = &SupplierSimpleDTO{ Supplier: SupplierSimpleDTO{
Id: del.Supplier.Id, Id: del.Supplier.Id,
Name: del.Supplier.Name, Name: del.Supplier.Name,
} },
}
deliveries = append(deliveries, TransferDeliveryDTO{
Id: del.Id,
Supplier: supplier,
VehiclePlate: del.VehiclePlate, VehiclePlate: del.VehiclePlate,
DriverName: del.DriverName, DriverName: del.DriverName,
DocumentNumber: del.DocumentNumber, DocumentNumber: del.DocumentNumber,
@@ -40,6 +40,6 @@ func (r *StockTransferRepositoryImpl) GenerateMovementNumber(ctx context.Context
if err != nil { if err != nil {
return "", err return "", err
} }
movementNumber := fmt.Sprintf("PND-LTI-%05d", seq) movementNumber := fmt.Sprintf("ST-%05d", seq)
return movementNumber, nil return movementNumber, nil
} }
@@ -94,24 +94,10 @@ func (s transferService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit
return nil, 0, err return nil, 0, err
} }
scope, err := m.ResolveLocationScope(c, s.StockTransferRepo.DB())
if err != nil {
return nil, 0, err
}
offset := (params.Page - 1) * params.Limit offset := (params.Page - 1) * params.Limit
transfers, total, err := s.StockTransferRepo.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { transfers, total, err := s.StockTransferRepo.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db) db = s.withRelations(db)
if scope.Restrict {
if len(scope.IDs) == 0 {
return db.Where("1 = 0")
}
db = db.
Joins("JOIN warehouses w_from ON w_from.id = stock_transfers.from_warehouse_id").
Joins("JOIN warehouses w_to ON w_to.id = stock_transfers.to_warehouse_id").
Where("w_from.location_id IN ? OR w_to.location_id IN ?", scope.IDs, scope.IDs)
}
if params.Search != "" { if params.Search != "" {
searchTerm := "%" + strings.TrimSpace(params.Search) + "%" searchTerm := "%" + strings.TrimSpace(params.Search) + "%"
db = db.Joins("LEFT JOIN warehouses AS from_warehouses ON from_warehouses.id = stock_transfers.from_warehouse_id"). db = db.Joins("LEFT JOIN warehouses AS from_warehouses ON from_warehouses.id = stock_transfers.from_warehouse_id").
@@ -130,28 +116,6 @@ func (s transferService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit
} }
func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, error) { func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, error) {
scope, err := m.ResolveLocationScope(c, s.StockTransferRepo.DB())
if err != nil {
return nil, err
}
if scope.Restrict {
if len(scope.IDs) == 0 {
return nil, fiber.NewError(fiber.StatusNotFound, "Transfer not found")
}
var count int64
if err := s.StockTransferRepo.DB().WithContext(c.Context()).
Table("stock_transfers").
Joins("JOIN warehouses w_from ON w_from.id = stock_transfers.from_warehouse_id").
Joins("JOIN warehouses w_to ON w_to.id = stock_transfers.to_warehouse_id").
Where("stock_transfers.id = ?", id).
Where("w_from.location_id IN ? OR w_to.location_id IN ?", scope.IDs, scope.IDs).
Count(&count).Error; err != nil {
return nil, err
}
if count == 0 {
return nil, fiber.NewError(fiber.StatusNotFound, "Transfer not found")
}
}
transferPtr, err := s.StockTransferRepo.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB { transferPtr, err := s.StockTransferRepo.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
return s.withRelations(db) return s.withRelations(db)
@@ -160,8 +124,7 @@ func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, e
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Transfer dengan ID %d tidak ditemukan", id)) 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, fmt.Sprintf("Gagal mengambil data transfer dengan ID %d", id))
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data transfer")
} }
return transferPtr, nil return transferPtr, nil
@@ -179,8 +142,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
if errors.Is(err, gorm.ErrRecordNotFound) { 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)) 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, fmt.Sprintf("Gagal mengecek stok produk %d di gudang asal", product.ProductID))
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengecek stok produk")
} }
if sourcePW.Quantity < product.ProductQty { 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)) 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))
@@ -201,15 +163,12 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
return nil, err return nil, err
} }
if destPfkID > 0 { projectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), destPfkID)
projectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), destPfkID) if err != nil {
if err != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data project flock untuk gudang tujuan")
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")))
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) actorID, err := m.ActorIDFromContext(c)
@@ -232,31 +191,12 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
} }
for _, delivery := range req.Deliveries { for _, delivery := range req.Deliveries {
if delivery.SupplierID == 0 {
continue
}
if delivery.VehiclePlate == "" {
return nil, fiber.NewError(fiber.StatusBadRequest, "Vehicle plate wajib diisi ketika supplier dipilih")
}
if delivery.DriverName == "" {
return nil, fiber.NewError(fiber.StatusBadRequest, "Driver name wajib diisi ketika supplier dipilih")
}
if delivery.DeliveryCost <= 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "Delivery cost harus lebih dari 0 ketika supplier dipilih")
}
if delivery.DeliveryCostPerItem <= 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "Delivery cost per item harus lebih dari 0 ketika supplier dipilih")
}
supplier, err := s.SupplierRepo.GetByID(c.Context(), uint(delivery.SupplierID), nil) supplier, err := s.SupplierRepo.GetByID(c.Context(), uint(delivery.SupplierID), nil)
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Supplier dengan ID %d tidak ditemukan", delivery.SupplierID)) 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, fmt.Sprintf("Gagal mengambil data supplier dengan ID %d", delivery.SupplierID))
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data supplier")
} }
if supplier.Category != string(utils.SupplierCategoryBOP) { 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)) 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))
@@ -265,8 +205,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
movementNumber, err := s.StockTransferRepo.GenerateMovementNumber(c.Context()) movementNumber, err := s.StockTransferRepo.GenerateMovementNumber(c.Context())
if err != nil { if err != nil {
s.Log.Errorf("Failed to generate movement number: %+v", err) return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat nomor movement transfer")
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat nomor transfer")
} }
transferDate, _ := utils.ParseDateString(req.TransferDate) transferDate, _ := utils.ParseDateString(req.TransferDate)
@@ -289,7 +228,6 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
stockTransferDeliveryRepoTX := s.StockTransferDeliveryRepo.WithTx(tx) stockTransferDeliveryRepoTX := s.StockTransferDeliveryRepo.WithTx(tx)
stockTransferDeliveryItemRepoTX := s.StockTransferDeliveryItemRepo.WithTx(tx) stockTransferDeliveryItemRepoTX := s.StockTransferDeliveryItemRepo.WithTx(tx)
productWarehouseRepoTX := rProductWarehouse.NewProductWarehouseRepository(tx) productWarehouseRepoTX := rProductWarehouse.NewProductWarehouseRepository(tx)
stocklogsRepoTx := s.StockLogsRepository.WithTx(tx)
if err := stockTransferRepoTX.CreateOne(c.Context(), entityTransfer, nil); err != nil { if err := stockTransferRepoTX.CreateOne(c.Context(), entityTransfer, nil); err != nil {
return err return err
@@ -307,16 +245,14 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
if errors.Is(err, gorm.ErrRecordNotFound) { 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)) 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, fmt.Sprintf("Gagal mengambil data product warehouse untuk produk %d di gudang asal", product.ProductID))
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data stok gudang asal")
} }
destPW, err := productWarehouseRepoTX.GetProductWarehouseByProductAndWarehouseID( destPW, err := productWarehouseRepoTX.GetProductWarehouseByProductAndWarehouseID(
c.Context(), uint(product.ProductID), uint(req.DestinationWarehouseID), c.Context(), uint(product.ProductID), uint(req.DestinationWarehouseID),
) )
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { 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, fmt.Sprintf("Gagal mengambil data product warehouse untuk produk %d di gudang tujuan", product.ProductID))
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data stok gudang tujuan")
} }
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
ctx := c.Context() ctx := c.Context()
@@ -325,6 +261,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
return err return err
} }
// Set ProjectFlockKandangId hanya jika ada kandang
var pfkID *uint var pfkID *uint
if projectFlockKandangID > 0 { if projectFlockKandangID > 0 {
pfkID = &projectFlockKandangID pfkID = &projectFlockKandangID
@@ -337,8 +274,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
ProjectFlockKandangId: pfkID, ProjectFlockKandangId: pfkID,
} }
if err := productWarehouseRepoTX.CreateOne(c.Context(), destPW, nil); err != nil { 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, fmt.Sprintf("Gagal membuat product warehouse untuk produk %d di gudang tujuan", product.ProductID))
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat data stok gudang tujuan")
} }
} }
@@ -364,16 +300,9 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
var deliveries []*entity.StockTransferDelivery var deliveries []*entity.StockTransferDelivery
for _, delivery := range req.Deliveries { for _, delivery := range req.Deliveries {
supplierId := func() *uint64 {
if delivery.SupplierID > 0 {
id := uint64(delivery.SupplierID)
return &id
}
return nil
}()
deliveries = append(deliveries, &entity.StockTransferDelivery{ deliveries = append(deliveries, &entity.StockTransferDelivery{
StockTransferId: entityTransfer.Id, StockTransferId: entityTransfer.Id,
SupplierId: supplierId, SupplierId: uint64(delivery.SupplierID),
VehiclePlate: delivery.VehiclePlate, VehiclePlate: delivery.VehiclePlate,
DriverName: delivery.DriverName, DriverName: delivery.DriverName,
ShippingCostItem: delivery.DeliveryCostPerItem, ShippingCostItem: delivery.DeliveryCostPerItem,
@@ -435,9 +364,9 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
Files: documentFiles, Files: documentFiles,
}) })
if err != nil { if err != nil {
s.Log.Errorf("Failed to upload document for delivery %d (delivery_id=%d, filename=%s): %+v", s.Log.WithError(err).Errorf("Failed to upload document for delivery %d (delivery_id: %d, filename: %s)",
deliveryIdx+1, delivery.Id, file.Filename, err) deliveryIdx+1, delivery.Id, file.Filename)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengunggah dokumen") return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to upload document for delivery %d: %v", deliveryIdx+1, err))
} }
} }
} }
@@ -463,33 +392,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
"usage_qty": consumeResult.UsageQuantity, "usage_qty": consumeResult.UsageQuantity,
"pending_qty": consumeResult.PendingQuantity, "pending_qty": consumeResult.PendingQuantity,
}).Error; err != nil { }).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, fmt.Sprintf("Gagal mengupdate tracking usage untuk produk %d", product.ProductID))
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: "",
}
stockLogs, err := s.StockLogsRepository.GetByProductWarehouse(c.Context(), uint(*detail.SourceProductWarehouseID), 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]
stockLogDecrease.Stock = latestStockLog.Stock - stockLogDecrease.Decrease
} else {
stockLogDecrease.Stock -= stockLogDecrease.Decrease
}
if err := stocklogsRepoTx.CreateOne(c.Context(), stockLogDecrease, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok keluar")
} }
note := fmt.Sprintf("Transfer #%s", entityTransfer.MovementNumber) note := fmt.Sprintf("Transfer #%s", entityTransfer.MovementNumber)
@@ -502,8 +405,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
Tx: tx, Tx: tx,
}) })
if err != nil { 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, fmt.Sprintf("Gagal menambah stok untuk produk %d di gudang tujuan. Error: %v", product.ProductID, err))
return fiber.NewError(fiber.StatusInternalServerError, "Gagal menambah stok gudang tujuan")
} }
if err := tx.Model(&entity.StockTransferDetail{}). if err := tx.Model(&entity.StockTransferDetail{}).
@@ -511,42 +413,12 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
Updates(map[string]interface{}{ Updates(map[string]interface{}{
"total_qty": replenishResult.AddedQuantity, "total_qty": replenishResult.AddedQuantity,
}).Error; err != nil { }).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, fmt.Sprintf("Gagal mengupdate tracking total untuk produk %d", product.ProductID))
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: "",
}
stockLogs, err = s.StockLogsRepository.GetByProductWarehouse(c.Context(), uint(*detail.DestProductWarehouseID), 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]
stockLogIncrease.Stock = latestStockLog.Stock + stockLogIncrease.Increase
} else {
stockLogIncrease.Stock += stockLogIncrease.Increase
}
if err := stocklogsRepoTx.CreateOne(c.Context(), stockLogIncrease, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok masuk")
} }
} }
if len(req.Deliveries) > 0 { if len(req.Deliveries) > 0 {
for _, delivery := range req.Deliveries { for _, delivery := range req.Deliveries {
// Skip adding to expensePayloads if SupplierID is 0 (optional)
if delivery.SupplierID == 0 {
continue
}
for _, prod := range delivery.Products { for _, prod := range delivery.Products {
detail := detailMap[uint64(prod.ProductID)] detail := detailMap[uint64(prod.ProductID)]
if detail == nil { if detail == nil {
@@ -575,10 +447,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
}) })
if err != nil { if err != nil {
if fiberErr, ok := err.(*fiber.Error); ok { return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal memproses transfer. Error: %v", err))
return nil, fiberErr
}
return nil, fiber.NewError(fiber.StatusInternalServerError, "Internal server error")
} }
result, err := s.GetOne(c, uint(entityTransfer.Id)) result, err := s.GetOne(c, uint(entityTransfer.Id))
@@ -588,8 +457,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
if len(expensePayloads) > 0 { if len(expensePayloads) > 0 {
if err := s.notifyExpenseItemsDelivered(c, entityTransfer.Id, expensePayloads); err != nil { 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, fmt.Sprintf("Gagal sinkronisasi data expense untuk transfer %s. Silakan cek manual di module expense", entityTransfer.MovementNumber))
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal sinkronisasi data expense. Silakan cek manual di module expense")
} }
} }
@@ -609,10 +477,10 @@ func (s *transferService) getActiveProjectFlockKandangID(ctx context.Context, wa
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Gudang dengan ID %d tidak ditemukan", warehouseID)) 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, fmt.Sprintf("Gagal mengambil data gudang dengan ID %d", warehouseID))
return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data gudang")
} }
// Jika warehouse tidak punya kandang_id, return 0 tanpa error
if warehouse.KandangId == nil || *warehouse.KandangId == 0 { if warehouse.KandangId == nil || *warehouse.KandangId == 0 {
return 0, nil return 0, nil
} }
@@ -622,8 +490,7 @@ func (s *transferService) getActiveProjectFlockKandangID(ctx context.Context, wa
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Tidak ada project flock aktif untuk kandang %d", *warehouse.KandangId)) 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 kandang yang aktif")
return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data project flock")
} }
return uint(projectFlockKandang.Id), nil return uint(projectFlockKandang.Id), nil
@@ -21,12 +21,12 @@ type TransferDeliveryProduct struct {
} }
type TransferDelivery struct { type TransferDelivery struct {
DeliveryCost float64 `json:"delivery_cost"` DeliveryCost float64 `json:"delivery_cost" validate:"required"`
DeliveryCostPerItem float64 `json:"delivery_cost_per_item"` DeliveryCostPerItem float64 `json:"delivery_cost_per_item" validate:"required"`
DocumentIndex int `json:"document_index" validate:"omitempty,min=-1" default:"-1"` DocumentIndex int `json:"document_index" validate:"omitempty,min=-1" default:"-1"`
DriverName string `json:"driver_name"` DriverName string `json:"driver_name" validate:"required"`
VehiclePlate string `json:"vehicle_plate"` VehiclePlate string `json:"vehicle_plate" validate:"required"`
SupplierID uint `json:"supplier_id" ` SupplierID uint `json:"supplier_id" validate:"required"`
Products []TransferDeliveryProduct `json:"products" validate:"required,dive"` Products []TransferDeliveryProduct `json:"products" validate:"required,dive"`
} }
@@ -3,7 +3,6 @@ package controller
import ( import (
"math" "math"
"strconv" "strconv"
"strings"
"gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/dto" "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/dto"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/services" service "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/services"
@@ -24,38 +23,9 @@ func NewDeliveryOrdersController(deliveryOrdersService service.DeliveryOrdersSer
} }
func (u *DeliveryOrdersController) GetAll(c *fiber.Ctx) error { func (u *DeliveryOrdersController) GetAll(c *fiber.Ctx) error {
parseUintListParam := func(param string) ([]uint, error) {
if param == "" {
return nil, nil
}
parts := strings.Split(param, ",")
ids := make([]uint, 0, len(parts))
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed == "" {
return nil, strconv.ErrSyntax
}
parsed, err := strconv.ParseUint(trimmed, 10, 64)
if err != nil {
return nil, err
}
ids = append(ids, uint(parsed))
}
return ids, nil
}
productIDs, err := parseUintListParam(c.Query("product_ids", ""))
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid product_ids")
}
query := &validation.DeliveryOrderQuery{ query := &validation.DeliveryOrderQuery{
Page: c.QueryInt("page", 1), Page: c.QueryInt("page", 1),
Limit: c.QueryInt("limit", 10), Limit: c.QueryInt("limit", 10),
Search: strings.TrimSpace(c.Query("search", "")),
ProductIDs: productIDs,
Status: strings.ReplaceAll(strings.TrimSpace(c.Query("status", "")), "_", " "),
CustomerId: uint(c.QueryInt("customer_id", 0)),
MarketingId: uint(c.QueryInt("marketing_id", 0)), MarketingId: uint(c.QueryInt("marketing_id", 0)),
} }
@@ -2,23 +2,19 @@ package dto
import ( import (
"fmt" "fmt"
"math"
"sort" "sort"
"strings"
"time" "time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto" approvalDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/approvals/dto"
productwarehouseDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/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" 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" userDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/users/dto"
) )
type MarketingRelationDTO struct { type MarketingRelationDTO struct {
Id uint `json:"id"` Id uint `json:"id"`
SoNumber string `json:"so_number"` SoNumber string `json:"so_number"`
DoNumber *string `json:"do_number"`
SoDate time.Time `json:"so_date"` SoDate time.Time `json:"so_date"`
Notes string `json:"notes,omitempty"` Notes string `json:"notes,omitempty"`
} }
@@ -72,76 +68,52 @@ type DeliveryItemDTO struct {
} }
type DeliveryGroupDTO struct { type DeliveryGroupDTO struct {
DoNumber string `json:"do_number"` DoNumber string `json:"do_number"`
DeliveryDate *time.Time `json:"delivery_date"` DeliveryDate *time.Time `json:"delivery_date"`
Warehouse *warehouseDTO.WarehouseRelationDTO `json:"warehouse,omitempty"` Warehouse *productwarehouseDTO.WarehouseRelationDTO `json:"warehouse,omitempty"`
Deliveries []DeliveryItemDTO `json:"deliveries"` Deliveries []DeliveryItemDTO `json:"deliveries"`
} }
type DeliveryMarketingProductDTO struct { type DeliveryMarketingProductDTO struct {
Id uint `json:"id"` Id uint `json:"id"`
MarketingId uint `json:"marketing_id"` MarketingId uint `json:"marketing_id"`
ProductWarehouseId uint `json:"product_warehouse_id"` ProductWarehouseId uint `json:"product_warehouse_id"`
MarketingType string `json:"marketing_type"` Qty float64 `json:"qty"`
Qty float64 `json:"qty"` UnitPrice float64 `json:"unit_price"`
UnitPrice float64 `json:"unit_price"` AvgWeight float64 `json:"avg_weight"`
AvgWeight float64 `json:"avg_weight"` TotalWeight float64 `json:"total_weight"`
TotalWeight float64 `json:"total_weight"` TotalPrice float64 `json:"total_price"`
TotalPrice float64 `json:"total_price"` ProductWarehouse *productwarehouseDTO.ProductWarehousNestedDTO `json:"product_warehouse,omitempty"`
ConvertionUnit *string `json:"convertion_unit,omitempty"` VehicleNumber string `json:"vehicle_number,omitempty"`
WeightPerConvertion *float64 `json:"weight_per_convertion,omitempty"`
TotalPeti *float64 `json:"total_peti,omitempty"`
Week *int `json:"week,omitempty"`
ProductWarehouse *productwarehouseDTO.ProductWarehousNestedDTO `json:"product_warehouse,omitempty"`
VehicleNumber string `json:"vehicle_number,omitempty"`
} }
func ToMarketingRelationDTO(marketing *entity.Marketing) MarketingRelationDTO { func ToMarketingRelationDTO(marketing *entity.Marketing) MarketingRelationDTO {
var doNumber *string
if doNumbers := collectDoNumbers(marketing); len(doNumbers) > 0 {
value := doNumbers[0]
doNumber = &value
}
return MarketingRelationDTO{ return MarketingRelationDTO{
Id: marketing.Id, Id: marketing.Id,
SoNumber: marketing.SoNumber, SoNumber: marketing.SoNumber,
DoNumber: doNumber,
SoDate: marketing.SoDate, SoDate: marketing.SoDate,
Notes: marketing.Notes, Notes: marketing.Notes,
} }
} }
func ToDeliveryMarketingProductDTO(e entity.MarketingProduct, marketingType string) DeliveryMarketingProductDTO { func ToDeliveryMarketingProductDTO(e entity.MarketingProduct) DeliveryMarketingProductDTO {
var productWarehouse *productwarehouseDTO.ProductWarehousNestedDTO var productWarehouse *productwarehouseDTO.ProductWarehousNestedDTO
if e.ProductWarehouse.Id != 0 { if e.ProductWarehouse.Id != 0 {
mapped := productwarehouseDTO.ToProductWarehouseNestedDTO(e.ProductWarehouse) mapped := productwarehouseDTO.ToProductWarehouseNestedDTO(e.ProductWarehouse)
productWarehouse = &mapped productWarehouse = &mapped
} }
// Calculate total_peti only for TELUR marketing type
var totalPeti *float64
if marketingType == "TELUR" && e.ConvertionUnit != nil && *e.ConvertionUnit == "PETI" && e.WeightPerConvertion != nil && *e.WeightPerConvertion > 0 {
calculated := math.Floor(e.TotalWeight / *e.WeightPerConvertion)
totalPeti = &calculated
}
return DeliveryMarketingProductDTO{ return DeliveryMarketingProductDTO{
Id: e.Id, Id: e.Id,
MarketingId: e.MarketingId, MarketingId: e.MarketingId,
ProductWarehouseId: e.ProductWarehouseId, ProductWarehouseId: e.ProductWarehouseId,
MarketingType: marketingType, Qty: e.Qty,
Qty: e.Qty, UnitPrice: e.UnitPrice,
UnitPrice: e.UnitPrice, AvgWeight: e.AvgWeight,
AvgWeight: e.AvgWeight, TotalWeight: e.TotalWeight,
TotalWeight: e.TotalWeight, TotalPrice: e.TotalPrice,
TotalPrice: e.TotalPrice, ProductWarehouse: productWarehouse,
ConvertionUnit: e.ConvertionUnit, VehicleNumber: getVehicleNumber(e),
WeightPerConvertion: e.WeightPerConvertion,
TotalPeti: totalPeti,
Week: e.Week,
ProductWarehouse: productWarehouse,
VehicleNumber: getVehicleNumber(e),
} }
} }
@@ -188,9 +160,10 @@ func ToMarketingListDTO(marketing *entity.Marketing, deliveryProducts []entity.M
if len(marketing.Products) > 0 { if len(marketing.Products) > 0 {
salesOrderProducts = make([]DeliveryMarketingProductDTO, len(marketing.Products)) salesOrderProducts = make([]DeliveryMarketingProductDTO, len(marketing.Products))
for i, product := range marketing.Products { for i, product := range marketing.Products {
salesOrderProducts[i] = ToDeliveryMarketingProductDTO(product, marketing.MarketingType) salesOrderProducts[i] = ToDeliveryMarketingProductDTO(product)
} }
} }
return MarketingListDTO{ return MarketingListDTO{
MarketingRelationDTO: ToMarketingRelationDTO(marketing), MarketingRelationDTO: ToMarketingRelationDTO(marketing),
Customer: customer, Customer: customer,
@@ -227,7 +200,7 @@ func ToMarketingDetailDTO(marketing *entity.Marketing, deliveryProducts []entity
if len(marketing.Products) > 0 { if len(marketing.Products) > 0 {
salesOrderProducts = make([]DeliveryMarketingProductDTO, len(marketing.Products)) salesOrderProducts = make([]DeliveryMarketingProductDTO, len(marketing.Products))
for i, product := range marketing.Products { for i, product := range marketing.Products {
salesOrderProducts[i] = ToDeliveryMarketingProductDTO(product, marketing.MarketingType) salesOrderProducts[i] = ToDeliveryMarketingProductDTO(product)
} }
} }
@@ -247,6 +220,7 @@ func ToMarketingDetailDTO(marketing *entity.Marketing, deliveryProducts []entity
mapped := approvalDTO.ToApprovalDTO(*marketing.LatestApproval) mapped := approvalDTO.ToApprovalDTO(*marketing.LatestApproval)
latestApproval = mapped latestApproval = mapped
} }
return MarketingDetailDTO{ return MarketingDetailDTO{
MarketingRelationDTO: ToMarketingRelationDTO(marketing), MarketingRelationDTO: ToMarketingRelationDTO(marketing),
SoDocs: marketing.SoDocs, SoDocs: marketing.SoDocs,
@@ -312,7 +286,7 @@ func groupDeliveryProducts(products []MarketingDeliveryProductDTO, soNumber stri
if !exists { if !exists {
group = &DeliveryGroupDTO{ group = &DeliveryGroupDTO{
DeliveryDate: product.DeliveryDate, DeliveryDate: product.DeliveryDate,
Warehouse: &warehouseDTO.WarehouseRelationDTO{ Warehouse: &productwarehouseDTO.WarehouseRelationDTO{
Id: warehouseId, Id: warehouseId,
Name: warehouseName, Name: warehouseName,
}, },
@@ -353,46 +327,11 @@ func groupDeliveryProducts(products []MarketingDeliveryProductDTO, soNumber stri
} }
func GenerateDeliveryOrderNumber(soNumber string, deliveryDate *time.Time, warehouseId uint) string { func GenerateDeliveryOrderNumber(soNumber string, deliveryDate *time.Time, warehouseId uint) string {
numberPrefix := soNumber dateStr := ""
if strings.HasPrefix(strings.ToUpper(strings.TrimSpace(soNumber)), "SO-") { if deliveryDate != nil {
numberPrefix = "DO-" + soNumber[3:] dateStr = deliveryDate.Format("20060102")
} }
return numberPrefix return fmt.Sprintf("%s-%s-%d", soNumber, dateStr, warehouseId)
}
func collectDoNumbers(marketing *entity.Marketing) []string {
if marketing == nil || len(marketing.Products) == 0 {
return nil
}
seen := make(map[string]struct{})
for _, product := range marketing.Products {
if product.DeliveryProduct == nil || product.DeliveryProduct.DeliveryDate == nil {
continue
}
warehouseID := product.ProductWarehouse.WarehouseId
if warehouseID == 0 && product.ProductWarehouse.Warehouse.Id != 0 {
warehouseID = product.ProductWarehouse.Warehouse.Id
}
if warehouseID == 0 {
continue
}
doNumber := GenerateDeliveryOrderNumber(marketing.SoNumber, product.DeliveryProduct.DeliveryDate, warehouseID)
if doNumber != "" {
seen[doNumber] = struct{}{}
}
}
if len(seen) == 0 {
return nil
}
result := make([]string, 0, len(seen))
for value := range seen {
result = append(result, value)
}
sort.Strings(result)
return result
} }
func getVehicleNumber(e entity.MarketingProduct) string { func getVehicleNumber(e entity.MarketingProduct) string {
@@ -1,7 +1,6 @@
package dto package dto
import ( import (
"math"
"time" "time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
@@ -11,18 +10,13 @@ import (
// === DTO Structs === // === DTO Structs ===
type MarketingProductDTO struct { type MarketingProductDTO struct {
Id uint `json:"id"` Id uint `json:"id"`
MarketingType string `json:"marketing_type"` Qty float64 `json:"qty"`
Qty float64 `json:"qty"` UnitPrice float64 `json:"unit_price"`
UnitPrice float64 `json:"unit_price"` AvgWeight float64 `json:"avg_weight"`
AvgWeight float64 `json:"avg_weight"` TotalWeight float64 `json:"total_weight"`
TotalWeight float64 `json:"total_weight"` TotalPrice float64 `json:"total_price"`
TotalPrice float64 `json:"total_price"` ProductWarehouse *productWarehouseDTO.ProductWarehousNestedDTO `json:"product_warehouse,omitempty"`
ConvertionUnit *string `json:"convertion_unit,omitempty"`
WeightPerConvertion *float64 `json:"weight_per_convertion,omitempty"`
TotalPeti *float64 `json:"total_peti,omitempty"`
Week *int `json:"week,omitempty"`
ProductWarehouse *productWarehouseDTO.ProductWarehousNestedDTO `json:"product_warehouse,omitempty"`
} }
type SalesOrdersListDTO struct { type SalesOrdersListDTO struct {
@@ -35,7 +29,7 @@ type SalesOrdersListDTO struct {
// === Mapper Functions === // === Mapper Functions ===
func ToMarketingProductDTO(e entity.MarketingProduct, marketingType string) MarketingProductDTO { func ToMarketingProductDTO(e entity.MarketingProduct) MarketingProductDTO {
var productWarehouse *productWarehouseDTO.ProductWarehousNestedDTO var productWarehouse *productWarehouseDTO.ProductWarehousNestedDTO
if e.ProductWarehouse.Id != 0 { if e.ProductWarehouse.Id != 0 {
@@ -43,33 +37,21 @@ func ToMarketingProductDTO(e entity.MarketingProduct, marketingType string) Mark
productWarehouse = &mapped productWarehouse = &mapped
} }
// Calculate total_peti only for TELUR marketing type
var totalPeti *float64
if marketingType == "TELUR" && e.ConvertionUnit != nil && *e.ConvertionUnit == "PETI" && e.WeightPerConvertion != nil && *e.WeightPerConvertion > 0 {
calculated := math.Floor(e.TotalWeight / *e.WeightPerConvertion)
totalPeti = &calculated
}
return MarketingProductDTO{ return MarketingProductDTO{
Id: e.Id, Id: e.Id,
MarketingType: marketingType, Qty: e.Qty,
Qty: e.Qty, UnitPrice: e.UnitPrice,
UnitPrice: e.UnitPrice, AvgWeight: e.AvgWeight,
AvgWeight: e.AvgWeight, TotalWeight: e.TotalWeight,
TotalWeight: e.TotalWeight, TotalPrice: e.TotalPrice,
TotalPrice: e.TotalPrice, ProductWarehouse: productWarehouse,
ConvertionUnit: e.ConvertionUnit,
WeightPerConvertion: e.WeightPerConvertion,
TotalPeti: totalPeti,
Week: e.Week,
ProductWarehouse: productWarehouse,
} }
} }
func ToSalesOrdersListDTO(e entity.Marketing) SalesOrdersListDTO { func ToSalesOrdersListDTO(e entity.Marketing) SalesOrdersListDTO {
products := make([]MarketingProductDTO, len(e.Products)) products := make([]MarketingProductDTO, len(e.Products))
for i, p := range e.Products { for i, p := range e.Products {
products[i] = ToMarketingProductDTO(p, e.MarketingType) products[i] = ToMarketingProductDTO(p)
} }
return SalesOrdersListDTO{ return SalesOrdersListDTO{
@@ -86,7 +68,7 @@ func ToSalesOrdersListDTOFromMarketing(e entity.Marketing) SalesOrdersListDTO {
if len(e.Products) > 0 { if len(e.Products) > 0 {
salesOrder = make([]MarketingProductDTO, len(e.Products)) salesOrder = make([]MarketingProductDTO, len(e.Products))
for i, product := range e.Products { for i, product := range e.Products {
salesOrder[i] = ToMarketingProductDTO(product, e.MarketingType) salesOrder[i] = ToMarketingProductDTO(product)
} }
} }

Some files were not shown because too many files have changed in this diff Show More