Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into feat/BE/sso-adjustment

This commit is contained in:
ragilap
2026-01-24 19:03:52 +07:00
109 changed files with 6479 additions and 2422 deletions
+18 -87
View File
@@ -1,90 +1,21 @@
stages: workflow:
- deploy rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
- if: '$CI_COMMIT_BRANCH == "development"'
- if: '$CI_COMMIT_BRANCH == "staging"'
- if: '$CI_COMMIT_BRANCH == "production"'
- when: never
deploy-dev: include:
stage: deploy - local: "ci/development.yml"
image: alpine:3.20 rules:
variables: - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
DEPLOY_APP: "LTI-MBUGROUP" - if: '$CI_COMMIT_BRANCH == "development"'
# Opsional: kalau pakai submodule, ini bikin clone submodule pakai SSH juga
GIT_SUBMODULE_STRATEGY: recursive
GIT_DEPTH: "1"
before_script: - local: "ci/staging.yml"
- echo "🧰 Installing dependencies..." rules:
- apk update && apk add --no-cache openssh git curl bash - if: '$CI_COMMIT_BRANCH == "staging"'
# Setup SSH di runner - local: "ci/production.yml"
- mkdir -p ~/.ssh rules:
- echo "$SSH_PRIVATE_KEY" | tr -d '\r' > ~/.ssh/id_rsa - if: '$CI_COMMIT_BRANCH == "production"'
- chmod 600 ~/.ssh/id_rsa
- eval "$(ssh-agent -s)"
- ssh-add ~/.ssh/id_rsa
# Trust host keys (server + gitlab) biar SSH gak nanya interaktif
- ssh-keyscan -H "$SERVER_IP" >> ~/.ssh/known_hosts
- ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts
script:
- echo "🚀 Deploying latest code to $SERVER_USER@$SERVER_IP"
- >
if ssh -o StrictHostKeyChecking=no "$SERVER_USER@$SERVER_IP" "
set -e
cd /home/devops/docker/deployment/development/lti-api
# Pastikan remote origin SSH (antisipasi kalau pernah ke-set HTTPS)
git remote set-url origin git@gitlab.com:mbugroup/lti-api.git
# Pastikan server percaya gitlab.com juga (untuk git fetch via SSH)
mkdir -p ~/.ssh
ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts
# Fetch/reset pakai SSH
GIT_SSH_COMMAND='ssh -o StrictHostKeyChecking=no' git fetch origin development
git reset --hard origin/development
docker compose restart dev-api-lti || docker compose up -d dev-api-lti
"; then
STATUS='success';
else
STATUS='failed';
fi;
RUN_URL="${CI_PROJECT_URL}/-/pipelines/${CI_PIPELINE_ID}";
if [ "$STATUS" = "success" ]; then
COLOR=3066993;
TITLE="✅ Deployment API Succeeded";
DESC="Deployment job on branch \`${CI_COMMIT_REF_NAME}\` completed successfully.";
else
COLOR=15158332;
TITLE="❌ Deployment API Failed Gaes";
DESC="Deployment job on branch \`${CI_COMMIT_REF_NAME}\` failed.";
fi;
echo "{
\"username\": \"CI Bot\",
\"embeds\": [{
\"title\": \"$TITLE\",
\"description\": \"$DESC\",
\"color\": $COLOR,
\"fields\": [
{\"name\": \"Repository\", \"value\": \"${CI_PROJECT_PATH}\", \"inline\": true},
{\"name\": \"Actor\", \"value\": \"${GITLAB_USER_LOGIN}\", \"inline\": true},
{\"name\": \"Commit\", \"value\": \"${CI_COMMIT_SHA}\", \"inline\": false},
{\"name\": \"Pipeline\", \"value\": \"[Open run](${RUN_URL})\", \"inline\": false}
]
}]
}" > payload.json;
echo "📡 Sending notification to Discord...";
curl -sS -H "Content-Type: application/json" \
-d @payload.json "$DISCORD_WEBHOOK_URL";
only:
- development
environment:
name: development
+90
View File
@@ -0,0 +1,90 @@
stages:
- deploy
deploy-dev:
stage: deploy
image: alpine:3.20
variables:
DEPLOY_APP: "LTI-MBUGROUP"
# Opsional: kalau pakai submodule, ini bikin clone submodule pakai SSH juga
GIT_SUBMODULE_STRATEGY: recursive
GIT_DEPTH: "1"
before_script:
- echo "🧰 Installing dependencies..."
- apk update && apk add --no-cache openssh git curl bash
# Setup SSH di runner
- mkdir -p ~/.ssh
- echo "$SSH_PRIVATE_KEY" | tr -d '\r' > ~/.ssh/id_rsa
- chmod 600 ~/.ssh/id_rsa
- eval "$(ssh-agent -s)"
- ssh-add ~/.ssh/id_rsa
# Trust host keys (server + gitlab) biar SSH gak nanya interaktif
- ssh-keyscan -H "$SERVER_IP" >> ~/.ssh/known_hosts
- ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts
script:
- echo "🚀 Deploying latest code to $SERVER_USER@$SERVER_IP"
- >
if ssh -o StrictHostKeyChecking=no "$SERVER_USER@$SERVER_IP" "
set -e
cd /home/devops/docker/deployment/development/lti-api
# Pastikan remote origin SSH (antisipasi kalau pernah ke-set HTTPS)
git remote set-url origin git@gitlab.com:mbugroup/lti-api.git
# Pastikan server percaya gitlab.com juga (untuk git fetch via SSH)
mkdir -p ~/.ssh
ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts
# Fetch/reset pakai SSH
GIT_SSH_COMMAND='ssh -o StrictHostKeyChecking=no' git fetch origin development
git reset --hard origin/development
docker compose restart dev-api-lti || docker compose up -d dev-api-lti
"; then
STATUS='success';
else
STATUS='failed';
fi;
RUN_URL="${CI_PROJECT_URL}/-/pipelines/${CI_PIPELINE_ID}";
if [ "$STATUS" = "success" ]; then
COLOR=3066993;
TITLE="✅ Deployment API Succeeded";
DESC="Deployment job on branch \`${CI_COMMIT_REF_NAME}\` completed successfully.";
else
COLOR=15158332;
TITLE="❌ Deployment API Failed Gaes";
DESC="Deployment job on branch \`${CI_COMMIT_REF_NAME}\` failed.";
fi;
echo "{
\"username\": \"CI Bot\",
\"embeds\": [{
\"title\": \"$TITLE\",
\"description\": \"$DESC\",
\"color\": $COLOR,
\"fields\": [
{\"name\": \"Repository\", \"value\": \"${CI_PROJECT_PATH}\", \"inline\": true},
{\"name\": \"Actor\", \"value\": \"${GITLAB_USER_LOGIN}\", \"inline\": true},
{\"name\": \"Commit\", \"value\": \"${CI_COMMIT_SHA}\", \"inline\": false},
{\"name\": \"Pipeline\", \"value\": \"[Open run](${RUN_URL})\", \"inline\": false}
]
}]
}" > payload.json;
echo "📡 Sending notification to Discord...";
curl -sS -H "Content-Type: application/json" \
-d @payload.json "$DISCORD_WEBHOOK_URL";
only:
- development
environment:
name: development
+131
View File
@@ -0,0 +1,131 @@
stages:
- build
# - migrate
- deploy
- seed
default:
tags:
- self-hosted-prod
workflow:
rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"'
when: always
- when: never
variables:
DOCKER_BUILDKIT: "1"
IMAGE_TAG: "production_${CI_COMMIT_SHORT_SHA}"
IMAGE_NAME: "${CI_REGISTRY_IMAGE}:${IMAGE_TAG}"
IMAGE_LATEST: "${CI_REGISTRY_IMAGE}:production_latest"
DEPLOY_DIR: "/opt/deploy/lti"
COMPOSE_FILE: "docker-compose.yaml"
# =========================
# BUILD (AUTO)
# =========================
build_production:
stage: build
rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"'
script: |
set -e
docker info
echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
echo "✅ Build image: $IMAGE_NAME"
docker build -t "$IMAGE_NAME" -f Dockerfile .
echo "✅ Push image: $IMAGE_NAME"
docker push "$IMAGE_NAME"
echo "✅ Tag latest: $IMAGE_LATEST"
docker tag "$IMAGE_NAME" "$IMAGE_LATEST"
docker push "$IMAGE_LATEST"
# =========================
# MIGRATE (PRODUCTION - MANUAL)
# =========================
#migrate_production:
# stage: migrate
# rules:
# - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"'
# when: manual
# allow_failure: false
# needs:
# - job: build_production
# artifacts: false
# script: |
# set -e
# cd /opt/deploy/lti
# test -f .env || (echo "❌ .env not found" && exit 1)
# set -a
# . ./.env
# set +a
# Validasi env wajib
# : "${DB_HOST:?DB_HOST not set}"
# : "${DB_PORT:?DB_PORT not set}"
# : "${DB_USER:?DB_USER not set}"
# : "${DB_PASSWORD:?DB_PASSWORD not set}"
# : "${DB_NAME:?DB_NAME not set}"
# DB_SSLMODE="${DB_SSLMODE:-require}"
# export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE}"
# echo "✅ Running migrations (production)..."
# docker run --rm \
# -v "$CI_PROJECT_DIR/internal/database/migrations:/migrations:ro" \
# migrate/migrate:v4.15.2 \
# -path=/migrations -database "$DATABASE_URL" up
# =========================
# DEPLOY (AUTO)
# =========================
deploy_production:
stage: deploy
rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"'
needs:
# - job: migrate_production
# artifacts: false
- job: build_production
artifacts: false
script: |
set -e
docker info
echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
cd "$DEPLOY_DIR"
test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found in $DEPLOY_DIR" && exit 1)
test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1)
docker compose -f "$COMPOSE_FILE" pull
docker compose -f "$COMPOSE_FILE" up -d --force-recreate
docker image prune -f
# =========================
# SEED (MANUAL)
# =========================
seed_production:
stage: seed
rules:
- if: '$CI_COMMIT_BRANCH == "production"'
when: manual
script: |
set -e
cd /opt/deploy/lti
test -f .env || (echo "❌ .env not found" && exit 1)
echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
docker compose --env-file .env pull seed
docker compose --env-file .env run --rm seed
+173
View File
@@ -0,0 +1,173 @@
stages:
- build
- migrate
- deploy
- seed
default:
tags:
- self-hosted-stg
workflow:
rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"'
when: always
- when: never
variables:
DOCKER_BUILDKIT: "1"
IMAGE_TAG: "staging_${CI_COMMIT_SHORT_SHA}"
IMAGE_NAME: "${CI_REGISTRY_IMAGE}:${IMAGE_TAG}"
IMAGE_LATEST: "${CI_REGISTRY_IMAGE}:staging_latest"
DEPLOY_DIR: "/opt/deploy/stg-lti-api"
COMPOSE_FILE: "docker-compose.yaml"
# =========================
# BUILD (AUTO)
# =========================
build_staging:
stage: build
rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"'
script: |
set -e
docker info
echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
echo "✅ Build image: $IMAGE_NAME"
docker build -t "$IMAGE_NAME" -f Dockerfile .
echo "✅ Push image: $IMAGE_NAME"
docker push "$IMAGE_NAME"
echo "✅ Tag latest: $IMAGE_LATEST"
docker tag "$IMAGE_NAME" "$IMAGE_LATEST"
docker push "$IMAGE_LATEST"
# =========================
# MIGRATE (AUTO)
# =========================
migrate_staging:
stage: migrate
rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"'
needs:
- job: build_staging
artifacts: false
script: |
set -e
echo "✅ Running migrations (staging) ..."
cd "$DEPLOY_DIR"
test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found in $DEPLOY_DIR" && exit 1)
test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1)
# ✅ load env dari server
set -a
. ./.env
set +a
# ✅ validasi
test -n "$DB_HOST" || (echo "❌ DB_HOST empty" && exit 1)
test -n "$DB_PORT" || (echo "❌ DB_PORT empty" && exit 1)
test -n "$DB_USER" || (echo "❌ DB_USER empty" && exit 1)
test -n "$DB_PASSWORD" || (echo "❌ DB_PASSWORD empty" && exit 1)
test -n "$DB_NAME" || (echo "❌ DB_NAME empty" && exit 1)
export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE:-disable}"
echo "✅ DATABASE_URL=$DATABASE_URL"
# ✅ Pastikan postgres & redis ON (sesuaikan nama service compose kamu!)
echo "✅ Ensuring postgres & redis running ..."
docker compose -f "$COMPOSE_FILE" up -d stg-postgres-lti stg-redis-lti || true
# ✅ Ambil network key dari compose
COMPOSE_NETWORK_KEY="$(docker compose -f "$COMPOSE_FILE" config | awk '/networks:/ {getline; print $1}' | tr -d ':')"
echo "✅ Compose network key: $COMPOSE_NETWORK_KEY"
# ✅ Cari network name yang dipakai docker
NETWORK_NAME="$(docker network ls --format '{{.Name}}' | grep "_${COMPOSE_NETWORK_KEY}$" | head -n 1)"
test -n "$NETWORK_NAME" || (echo "❌ Cannot find docker network for compose ($COMPOSE_NETWORK_KEY)" && exit 1)
echo "✅ Docker network detected: $NETWORK_NAME"
# ✅ Migrations dari repo (CI workspace)
echo "✅ Checking migrations from repo..."
ls -lah "$CI_PROJECT_DIR/internal/database/migrations"
echo "✅ Running migrations via migrate/migrate container"
set +e
out=$(docker run --rm \
--network "$NETWORK_NAME" \
-v "$CI_PROJECT_DIR/internal/database/migrations:/migrations:ro" \
migrate/migrate:v4.15.2 \
-path=/migrations -database "$DATABASE_URL" up 2>&1)
code=$?
set -e
echo "$out"
# ✅ Handle no change dengan benar (tidak false-success)
if echo "$out" | grep -qi "no change"; then
echo "✅ No change (already up to date)"
exit 0
fi
if [ $code -ne 0 ]; then
echo "❌ Migration failed with exit code $code"
exit $code
fi
echo "✅ Migration applied successfully"
# =========================
# DEPLOY (AUTO)
# =========================
deploy_staging:
stage: deploy
rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"'
needs:
- job: migrate_staging
artifacts: false
- job: build_staging
artifacts: false
script: |
set -e
docker info
echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
cd "$DEPLOY_DIR"
test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found in $DEPLOY_DIR" && exit 1)
test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1)
docker compose -f "$COMPOSE_FILE" pull
docker compose -f "$COMPOSE_FILE" up -d --force-recreate
docker image prune -f
# =========================
# SEED (MANUAL)
# =========================
seed_staging:
stage: seed
rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "staging"'
needs:
- job: deploy_staging
artifacts: false
when: manual
allow_failure: false
script: |
set -e
cd "$DEPLOY_DIR"
test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found" && exit 1)
test -f .env || (echo "❌ .env not found" && exit 1)
docker compose -f "$COMPOSE_FILE" pull seed || true
docker compose -f "$COMPOSE_FILE" run --rm seed%
@@ -0,0 +1,307 @@
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, date *time.Time) (float64, float64, error)
GetProjectFlockIDByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (uint, error)
GetTransferSourceSummary(ctx context.Context, projectFlockKandangId uint) (uint, float64, error)
}
type HppRepositoryImpl struct {
db *gorm.DB
}
func NewHppCostRepository(db *gorm.DB) HppCostRepository {
return &HppRepositoryImpl{db: db}
}
func (r *HppRepositoryImpl) GetProjectFlockKandangIDs(ctx context.Context, projectFlockId uint) ([]uint, error) {
var ids []uint
err := r.db.WithContext(ctx).
Table("project_flock_kandangs").
Select("id").
Where("project_flock_id = ?", projectFlockId).
Scan(&ids).Error
if err != nil {
return nil, err
}
return ids, nil
}
func (r *HppRepositoryImpl) GetDocCost(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) {
var total float64
err := r.db.WithContext(ctx).
Table("project_chickins AS pc").
Select("COALESCE(SUM(pc.usage_qty * COALESCE(pi.price, 0)), 0)").
Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id AND sa.stockable_type = ?", fifo.UsableKeyProjectChickin.String(), fifo.StockableKeyPurchaseItems.String()).
Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id").
Where("pc.project_flock_kandang_id IN (?)", projectFlockKandangIDs).
Scan(&total).Error
if err != nil {
return 0, err
}
return total, nil
}
func (r *HppRepositoryImpl) GetBudgetCostByProjectFlockId(ctx context.Context, projectFlockId uint) (float64, error) {
var total float64
err := r.db.WithContext(ctx).
Table("project_budgets AS pb").
Select("COALESCE(SUM(pb.qty * pb.price), 0)").
Where("pb.project_flock_id = ?", projectFlockId).
Scan(&total).Error
if err != nil {
return 0, err
}
return total, nil
}
func (r *HppRepositoryImpl) GetExpedisionCost(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) {
var total float64
err := r.db.WithContext(ctx).
Table("expense_nonstocks AS en").
Select("COALESCE(SUM(er.qty * er.price), 0)").
Joins("JOIN expense_realizations AS er ON er.expense_nonstock_id = en.id").
Joins("JOIN flags AS f ON f.flagable_id = en.nonstock_id AND f.flagable_type = ?", entity.FlagableTypeNonstock).
Where("en.project_flock_kandang_id IN (?)", projectFlockKandangIDs).
Where("f.name = ?", utils.FlagEkspedisi).
Scan(&total).Error
if err != nil {
return 0, err
}
return total, nil
}
func (r *HppRepositoryImpl) GetFeedUsageCost(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, error) {
if date == nil {
now := time.Now()
date = &now
}
var total float64
err := r.db.WithContext(ctx).
Table("recordings AS r").
Select("COALESCE(SUM(rs.usage_qty * COALESCE(pi.price, 0)), 0)").
Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id").
Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id").
Joins("JOIN flags AS f ON f.flagable_id = pw.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct).
Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String()).
Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id").
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
Where("r.record_datetime <= ?", *date).
Where("f.name = ?", utils.FlagPakan).
Scan(&total).Error
if err != nil {
return 0, err
}
return total, nil
}
func (r *HppRepositoryImpl) GetOvkUsageCost(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, error) {
if date == nil {
now := time.Now()
date = &now
}
flags := []utils.FlagType{
utils.FlagOVK,
utils.FlagObat,
utils.FlagVitamin,
utils.FlagKimia,
}
var total float64
err := r.db.WithContext(ctx).
Table("recordings AS r").
Select("COALESCE(SUM(rs.usage_qty * COALESCE(pi.price, 0)), 0)").
Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id").
Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id").
Joins("JOIN flags AS f ON f.flagable_id = pw.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct).
Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String()).
Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id").
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
Where("r.record_datetime <= ?", *date).
Where("f.name IN ?", flags).
Scan(&total).Error
if err != nil {
return 0, err
}
return total, nil
}
func (r *HppRepositoryImpl) GetTotalPopulation(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) {
var total float64
err := r.db.WithContext(ctx).
Table("project_chickins AS pc").
Select("COALESCE(SUM(pc.usage_qty), 0)").
Where("pc.project_flock_kandang_id IN (?)", projectFlockKandangIDs).
Scan(&total).Error
if err != nil {
return 0, err
}
return total, nil
}
func (r *HppRepositoryImpl) GetPulletCost(ctx context.Context, projectFlockKandangId uint) (float64, error) {
stockablePurchase := fifo.StockableKeyPurchaseItems.String()
stockableTransferIn := fifo.StockableKeyStockTransferIn.String()
usableProjectChickin := fifo.UsableKeyProjectChickin.String()
var total float64
err := r.db.WithContext(ctx).
Table("project_chickins AS pc").
Select(`
COALESCE(SUM(pc.usage_qty * CASE
WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0)
WHEN sa.stockable_type = ? THEN COALESCE(tpi.price, 0)
ELSE 0
END), 0)`,
stockablePurchase, stockableTransferIn).
Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id", usableProjectChickin).
Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase).
Joins("LEFT JOIN stock_allocations AS tsa ON tsa.usable_type = ? AND tsa.usable_id = sa.stockable_id AND sa.stockable_type = ? AND tsa.stockable_type = ?", stockableTransferIn, stockableTransferIn, stockablePurchase).
Joins("LEFT JOIN purchase_items AS tpi ON tpi.id = tsa.stockable_id").
Where("pc.project_flock_kandang_id = ?", projectFlockKandangId).
Scan(&total).Error
if err != nil {
return 0, err
}
return total, nil
}
func (r *HppRepositoryImpl) GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, float64, error) {
if date == nil {
now := time.Now()
date = &now
}
var totals struct {
TotalPieces float64
TotalWeightKg float64
}
err := r.db.WithContext(ctx).
Table("recordings AS r").
Select("COALESCE(SUM(re.qty), 0) AS total_pieces, COALESCE(SUM(re.weight), 0)AS total_weight_kg").
Joins("JOIN recording_eggs AS re ON re.recording_id = r.id").
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
Where("r.record_datetime <= ?", *date).
Scan(&totals).Error
if err != nil {
return 0, 0, err
}
return totals.TotalPieces, totals.TotalWeightKg, nil
}
func (r *HppRepositoryImpl) GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(
ctx context.Context,
projectFlockKandangIDs []uint,
date *time.Time,
) (float64, float64, error) {
if date == nil {
now := time.Now()
date = &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 <= ?", *date)
var totals struct {
TotalPieces float64
TotalWeight float64
}
err := r.db.WithContext(ctx).
Table("(?) AS x", subQuery).
Select(`
COALESCE(SUM(x.mdp_usage_qty), 0) AS total_pieces,
COALESCE(SUM(x.mdp_weight), 0) AS total_weight
`).
Scan(&totals).Error
if err != nil {
return 0, 0, err
}
return totals.TotalPieces, totals.TotalWeight, nil
}
func (r *HppRepositoryImpl) GetProjectFlockIDByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (uint, error) {
var projectFlockID uint
err := r.db.WithContext(ctx).
Table("project_flock_kandangs").
Select("project_flock_id").
Where("id = ?", projectFlockKandangId).
Scan(&projectFlockID).Error
if err != nil {
return 0, err
}
return projectFlockID, nil
}
func (r *HppRepositoryImpl) GetTransferSourceSummary(ctx context.Context, projectFlockKandangId uint) (uint, float64, error) {
var summary struct {
ProjectFlockID uint
TotalQty float64
}
err := r.db.WithContext(ctx).
Table("laying_transfer_targets AS ltt").
Select("lt.from_project_flock_id AS project_flock_id, COALESCE(SUM(ltt.total_qty), 0) AS total_qty").
Joins("JOIN laying_transfers AS lt ON lt.id = ltt.laying_transfer_id").
Where("ltt.target_project_flock_kandang_id = ?", projectFlockKandangId).
Group("lt.from_project_flock_id").
Scan(&summary).Error
if err != nil {
return 0, 0, err
}
return summary.ProjectFlockID, summary.TotalQty, nil
}
@@ -25,6 +25,7 @@ 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
} }
type fifoService struct { type fifoService struct {
@@ -95,6 +96,15 @@ 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
@@ -137,6 +147,37 @@ type StockReleaseRequest struct {
Reason *string Reason *string
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()) == "" {
@@ -0,0 +1,268 @@
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, date *time.Time, totalDepresiasiGrowing float64) (float64, error)
GetBudgetKandangLaying(projectFlockKandangId uint, date *time.Time) (float64, error)
GetDepresiasiTransfer(projectFlockKandangId uint, date *time.Time) (float64, error)
GetHppEstimationDanRealisasi(totalProductionCost float64, projectFlockKandangId uint, date *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
}
depresiasiTransfer, err := s.GetDepresiasiTransfer(projectFlockKandangId, date)
if err != nil {
return nil, err
}
totalProductionCost, err := s.GetTotalProductionCost(projectFlockKandangId, date, depresiasiTransfer)
if err != nil {
return nil, err
}
return s.GetHppEstimationDanRealisasi(totalProductionCost, projectFlockKandangId, date)
}
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, date *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}, date)
if err != nil {
return 0, err
}
costOvk, err := s.hppRepo.GetOvkUsageCost(context.Background(), []uint{projectFlockKandangId}, date)
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, date)
if err != nil {
return 0, err
}
return depresiasiTransfer + costPullet + costFeed + costOvk + costExpedision + costBudget, nil
}
func (s *hppService) GetBudgetKandangLaying(projectFlockKandangId uint, date *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, date)
if err != nil {
return 0, err
}
eggProduksiPiecesKandang, _, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, date)
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, date *time.Time) (float64, error) {
if date == nil {
now := time.Now()
date = &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, date)
if err != nil {
return 0, err
}
return (totalDepresiasiFlockGrowing * transferTotalQty) / totalPopulationFlockGrowing, nil
}
func (s *hppService) GetHppEstimationDanRealisasi(totalProductionCost float64, projectFlockKandangId uint, date *time.Time) (*HppCostResponse, error) {
if date == nil {
now := time.Now()
date = &now
}
if s.hppRepo == nil {
return &HppCostResponse{}, nil
}
estimPieces, estimWeightKg, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, date)
if err != nil {
return nil, err
}
realPieces, realWeightKg, err := s.hppRepo.GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, date)
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
}
@@ -0,0 +1,3 @@
ALTER TABLE recording_eggs
DROP COLUMN IF EXISTS total_used,
DROP COLUMN IF EXISTS total_qty;
@@ -0,0 +1,7 @@
ALTER TABLE recording_eggs
ADD COLUMN total_qty NUMERIC(15, 3) DEFAULT 0 NOT NULL,
ADD COLUMN total_used NUMERIC(15, 3) DEFAULT 0 NOT NULL;
UPDATE recording_eggs
SET total_qty = qty
WHERE total_qty = 0;
@@ -0,0 +1,3 @@
-- Rollback: add price back to nonstock_suppliers
ALTER TABLE nonstock_suppliers
ADD COLUMN IF NOT EXISTS price NUMERIC(15, 3) NOT NULL DEFAULT 0;
@@ -0,0 +1,3 @@
-- Migration: remove price from nonstock_suppliers
ALTER TABLE nonstock_suppliers
DROP COLUMN IF EXISTS price;
@@ -0,0 +1,4 @@
-- Rollback: Remove requested_qty column from laying_transfer_sources table
ALTER TABLE laying_transfer_sources
DROP COLUMN IF EXISTS requested_qty;
@@ -0,0 +1,9 @@
-- Add requested_qty column to laying_transfer_sources table
-- This field stores the quantity requested by user during create/update
-- Separate from UsageQty (FIFO consumed) and PendingUsageQty (FIFO pending)
ALTER TABLE laying_transfer_sources
ADD COLUMN requested_qty NUMERIC(15,3) DEFAULT 0 NOT NULL;
-- Add comment for documentation
COMMENT ON COLUMN laying_transfer_sources.requested_qty IS 'Quantity requested by user during create/update';
@@ -0,0 +1,3 @@
ALTER TABLE recording_depletions
DROP COLUMN IF EXISTS pending_qty,
DROP COLUMN IF EXISTS source_product_warehouse_id;
@@ -0,0 +1,17 @@
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;
+9 -20
View File
@@ -2,28 +2,17 @@ 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"`
StockLogId uint `gorm:"column:stock_log_id;not null;index"` StockLogId uint `gorm:"column:stock_log_id;not null;index"`
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"` ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
TotalQty float64 `gorm:"column:total_qty;default:0"`
TotalUsed float64 `gorm:"column:total_used;default:0"`
UsageQty float64 `gorm:"column:usage_qty;default:0"`
PendingQty float64 `gorm:"column:pending_qty;default:0"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime"`
// === 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"` StockLog *StockLog `gorm:"foreignKey:StockLogId;references:Id"`
ProductWarehouse *ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"` ProductWarehouse *ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"`
} }
@@ -11,6 +11,7 @@ type LayingTransferSource struct {
LayingTransferId uint `gorm:"index;not null"` LayingTransferId uint `gorm:"index;not null"`
SourceProjectFlockKandangId uint `gorm:"not null"` SourceProjectFlockKandangId uint `gorm:"not null"`
ProductWarehouseId *uint `gorm:""` ProductWarehouseId *uint `gorm:""`
RequestedQty float64 `gorm:"type:numeric(15,3);default:0;not null"` // Quantity requested by user
UsageQty float64 `gorm:"type:numeric(15,3);default:0;not null"` // FIFO USABLE field UsageQty float64 `gorm:"type:numeric(15,3);default:0;not null"` // FIFO USABLE field
PendingUsageQty float64 `gorm:"type:numeric(15,3);default:0;not null"` // FIFO USABLE field PendingUsageQty float64 `gorm:"type:numeric(15,3);default:0;not null"` // FIFO USABLE field
Note string `gorm:"type:text"` Note string `gorm:"type:text"`
-1
View File
@@ -5,7 +5,6 @@ import "time"
type NonstockSupplier struct { type NonstockSupplier struct {
NonstockId uint `gorm:"not null"` NonstockId uint `gorm:"not null"`
SupplierId uint `gorm:"not null"` SupplierId uint `gorm:"not null"`
Price float64 `gorm:"type:numeric(15,3);not null;default:0"`
CreatedAt time.Time `gorm:"autoCreateTime"` CreatedAt time.Time `gorm:"autoCreateTime"`
Nonstock Nonstock `gorm:"foreignKey:NonstockId;references:Id"` Nonstock Nonstock `gorm:"foreignKey:NonstockId;references:Id"`
+6 -4
View File
@@ -1,10 +1,12 @@
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"`
Qty float64 `gorm:"column:qty;not null"` SourceProductWarehouseId *uint `gorm:"column:source_product_warehouse_id"`
Qty float64 `gorm:"column:qty;not null"`
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"`
+2
View File
@@ -7,6 +7,8 @@ type RecordingEgg struct {
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"`
Qty int `gorm:"column:qty;not null"` Qty int `gorm:"column:qty;not null"`
TotalQty float64 `gorm:"column:total_qty"`
TotalUsed float64 `gorm:"column:total_used"`
Weight *float64 `gorm:"column:weight"` Weight *float64 `gorm:"column:weight"`
CreatedBy uint `gorm:"column:created_by"` CreatedBy uint `gorm:"column:created_by"`
CreatedAt time.Time `gorm:"autoCreateTime"` CreatedAt time.Time `gorm:"autoCreateTime"`
+15 -1
View File
@@ -1,8 +1,9 @@
package middleware package middleware
const( const (
P_DashboardGetAll = "lti.dashboard.list" P_DashboardGetAll = "lti.dashboard.list"
) )
// project-flock // project-flock
const ( const (
P_ProjectFlockKandangsClosing = "lti.production.project_flock_kandangs.closing" P_ProjectFlockKandangsClosing = "lti.production.project_flock_kandangs.closing"
@@ -51,6 +52,7 @@ const (
P_ReportDebtSupplierGetAll = "lti.repport.debtsupplier.list" P_ReportDebtSupplierGetAll = "lti.repport.debtsupplier.list"
P_ReportHppPerKandangGetAll = "lti.repport.gethppperkandang.list" P_ReportHppPerKandangGetAll = "lti.repport.gethppperkandang.list"
P_ReportProductionResultGetAll = "lti.repport.production_result.list" P_ReportProductionResultGetAll = "lti.repport.production_result.list"
P_ReportCustomerPaymentGetAll = "lti.repport.customerpayment.list"
) )
const ( const (
@@ -238,3 +240,15 @@ const (
P_UserGetAll = "lti.users.list" P_UserGetAll = "lti.users.list"
P_UserGetOne = "lti.users.detail" P_UserGetOne = "lti.users.detail"
) )
// daily-checklist
const (
P_DailyChecklistDashboardList = "lti.daily_checklist.dashboard.list"
P_DailyChecklistCreateOne = "lti.daily_checklist.create"
P_DailyChecklistGetAll = "lti.daily_checklist.list"
P_DailyChecklistGetOne = "lti.daily_checklist.detail"
P_DailyChecklistReports = "lti.daily_checklist.reports"
P_DailyChecklistEmployee = "lti.daily_checklist.master_data.employee"
P_DailyChecklistActivity = "lti.daily_checklist.master_data.activity"
P_DailyChecklistActivityConfig = "lti.daily_checklist.master_data.configuration"
)
@@ -14,22 +14,45 @@ import (
) )
type ClosingController struct { type ClosingController struct {
ClosingService service.ClosingService ClosingService service.ClosingService
SapronakService service.SapronakService SapronakService service.SapronakService
ClosingKeuanganService service.ClosingKeuanganService
} }
func NewClosingController(closingService service.ClosingService, sapronakService service.SapronakService) *ClosingController { func NewClosingController(closingService service.ClosingService, sapronakService service.SapronakService, closingKeuanganService service.ClosingKeuanganService) *ClosingController {
return &ClosingController{ return &ClosingController{
ClosingService: closingService, ClosingService: closingService,
SapronakService: sapronakService, SapronakService: sapronakService,
ClosingKeuanganService: closingKeuanganService,
} }
} }
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 {
@@ -158,7 +181,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(uint(projectFlockID), result), Data: dto.ToPenjualanRealisasiResponseDTO(result),
}) })
} }
@@ -188,7 +211,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(uint(projectFlockID), result), Data: dto.ToPenjualanRealisasiResponseDTO(result),
}) })
} }
@@ -234,9 +257,10 @@ 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)
@@ -275,6 +299,45 @@ 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", "")
@@ -338,7 +401,7 @@ func (u *ClosingController) GetClosingKeuangan(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadRequest, "Invalid Project Flock Id") return fiber.NewError(fiber.StatusBadRequest, "Invalid Project Flock Id")
} }
result, err := u.ClosingService.GetClosingKeuangan(c, uint(projectFlockID)) result, err := u.ClosingKeuanganService.GetClosingKeuangan(c, uint(projectFlockID))
if err != nil { if err != nil {
return err return err
} }
@@ -352,6 +415,34 @@ func (u *ClosingController) GetClosingKeuangan(c *fiber.Ctx) error {
}) })
} }
func (u *ClosingController) GetClosingKeuanganByKandang(c *fiber.Ctx) error {
projectParam := c.Params("project_flock_id")
kandangParam := c.Params("project_flock_kandang_id")
projectFlockID, err := strconv.Atoi(projectParam)
if err != nil || projectFlockID <= 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_id")
}
pfkID, err := strconv.Atoi(kandangParam)
if err != nil || pfkID <= 0 {
return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_kandang_id")
}
result, err := u.ClosingKeuanganService.GetClosingKeuanganByKandang(c, uint(projectFlockID), uint(pfkID))
if err != nil {
return err
}
return c.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Get closing keuangan by kandang successfully",
Data: result,
})
}
func (u *ClosingController) GetExpeditionHPP(c *fiber.Ctx) error { func (u *ClosingController) GetExpeditionHPP(c *fiber.Ctx) error {
param := c.Params("project_flock_id") param := c.Params("project_flock_id")
+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,omitempty"` HendayStd float64 `json:"hen_day_std"`
EggMass *float64 `json:"egg_mass,omitempty"` EggMass float64 `json:"egg_mass,omitempty"`
EggMassStd *float64 `json:"egg_mass_std,omitempty"` EggMassStd float64 `json:"egg_mass_std"`
EggWeight *float64 `json:"egg_weight,omitempty"` EggWeight float64 `json:"egg_weight,omitempty"`
EggWeightStd *float64 `json:"egg_weight_std,omitempty"` 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,omitempty"` HenHouseStd float64 `json:"hen_housed_std"`
} }
type ClosingSalesGroupDTO struct { type ClosingSalesGroupDTO struct {
@@ -1,135 +1,103 @@
package dto package dto
import ( // === CLOSING KEUANGAN CODES ===
"slices"
"strings"
"gitlab.com/mbugroup/lti-api.git/internal/entities" // Closing HPP Codes
"gitlab.com/mbugroup/lti-api.git/internal/utils" type ClosingHPPCode string
)
// === CONSTANTS ===
const ( const (
HPPGroupPengeluaran = "HPP dan Pengeluaran" HPPCodePakan ClosingHPPCode = "PAKAN"
HPPGroupBahanBaku = "HPP dan Bahan Baku" HPPCodeOVK ClosingHPPCode = "OVK"
HPPLabelOverhead = "Pengeluaran Overhead" HPPCodeDOC ClosingHPPCode = "DOC"
HPPLabelEkspedisi = "Beban Ekspedisi" HPPCodeDepresiasi ClosingHPPCode = "DEPRESIASI"
HPPSummaryLabel = "HPP" HPPCodeOverhead ClosingHPPCode = "OVERHEAD"
HPPCodeEkspedisi ClosingHPPCode = "EKSPEDISI"
PLSalesTypeChicken = "Penjualan Ayam Besar"
PLSalesTypeEgg = "Penjualan Telur"
PLItemTypeSapronak = "Pembelian Sapronak"
PLItemTypeOverhead = "Pengeluaran Overhead"
PLItemTypeEkspedisi = "Beban Ekspedisi"
PLSummaryLabelGrossProfit = "LABA RUGI BRUTTO"
PLSummaryLabelSubTotal = "SUB TOTAL"
PLSummaryLabelNetProfit = "LABA RUGI NETTO"
PurchaseLabelPrefix = "Pembelian "
) )
// === CONTEXT STRUCTS === // Closing Profit Loss Codes
type ClosingProfitLossCode string
type CalculationContext struct { const (
TotalPopulation float64 PLCodeSales ClosingProfitLossCode = "SALES"
TotalWeightProduced float64 PLCodeSapronak ClosingProfitLossCode = "SAPRONAK"
TotalEggWeightKg float64 PLCodeOverhead ClosingProfitLossCode = "OVERHEAD"
TotalDepletion float64 PLCodeEkspedisi ClosingProfitLossCode = "EKSPEDISI"
TotalWeightSold float64 )
ActualPopulation float64
}
type ClosingKeuanganInput struct { // === NEW CLOSING KEUANGAN DTO ===
ProjectFlockCategory string
PurchaseItems []entities.PurchaseItem
Budgets []entities.ProjectBudget
Realizations []entities.ExpenseRealization
DeliveryProducts []entities.MarketingDeliveryProduct
Chickins []entities.ProjectChickin
TotalWeightProduced float64
TotalEggWeightKg float64
TotalDepletion float64
}
// === BASE METRICS ===
// 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"`
} }
type Comparison struct { // HPPItem represents an item in HPP section
type HPPItem struct {
ID uint `json:"id"`
Category string `json:"category"` // "purchase" or "overhead"
Code string `json:"code"` // "PAKAN", "OVK", "DOC", "EKSPEDISI"
Label string `json:"label"`
Budgeting FinancialMetrics `json:"budgeting"` Budgeting FinancialMetrics `json:"budgeting"`
Realization FinancialMetrics `json:"realization"` Realization FinancialMetrics `json:"realization"`
} }
// === HPP PURCHASES PACKAGE === // HPPSummary represents summary for HPP section
type HPPSummary struct {
type HppItem struct { Label string `json:"label"`
Type string `json:"type"` Budgeting FinancialMetrics `json:"budgeting"`
Comparison Realization FinancialMetrics `json:"realization"`
EggBudgeting *FinancialMetrics `json:"egg_budgeting,omitempty"`
EggRealization *FinancialMetrics `json:"egg_realization,omitempty"`
} }
type HppGroup struct { // HPPSection represents HPP data section
GroupName string `json:"group_name"` type HPPSection struct {
Data []HppItem `json:"data"` Items []HPPItem `json:"items"`
Summary HPPSummary `json:"summary"`
} }
type SummaryHpp struct { // ProfitLossItem represents an item in Profit & Loss section
Label string `json:"label"` type ProfitLossItem struct {
Budgeting FinancialMetrics `json:"budgeting"` Code string `json:"code"` // "SALES", "PURCHASE_DOC", "OVERHEAD", "EKSPEDISI"
Realization FinancialMetrics `json:"realization"` Label string `json:"label"`
EggBudgeting *FinancialMetrics `json:"egg_budgeting,omitempty"` Type string `json:"type"` // "income", "purchase", "overhead"
EggRealization *FinancialMetrics `json:"egg_realization,omitempty"` RpPerBird float64 `json:"rp_per_bird"`
RpPerKg float64 `json:"rp_per_kg"`
Amount float64 `json:"amount"`
} }
type HppPurchasesSection struct { // ProfitLossSummary represents summary for Profit & Loss section
Hpp []HppGroup `json:"hpp"` type ProfitLossSummary struct {
SummaryHpp SummaryHpp `json:"summary_hpp"` GrossProfit FinancialMetrics `json:"gross_profit"`
} SubTotal FinancialMetrics `json:"sub_total"`
NetProfit FinancialMetrics `json:"net_profit"`
// === PROFIT LOSS PACKAGE ===
type PLItem struct {
Type string `json:"type"`
FinancialMetrics
}
type PLSummaryItem struct {
Label string `json:"label"`
FinancialMetrics
}
type PLSummaryGroup struct {
GrossProfit PLSummaryItem `json:"gross_profit"`
SubTotal PLSummaryItem `json:"sub_total"`
NetProfit PLSummaryItem `json:"net_profit"`
}
type ProfitLossData struct {
Penjualan []PLItem `json:"penjualan"`
Pembelian []PLItem `json:"pembelian"`
Overhead PLItem `json:"overhead"`
Ekspedisi PLItem `json:"ekspedisi"`
Summary PLSummaryGroup `json:"summary"`
} }
// ProfitLossSection represents Profit & Loss data section
type ProfitLossSection struct { type ProfitLossSection struct {
Data ProfitLossData `json:"data"` Items []ProfitLossItem `json:"items"`
Summary ProfitLossSummary `json:"summary"`
} }
// === RESPONSE DTO (ROOT) === // ClosingKeuanganData represents the main data structure
type ClosingKeuanganData struct {
HPP HPPSection `json:"hpp"`
ProfitLoss ProfitLossSection `json:"profit_loss"`
}
type ReportResponse struct { // ClosingKeuanganResponse represents the full API response
HppPurchases HppPurchasesSection `json:"hpp_purchases"` type ClosingKeuanganResponse struct {
ProfitLoss ProfitLossSection `json:"profit_loss"` Code int `json:"code"`
Status string `json:"status"`
Message string `json:"message"`
Data ClosingKeuanganData `json:"data"`
} }
// === MAPPER FUNCTIONS === // === 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,
@@ -138,451 +106,80 @@ func ToFinancialMetrics(rpPerBird, rpPerKg, amount float64) FinancialMetrics {
} }
} }
func ToComparison(budgeting, realization FinancialMetrics) Comparison { // ToHPPItem creates HPP item
return Comparison{ func ToHPPItem(id uint, category, code, label string, budgeting, realization FinancialMetrics) HPPItem {
return HPPItem{
ID: id,
Category: category,
Code: code,
Label: label,
Budgeting: budgeting, Budgeting: budgeting,
Realization: realization, Realization: realization,
} }
} }
// === HPP PENGELUARAN (from Purchase Items) === // ToHPPSummary creates HPP summary
func ToHPPSummary(label string, budgeting, realization FinancialMetrics, eggBudgeting, eggRealization *FinancialMetrics) HPPSummary {
func getFlagLabel(flagType utils.FlagType) string { return HPPSummary{
return PurchaseLabelPrefix + string(flagType) Label: label,
} Budgeting: budgeting,
Realization: realization,
func buildHppItemsByPurchaseFlags(purchaseItems []entities.PurchaseItem, ctx CalculationContext) []HppItem { EggBudgeting: eggBudgeting,
flags := []utils.FlagType{ EggRealization: eggRealization,
utils.FlagDOC, utils.FlagPullet, utils.FlagLayer, utils.FlagPakan,
utils.FlagPreStarter, utils.FlagStarter, utils.FlagFinisher,
utils.FlagOVK, utils.FlagObat, utils.FlagVitamin, utils.FlagKimia,
}
items := []HppItem{}
seenFlags := make(map[utils.FlagType]bool)
for _, item := range purchaseItems {
if item.Product == nil || len(item.Product.Flags) == 0 {
continue
}
for _, flag := range item.Product.Flags {
flagType := utils.FlagType(flag.Name)
if slices.Contains(flags, flagType) && !seenFlags[flagType] {
amount := sumPurchasesByFlag(purchaseItems, flagType)
rpPerBird, rpPerKg := calculatePerUnitMetrics(amount, ctx.TotalPopulation, ctx.TotalWeightProduced)
items = append(items, HppItem{
Type: getFlagLabel(flagType),
Comparison: ToComparison(
ToFinancialMetrics(rpPerBird, rpPerKg, amount),
ToFinancialMetrics(rpPerBird, rpPerKg, amount),
),
})
seenFlags[flagType] = true
}
}
}
return items
}
// === HPP BAHAN BAKU (from ProjectBudget + ExpenseRealization) ===
func createHppOverheadItem(budgetAmount, realizationAmount float64, ctx CalculationContext) HppItem {
budgetRpPerBird, budgetRpPerKg := calculatePerUnitMetrics(budgetAmount, ctx.TotalPopulation, ctx.TotalWeightProduced)
realizationRpPerBird, realizationRpPerKg := calculatePerUnitMetrics(realizationAmount, ctx.TotalPopulation, ctx.TotalWeightProduced)
return HppItem{
Type: HPPLabelOverhead,
Comparison: ToComparison(
ToFinancialMetrics(budgetRpPerBird, budgetRpPerKg, budgetAmount),
ToFinancialMetrics(realizationRpPerBird, realizationRpPerKg, realizationAmount),
),
} }
} }
func createHppEkspedisiItem(ekspedisiAmount float64, ctx CalculationContext) HppItem { // ToHPPSection creates HPP section
ekspedisiRpPerBird, ekspedisiRpPerKg := calculatePerUnitMetrics(ekspedisiAmount, ctx.TotalPopulation, ctx.TotalWeightProduced) func ToHPPSection(items []HPPItem, summary HPPSummary) HPPSection {
return HPPSection{
return HppItem{ Items: items,
Type: HPPLabelEkspedisi, Summary: summary,
Comparison: ToComparison(
ToFinancialMetrics(ekspedisiRpPerBird, ekspedisiRpPerKg, ekspedisiAmount),
ToFinancialMetrics(ekspedisiRpPerBird, ekspedisiRpPerKg, ekspedisiAmount),
),
} }
} }
func ToHppBahanBakuGroup(budgets []entities.ProjectBudget, realizations []entities.ExpenseRealization, ctx CalculationContext) HppGroup { // ToProfitLossItem creates Profit & Loss item
items := []HppItem{} func ToProfitLossItem(code, label, itemType string, rpPerBird, rpPerKg, amount float64) ProfitLossItem {
return ProfitLossItem{
budgetAmount := sumBudgetsByFilter(budgets, func(*entities.ProjectBudget) bool { return true }) Code: code,
realizationAmount := getOperationalExpenses(realizations) Label: label,
Type: itemType,
if budgetAmount > 0 || realizationAmount > 0 { RpPerBird: rpPerBird,
items = append(items, createHppOverheadItem(budgetAmount, realizationAmount, ctx)) RpPerKg: rpPerKg,
} Amount: amount,
ekspedisiAmount := sumRealizationsByFilter(realizations, filterRealizationByNonstockFlag(utils.FlagEkspedisi))
items = append(items, createHppEkspedisiItem(ekspedisiAmount, ctx))
return HppGroup{
GroupName: HPPGroupBahanBaku,
Data: items,
} }
} }
// === HPP SUMMARY === // ToProfitLossSummary creates Profit & Loss summary
func ToProfitLossSummary(grossProfit, subTotal, netProfit FinancialMetrics) ProfitLossSummary {
func ToSummaryHpp(label string, purchaseItems []entities.PurchaseItem, budgets []entities.ProjectBudget, realizations []entities.ExpenseRealization, projectFlockCategory string, ctx CalculationContext) SummaryHpp { return ProfitLossSummary{
purchaseTotal := sumPurchaseTotal(purchaseItems) GrossProfit: grossProfit,
budgetTotal := sumBudgetsByFilter(budgets, func(*entities.ProjectBudget) bool { return true }) SubTotal: subTotal,
totalBudget := purchaseTotal + budgetTotal NetProfit: netProfit,
totalRealization := sumRealizationsByFilter(realizations, func(*entities.ExpenseRealization) bool { return true })
budgetRpPerBird, budgetRpPerKg := calculatePerUnitMetrics(totalBudget, ctx.TotalPopulation, ctx.TotalWeightProduced)
realizationRpPerBird, realizationRpPerKg := calculatePerUnitMetrics(totalRealization, ctx.TotalPopulation, ctx.TotalWeightProduced)
summary := SummaryHpp{
Label: label,
Budgeting: ToFinancialMetrics(budgetRpPerBird, budgetRpPerKg, totalBudget),
Realization: ToFinancialMetrics(realizationRpPerBird, realizationRpPerKg, totalRealization),
}
if projectFlockCategory == string(utils.ProjectFlockCategoryLaying) && ctx.TotalEggWeightKg > 0 {
budgetEggRpPerKg, _ := calculatePerUnitMetrics(totalBudget, 0, ctx.TotalEggWeightKg)
realizationEggRpPerKg, _ := calculatePerUnitMetrics(totalRealization, 0, ctx.TotalEggWeightKg)
summary.EggBudgeting = &FinancialMetrics{
RpPerBird: 0,
RpPerKg: budgetEggRpPerKg,
Amount: totalBudget,
}
summary.EggRealization = &FinancialMetrics{
RpPerBird: 0,
RpPerKg: realizationEggRpPerKg,
Amount: totalRealization,
}
}
return summary
}
func ToHppPurchasesSection(purchaseItems []entities.PurchaseItem, budgets []entities.ProjectBudget, realizations []entities.ExpenseRealization, projectFlockCategory string, ctx CalculationContext) HppPurchasesSection {
hppGroups := []HppGroup{
{
GroupName: HPPGroupPengeluaran,
Data: buildHppItemsByPurchaseFlags(purchaseItems, ctx),
},
ToHppBahanBakuGroup(budgets, realizations, ctx),
}
summaryHpp := ToSummaryHpp(HPPSummaryLabel, purchaseItems, budgets, realizations, projectFlockCategory, ctx)
return HppPurchasesSection{
Hpp: hppGroups,
SummaryHpp: summaryHpp,
} }
} }
// === PROFIT & LOSS === // ToProfitLossSection creates Profit & Loss section
func ToProfitLossSection(items []ProfitLossItem, summary ProfitLossSummary) ProfitLossSection {
func ToPLItem(itemType string, metrics FinancialMetrics) PLItem {
return PLItem{
Type: itemType,
FinancialMetrics: metrics,
}
}
func ToPLSummaryItem(label string, metrics FinancialMetrics) PLSummaryItem {
return PLSummaryItem{
Label: label,
FinancialMetrics: metrics,
}
}
func createPLItemWithMetrics(itemType string, amount float64, ctx CalculationContext) PLItem {
rpPerBird, rpPerKg := calculatePerUnitMetrics(amount, ctx.ActualPopulation, ctx.TotalWeightProduced)
return ToPLItem(itemType, ToFinancialMetrics(rpPerBird, rpPerKg, amount))
}
func sumPLItems(items []PLItem) (totalAmount, totalPerBird float64) {
for _, item := range items {
totalAmount += item.Amount
totalPerBird += item.RpPerBird
}
return
}
func createPenjualanItem(salesType string, amount float64, ctx CalculationContext) PLItem {
rpPerBird, rpPerKg := calculatePerUnitMetrics(amount, ctx.ActualPopulation, ctx.TotalWeightSold)
return ToPLItem(salesType, ToFinancialMetrics(rpPerBird, rpPerKg, amount))
}
func ToPenjualanItems(projectFlockCategory string, deliveryProducts []entities.MarketingDeliveryProduct, ctx CalculationContext) []PLItem {
items := []PLItem{}
categorized := categorizeDeliveriesBySalesType(deliveryProducts)
if projectFlockCategory == string(utils.ProjectFlockCategoryLaying) {
ayamAmount := sumDeliveriesByCategory(categorized[PLSalesTypeChicken])
telurAmount := sumDeliveriesByCategory(categorized[PLSalesTypeEgg])
items = append(items, createPenjualanItem(PLSalesTypeChicken, ayamAmount, ctx))
items = append(items, createPenjualanItem(PLSalesTypeEgg, telurAmount, ctx))
} else {
ayamAmount := sumDeliveriesByCategory(categorized[PLSalesTypeChicken])
items = append(items, createPenjualanItem(PLSalesTypeChicken, ayamAmount, ctx))
}
return items
}
func ToPembelianItems(purchases []entities.PurchaseItem, realizations []entities.ExpenseRealization, ctx CalculationContext) []PLItem {
purchaseAmount := sumPurchaseTotal(purchases)
return []PLItem{
createPLItemWithMetrics(PLItemTypeSapronak, purchaseAmount, ctx),
}
}
func ToOverheadItems(realizations []entities.ExpenseRealization, ctx CalculationContext) []PLItem {
realizationAmount := getOperationalExpenses(realizations)
return []PLItem{
createPLItemWithMetrics(PLItemTypeOverhead, realizationAmount, ctx),
}
}
func ToEkspedisiItems(realizations []entities.ExpenseRealization, ctx CalculationContext) []PLItem {
amount := sumRealizationsByFilter(realizations, filterRealizationByNonstockFlag(utils.FlagEkspedisi))
return []PLItem{
createPLItemWithMetrics(PLItemTypeEkspedisi, amount, ctx),
}
}
func ToPLSummaryGroup(penjualanItems, pembelianItems, overheadItems, ekspedisiItems []PLItem) PLSummaryGroup {
totalPenjualan, totalPenjualanPerBird := sumPLItems(penjualanItems)
totalPembelian, totalPembelianPerBird := sumPLItems(pembelianItems)
totalOverhead, totalOverheadPerBird := sumPLItems(overheadItems)
totalEkspedisi, totalEkspedisiPerBird := sumPLItems(ekspedisiItems)
grossProfit := totalPenjualan - totalPembelian
grossProfitPerBird := totalPenjualanPerBird - totalPembelianPerBird
totalOtherExpenses := totalOverhead + totalEkspedisi
totalOtherExpensesPerBird := totalOverheadPerBird + totalEkspedisiPerBird
netProfit := grossProfit - totalOtherExpenses
netProfitPerBird := grossProfitPerBird - totalOtherExpensesPerBird
return PLSummaryGroup{
GrossProfit: ToPLSummaryItem(PLSummaryLabelGrossProfit, ToFinancialMetrics(grossProfitPerBird, 0, grossProfit)),
SubTotal: ToPLSummaryItem(PLSummaryLabelSubTotal, ToFinancialMetrics(totalOtherExpensesPerBird, 0, totalOtherExpenses)),
NetProfit: ToPLSummaryItem(PLSummaryLabelNetProfit, ToFinancialMetrics(netProfitPerBird, 0, netProfit)),
}
}
func ToProfitLossData(penjualanItems, pembelianItems, overheadItems, ekspedisiItems []PLItem) ProfitLossData {
summary := ToPLSummaryGroup(penjualanItems, pembelianItems, overheadItems, ekspedisiItems)
totalOverhead := aggregatePLItems(overheadItems, PLItemTypeOverhead)
totalEkspedisi := aggregatePLItems(ekspedisiItems, PLItemTypeEkspedisi)
return ProfitLossData{
Penjualan: penjualanItems,
Pembelian: pembelianItems,
Overhead: totalOverhead,
Ekspedisi: totalEkspedisi,
Summary: summary,
}
}
func ToProfitLossSection(penjualanItems, pembelianItems, overheadItems, ekspedisiItems []PLItem) ProfitLossSection {
return ProfitLossSection{ return ProfitLossSection{
Data: ToProfitLossData(penjualanItems, pembelianItems, overheadItems, ekspedisiItems), Items: items,
Summary: summary,
} }
} }
func aggregatePLItems(items []PLItem, label string) PLItem { // ToClosingKeuanganData creates complete closing keuangan data
totalAmount, totalPerBird := sumPLItems(items) func ToClosingKeuanganData(hpp HPPSection, profitLoss ProfitLossSection) ClosingKeuanganData {
return ToPLItem(label, ToFinancialMetrics(totalPerBird, 0, totalAmount)) return ClosingKeuanganData{
} HPP: hpp,
ProfitLoss: profitLoss,
func ToReportResponse(hppPurchases HppPurchasesSection, profitLoss ProfitLossSection) ReportResponse {
return ReportResponse{
HppPurchases: hppPurchases,
ProfitLoss: profitLoss,
} }
} }
func ToClosingKeuanganReport(input ClosingKeuanganInput) ReportResponse { // ToSuccessClosingKeuanganResponse creates success response
var totalPopulation float64 func ToSuccessClosingKeuanganResponse(data ClosingKeuanganData) ClosingKeuanganResponse {
var totalWeightSold float64 return ClosingKeuanganResponse{
Code: 200,
for _, chickin := range input.Chickins { Status: "success",
totalPopulation += chickin.UsageQty Message: "Get closing keuangan successfully",
} Data: data,
for _, delivery := range input.DeliveryProducts {
totalWeightSold += delivery.TotalWeight
}
ctx := CalculationContext{
TotalPopulation: totalPopulation,
TotalWeightProduced: input.TotalWeightProduced,
TotalEggWeightKg: input.TotalEggWeightKg,
TotalDepletion: input.TotalDepletion,
TotalWeightSold: totalWeightSold,
ActualPopulation: totalPopulation - input.TotalDepletion,
}
hppSection := ToHppPurchasesSection(input.PurchaseItems, input.Budgets, input.Realizations, input.ProjectFlockCategory, ctx)
penjualanItems := ToPenjualanItems(input.ProjectFlockCategory, input.DeliveryProducts, ctx)
pembelianItems := ToPembelianItems(input.PurchaseItems, input.Realizations, ctx)
overheadItems := ToOverheadItems(input.Realizations, ctx)
ekspedisiItems := ToEkspedisiItems(input.Realizations, ctx)
plSection := ToProfitLossSection(penjualanItems, pembelianItems, overheadItems, ekspedisiItems)
return ToReportResponse(hppSection, plSection)
}
// === HELPER FUNCTIONS ===
func calculatePerUnitMetrics(amount, totalPopulation, totalWeightSold float64) (rpPerBird, rpPerKg float64) {
if totalPopulation > 0 {
rpPerBird = amount / totalPopulation
}
if totalWeightSold > 0 {
rpPerKg = amount / totalWeightSold
}
return rpPerBird, rpPerKg
}
func hasProductFlag(flags []entities.Flag, flagType utils.FlagType) bool {
for _, flag := range flags {
if strings.ToUpper(flag.Name) == string(flagType) {
return true
}
}
return false
}
func filterByPurchaseFlag(flagType utils.FlagType) func(*entities.PurchaseItem) bool {
return func(item *entities.PurchaseItem) bool {
if item.Product == nil || len(item.Product.Flags) == 0 {
return false
}
return hasProductFlag(item.Product.Flags, flagType)
} }
} }
func filterRealizationByNonstockFlag(flagType utils.FlagType) func(*entities.ExpenseRealization) bool {
return func(realization *entities.ExpenseRealization) bool {
if realization.ExpenseNonstock == nil || realization.ExpenseNonstock.Nonstock == nil {
return false
}
return hasProductFlag(realization.ExpenseNonstock.Nonstock.Flags, flagType)
}
}
func filterRealizationExceptFlag(flagType utils.FlagType) func(*entities.ExpenseRealization) bool {
hasFlag := filterRealizationByNonstockFlag(flagType)
return func(realization *entities.ExpenseRealization) bool {
return !hasFlag(realization)
}
}
func sumByFilter[T any](items []T, extractor func(*T) float64, filter func(*T) bool) float64 {
amount := 0.0
for i := range items {
if filter(&items[i]) {
amount += extractor(&items[i])
}
}
return amount
}
func sumPurchasesByFilter(purchases []entities.PurchaseItem, filter func(*entities.PurchaseItem) bool) float64 {
return sumByFilter(purchases, func(p *entities.PurchaseItem) float64 { return p.TotalPrice }, filter)
}
func sumPurchasesByFlag(purchases []entities.PurchaseItem, flagType utils.FlagType) float64 {
return sumPurchasesByFilter(purchases, filterByPurchaseFlag(flagType))
}
func sumPurchaseTotal(purchases []entities.PurchaseItem) float64 {
return sumByFilter(purchases, func(p *entities.PurchaseItem) float64 { return p.TotalPrice }, func(*entities.PurchaseItem) bool { return true })
}
func sumBudgetsByFilter(budgets []entities.ProjectBudget, filter func(*entities.ProjectBudget) bool) float64 {
return sumByFilter(budgets, func(b *entities.ProjectBudget) float64 { return b.Price * b.Qty }, filter)
}
func sumRealizationsByFilter(realizations []entities.ExpenseRealization, filter func(*entities.ExpenseRealization) bool) float64 {
return sumByFilter(realizations, func(r *entities.ExpenseRealization) float64 { return r.Price * r.Qty }, filter)
}
func getOperationalExpenses(realizations []entities.ExpenseRealization) float64 {
return sumRealizationsByFilter(realizations, filterRealizationExceptFlag(utils.FlagEkspedisi))
}
func isChickenProductFlag(flagType utils.FlagType) bool {
switch flagType {
case utils.FlagDOC, utils.FlagPullet, utils.FlagLayer,
utils.FlagAyamAfkir, utils.FlagAyamCulling, utils.FlagAyamMati:
return true
}
return false
}
func isEggProductFlag(flagType utils.FlagType) bool {
switch flagType {
case utils.FlagTelur, utils.FlagTelurUtuh, utils.FlagTelurPecah,
utils.FlagTelurPutih, utils.FlagTelurRetak:
return true
}
return false
}
func getSalesTypeFromProductFlags(product *entities.Product) string {
if product == nil || len(product.Flags) == 0 {
return PLSalesTypeChicken
}
for _, flag := range product.Flags {
flagType := utils.FlagType(strings.ToUpper(flag.Name))
if isEggProductFlag(flagType) {
return PLSalesTypeEgg
}
if isChickenProductFlag(flagType) {
return PLSalesTypeChicken
}
}
return PLSalesTypeChicken
}
func categorizeDeliveriesBySalesType(deliveries []entities.MarketingDeliveryProduct) map[string][]entities.MarketingDeliveryProduct {
categorized := make(map[string][]entities.MarketingDeliveryProduct)
for _, delivery := range deliveries {
product := delivery.MarketingProduct.ProductWarehouse.Product
salesType := getSalesTypeFromProductFlags(&product)
categorized[salesType] = append(categorized[salesType], delivery)
}
return categorized
}
func sumDeliveriesByCategory(deliveries []entities.MarketingDeliveryProduct) float64 {
amount := 0.0
for _, delivery := range deliveries {
amount += delivery.TotalPrice
}
return amount
}
@@ -12,30 +12,44 @@ import (
// === 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"`
DoNumber string `json:"do_number"` Week int `json:"week"`
Product *productDTO.ProductRelationDTO `json:"product,omitempty"` DoNumber string `json:"do_number"`
Customer *customerDTO.CustomerRelationDTO `json:"customer,omitempty"` Product *productDTO.ProductRelationDTO `json:"product,omitempty"`
Qty float64 `json:"qty"` Customer *customerDTO.CustomerRelationDTO `json:"customer,omitempty"`
Weight float64 `json:"weight"` Qty float64 `json:"qty"`
AvgWeight float64 `json:"avg_weight"` Weight float64 `json:"weight"`
Price float64 `json:"price"` AvgWeight float64 `json:"avg_weight"`
TotalPrice float64 `json:"total_price"` SalesPrice float64 `json:"sales_price"`
Kandang *kandangDTO.KandangRelationDTO `json:"kandang,omitempty"` TotalSalesPrice float64 `json:"total_sales_price"`
PaymentStatus string `json:"payment_status"` ActualPrice float64 `json:"actual_price"`
TotalActualPrice float64 `json:"total_actual_price"`
Kandang *kandangDTO.KandangRelationDTO `json:"kandang,omitempty"`
}
type SummaryDTO struct {
TotalSalesPrice float64 `json:"total_sales_price"`
AvgSalesPrice float64 `json:"avg_sales_price"`
TotalActualPrice float64 `json:"total_actual_price"`
AvgActualPrice float64 `json:"avg_actual_price"`
} }
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 {
age := calculateAgeFromChickin(e.MarketingProduct.ProductWarehouse.ProjectFlockKandang, e.DeliveryDate) productFlags := make([]string, len(e.MarketingProduct.ProductWarehouse.Product.Flags))
for i, f := range e.MarketingProduct.ProductWarehouse.Product.Flags {
productFlags[i] = f.Name
}
ageInDay, ageInWeeks := calculateAgeFromChickin(e.MarketingProduct.ProductWarehouse.ProjectFlockKandang, e.DeliveryDate, productFlags)
var product *productDTO.ProductRelationDTO var product *productDTO.ProductRelationDTO
if e.MarketingProduct.ProductWarehouse.Product.Id != 0 { if e.MarketingProduct.ProductWarehouse.Product.Id != 0 {
@@ -63,19 +77,42 @@ 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: age, Age: ageInDay,
DoNumber: doNumber, Week: ageInWeeks,
Product: product, DoNumber: doNumber,
Customer: customer, Product: product,
Qty: e.UsageQty, Customer: customer,
Weight: e.TotalWeight, Qty: e.UsageQty,
AvgWeight: e.AvgWeight, Weight: e.TotalWeight,
Price: e.UnitPrice, AvgWeight: e.AvgWeight,
TotalPrice: e.TotalPrice, SalesPrice: e.MarketingProduct.UnitPrice,
Kandang: kandang, TotalSalesPrice: e.MarketingProduct.TotalPrice,
PaymentStatus: "Paid", ActualPrice: e.UnitPrice,
TotalActualPrice: e.TotalPrice,
Kandang: kandang,
}
}
func ToSummaryDto(e []entity.MarketingDeliveryProduct) SummaryDTO {
var totalSalesPrice, totalActualPrice, sumSales, sumActual float64
count := len(e)
for _, item := range e {
totalSalesPrice += item.MarketingProduct.TotalPrice
totalActualPrice += item.TotalPrice
sumSales += item.MarketingProduct.UnitPrice
sumActual += item.UnitPrice
}
return SummaryDTO{
TotalSalesPrice: totalSalesPrice,
TotalActualPrice: totalActualPrice,
AvgSalesPrice: sumSales / float64(count),
AvgActualPrice: sumActual / float64(count),
} }
} }
@@ -87,28 +124,22 @@ func ToSalesDTOs(e []entity.MarketingDeliveryProduct) []SalesDTO {
return result return result
} }
func ToPenjualanRealisasiResponseDTO(projectFlockID uint, e []entity.MarketingDeliveryProduct) PenjualanRealisasiResponseDTO { func ToPenjualanRealisasiResponseDTO(e []entity.MarketingDeliveryProduct) PenjualanRealisasiResponseDTO {
return PenjualanRealisasiResponseDTO{ return PenjualanRealisasiResponseDTO{
Sales: ToSalesDTOs(e),
Sales: ToSalesDTOs(e), Summary: ToSummaryDto(e),
} }
} }
func extractPeriodFromRealisasi(realisasi []entity.MarketingDeliveryProduct) int { func calculateAgeFromChickin(projectFlockKandang *entity.ProjectFlockKandang, deliveryDate *time.Time, productFlags []string) (int, int) {
if len(realisasi) > 0 {
for _, item := range realisasi {
if item.MarketingProduct.ProductWarehouse.ProjectFlockKandang != nil {
return item.MarketingProduct.ProductWarehouse.ProjectFlockKandang.Period
}
}
}
return 0
}
func calculateAgeFromChickin(projectFlockKandang *entity.ProjectFlockKandang, deliveryDate *time.Time) int {
if projectFlockKandang == nil || deliveryDate == nil || len(projectFlockKandang.Chickins) == 0 { if projectFlockKandang == nil || deliveryDate == nil || len(projectFlockKandang.Chickins) == 0 {
return 0 return 0, 0
}
for _, flag := range productFlags {
if flag == "OVK" || flag == "PAKAN" {
return 0, 0 //
}
} }
earliestChickinDate := projectFlockKandang.Chickins[0].ChickInDate earliestChickinDate := projectFlockKandang.Chickins[0].ChickInDate
@@ -118,7 +149,16 @@ func calculateAgeFromChickin(projectFlockKandang *entity.ProjectFlockKandang, de
} }
} }
ageInDays := int(deliveryDate.Sub(earliestChickinDate).Hours() / 24) diff := deliveryDate.Sub(earliestChickinDate)
ageInWeeks := ageInDays / 7 ageInDays := int(diff.Hours() / 24)
return ageInWeeks
var ageInWeeks int
if ageInDays <= 0 {
ageInWeeks = 0
} else {
ageInWeeks = ((ageInDays - 1) / 7) + 1
}
return ageInDays, ageInWeeks
} }
@@ -114,6 +114,17 @@ 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) SapronakProjectAggregatedDTO { func ToSapronakProjectAggregatedFromReports(reports []SapronakReportDTO, flag string) SapronakProjectAggregatedDTO {
@@ -185,7 +196,7 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
} }
for idx, item := range group.Items { for idx, item := range group.Items {
productKey := strings.ToUpper(flagKey + "|" + item.ProductName) productKey := strings.ToUpper(flagKey + "|" + item.ProductName + "|" + item.NoReferensi + "|" + formatDate(item.Tanggal))
baseRow := SapronakCategoryRowDTO{ baseRow := SapronakCategoryRowDTO{
ID: idx + 1, ID: idx + 1,
Date: formatDate(item.Tanggal), Date: formatDate(item.Tanggal),
@@ -201,18 +212,48 @@ 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
row.TotalAmount += item.Nilai 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", "adjustment keluar": case "pemakaian", "adjustment keluar":
price := row.UnitPrice
if price == 0 {
price = item.Harga
}
row.QtyUsed += item.QtyKeluar row.QtyUsed += item.QtyKeluar
case "mutasi keluar": row.TotalAmount += item.QtyKeluar * price
case "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
} }
} }
@@ -233,8 +274,8 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
total += r.TotalAmount total += r.TotalAmount
} }
avg := 0.0 avg := 0.0
if qtyIn > 0 { if qtyUsed > 0 {
avg = total / qtyIn avg = total / qtyUsed
} }
cat.Total = SapronakCategoryTotalDTO{ cat.Total = SapronakCategoryTotalDTO{
Label: label, Label: label,
+3 -1
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)
@@ -41,8 +42,9 @@ func (ClosingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *
approvalService := commonSvc.NewApprovalService(approvalRepo) approvalService := commonSvc.NewApprovalService(approvalRepo)
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(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)
ClosingRoutes(router, userService, closingService, sapronakService) ClosingRoutes(router, userService, closingService, sapronakService, closingKeuanganService)
} }
@@ -17,6 +17,7 @@ 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)
@@ -31,9 +32,10 @@ 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)
GetActualUsageCostByProjectFlockID(ctx context.Context, projectFlockID uint) ([]ActualUsageCostRow, error) FetchSapronakSales(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)
} }
@@ -60,10 +62,18 @@ 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"`
@@ -75,6 +85,7 @@ 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) {
@@ -110,14 +121,36 @@ 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", unionSQL) countSQL := fmt.Sprintf("SELECT COUNT(*) FROM (%s) AS combined%s", unionSQL, searchClause)
if err := db.Raw(countSQL, args...).Scan(&totalResults).Error; err != nil { countArgs := append(append([]any{}, args...), searchArgs...)
if err := db.Raw(countSQL, countArgs...).Scan(&totalResults).Error; err != nil {
return nil, 0, err return nil, 0, err
} }
dataArgs := append(append([]any{}, args...), params.Limit, params.Offset) dataArgs := append(append([]any{}, args...), searchArgs...)
dataSQL := fmt.Sprintf("SELECT * FROM (%s) AS combined ORDER BY sort_date ASC, id ASC LIMIT ? OFFSET ?", unionSQL) dataArgs = append(dataArgs, params.Limit, params.Offset)
dataSQL := fmt.Sprintf("SELECT * FROM (%s) AS combined%s ORDER BY sort_date ASC, id ASC LIMIT ? OFFSET ?", unionSQL, searchClause)
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 {
@@ -127,6 +160,79 @@ 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)
args = append(args, params.WarehouseIDs, params.WarehouseIDs)
case validation.SapronakTypeOutgoing:
if len(params.WarehouseIDs) > 0 {
unionParts = append(unionParts, sapronakOutgoingTransfersSQL)
args = append(args, params.WarehouseIDs)
}
if len(params.ProjectFlockKandangIDs) > 0 {
unionParts = append(unionParts, sapronakOutgoingMarketingsSQL)
args = append(args, params.ProjectFlockKandangIDs)
}
if len(unionParts) == 0 {
return []SapronakSummaryRow{}, nil
}
default:
return nil, fmt.Errorf("invalid sapronak type: %s", params.Type)
}
unionSQL := strings.Join(unionParts, " UNION ALL ")
search := strings.TrimSpace(params.Search)
searchClause := ""
var searchArgs []any
if search != "" {
searchClause = `
WHERE (
reference_number ILIKE ?
OR product_name ILIKE ?
OR product_category ILIKE ?
OR source_warehouse ILIKE ?
OR destination_warehouse ILIKE ?
OR CAST(quantity AS TEXT) ILIKE ?
OR unit ILIKE ?
OR notes ILIKE ?
OR transaction_type ILIKE ?
)`
like := "%" + search + "%"
searchArgs = append(searchArgs, like, like, like, like, like, like, like, like, like)
}
querySQL := fmt.Sprintf(`
SELECT
product_category AS category,
CAST(COALESCE(SUM(quantity), 0) AS BIGINT) AS total_qty,
unit_id AS uom_id,
unit AS uom_name
FROM (%s) AS combined%s
GROUP BY product_category, unit_id, unit
ORDER BY product_category ASC, unit ASC
`, unionSQL, searchClause)
queryArgs := append(append([]any{}, args...), searchArgs...)
var rows []SapronakSummaryRow
if err := db.Raw(querySQL, queryArgs...).Scan(&rows).Error; err != nil {
return nil, err
}
return rows, nil
}
func (r *ClosingRepositoryImpl) SumFeedPurchaseAndUsedByProjectFlockKandangIDs(ctx context.Context, projectFlockKandangIDs []uint) (float64, float64, error) { 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
@@ -318,6 +424,7 @@ func (r *ClosingRepositoryImpl) GetExpeditionHPP(ctx context.Context, projectFlo
Joins("JOIN suppliers s ON s.id = e.supplier_id"). Joins("JOIN suppliers s ON s.id = e.supplier_id").
Where("pfk.project_flock_id = ?", projectFlockID). Where("pfk.project_flock_id = ?", projectFlockID).
Where("e.category = ?", "BOP"). Where("e.category = ?", "BOP").
Where("e.realization_date IS NOT NULL").
Where("UPPER(f.name) = ?", strings.ToUpper(string(utils.FlagEkspedisi))) Where("UPPER(f.name) = ?", strings.ToUpper(string(utils.FlagEkspedisi)))
if projectFlockKandangID != nil && *projectFlockKandangID != 0 { if projectFlockKandangID != nil && *projectFlockKandangID != 0 {
@@ -379,6 +486,7 @@ 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
@@ -427,6 +535,7 @@ 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,
'Stock Refill' AS notes 'Stock Refill' AS notes
FROM stock_transfer_details std FROM stock_transfer_details std
@@ -473,9 +582,10 @@ 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,
COALESCE(fw.name, '') AS source_warehouse, COALESCE(fw.name, '') AS source_warehouse,
'' AS destination_warehouse, COALESCE(tw.name, '') AS destination_warehouse,
COALESCE(tw.name, '') 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,
'Transfer to other unit' AS notes 'Transfer to other unit' AS notes
FROM stock_transfer_details std FROM stock_transfer_details std
@@ -522,18 +632,27 @@ 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,
'' AS destination_warehouse, COALESCE(c.name, '') AS destination_warehouse,
'RETAIL CUSTOMER' 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
JOIN warehouses w ON w.id = pw.warehouse_id JOIN warehouses w ON w.id = pw.warehouse_id
WHERE pw.project_flock_kandang_id IN ? WHERE pw.project_flock_kandang_id IN ?
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')
)
` `
) )
@@ -754,6 +873,74 @@ func (r *ClosingRepositoryImpl) FetchSapronakChickinUsageDetails(ctx context.Con
) )
} }
func (r *ClosingRepositoryImpl) FetchSapronakUsageAllocatedDetails(ctx context.Context, projectFlockKandangID uint) (map[uint][]SapronakDetailRow, error) {
if projectFlockKandangID == 0 {
return map[uint][]SapronakDetailRow{}, nil
}
query := r.withCtx(ctx).
Table("stock_allocations AS sa").
Select(`
pw.product_id AS product_id,
p.name AS product_name,
f.name AS flag,
COALESCE(
pi.received_date,
st.transfer_date,
lt.transfer_date,
sl.created_at,
pc.chick_in_date,
r.record_datetime
) AS date,
COALESCE(
po.po_number,
st.movement_number,
lt.transfer_number,
CONCAT('ADJ-', ast.id),
CONCAT('CHICKIN-', pc.id),
CAST(r.id AS TEXT),
''
) AS reference,
0 AS qty_in,
COALESCE(SUM(sa.qty), 0) AS qty_out,
COALESCE(pi.price, p.product_price, 0) AS price
`).
Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id").
Joins("JOIN products p ON p.id = pw.product_id").
Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct).
Joins("LEFT JOIN recording_stocks rs ON rs.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyRecordingStock.String()).
Joins("LEFT JOIN recordings r ON r.id = rs.recording_id").
Joins("LEFT JOIN project_chickins pc_used ON pc_used.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyProjectChickin.String()).
Joins("LEFT JOIN purchase_items pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyPurchaseItems.String()).
Joins("LEFT JOIN purchases po ON po.id = pi.purchase_id").
Joins("LEFT JOIN stock_transfer_details std ON std.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyStockTransferIn.String()).
Joins("LEFT JOIN stock_transfers st ON st.id = std.stock_transfer_id").
Joins("LEFT JOIN laying_transfer_targets ltt ON ltt.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyTransferToLayingIn.String()).
Joins("LEFT JOIN laying_transfers lt ON lt.id = ltt.laying_transfer_id").
Joins("LEFT JOIN adjustment_stocks ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyAdjustmentIn.String()).
Joins("LEFT JOIN stock_logs sl ON sl.id = ast.stock_log_id").
Joins("LEFT JOIN project_flock_populations pfp ON pfp.id = sa.stockable_id AND sa.stockable_type = ?", fifo.StockableKeyProjectFlockPopulation.String()).
Joins("LEFT JOIN project_chickins pc ON pc.id = pfp.project_chickin_id").
Where("sa.status = ?", entity.StockAllocationStatusActive).
Where("f.name IN ?", sapronakFlagsAll).
Where(`
(sa.usable_type = ? AND r.project_flock_kandangs_id = ?)
OR
(sa.usable_type = ? AND pc_used.project_flock_kandang_id = ?)
`,
fifo.UsableKeyRecordingStock.String(), projectFlockKandangID,
fifo.UsableKeyProjectChickin.String(), projectFlockKandangID,
).
Group(`
pw.product_id, p.name, f.name,
pi.received_date, st.transfer_date, lt.transfer_date, sl.created_at, pc.chick_in_date, r.record_datetime,
po.po_number, st.movement_number, lt.transfer_number, ast.id, pc.id, r.id,
pi.price, p.product_price
`)
return scanAndGroupDetails(query)
}
func (r *ClosingRepositoryImpl) incomingPurchaseBase(ctx context.Context, kandangID uint) *gorm.DB { func (r *ClosingRepositoryImpl) incomingPurchaseBase(ctx context.Context, kandangID uint) *gorm.DB {
return r.withCtx(ctx). return r.withCtx(ctx).
Table("purchase_items AS pi"). Table("purchase_items AS pi").
@@ -889,143 +1076,196 @@ func (r *ClosingRepositoryImpl) FetchSapronakAdjustments(ctx context.Context, ka
} }
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) {
rows, err := r.fetchStockLogs(ctx, kandangID, string(utils.StockLogTypeTransfer), true) incomingQuery := r.withCtx(ctx).
Table("stock_transfer_details AS std").
Select(`
std.product_id AS product_id,
p.name AS product_name,
f.name AS flag,
st.transfer_date::timestamp AS date,
COALESCE(st.movement_number, '') AS reference,
COALESCE(std.total_qty, 0) AS qty_in,
0 AS qty_out,
COALESCE(p.product_price, 0) AS price
`).
Joins("JOIN stock_transfers st ON st.id = std.stock_transfer_id").
Joins("LEFT JOIN warehouses fw ON fw.id = st.from_warehouse_id").
Joins("JOIN product_warehouses pw ON pw.id = std.dest_product_warehouse_id").
Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
Joins("JOIN products p ON p.id = std.product_id").
Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct).
Where("w.kandang_id = ?", kandangID).
Where("(fw.kandang_id IS NULL OR fw.kandang_id <> w.kandang_id)").
Where("f.name IN ?", sapronakFlagsAll)
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 {
if ref := strings.TrimSpace(row.MovementNumber); ref != "" {
return ref
}
return fmt.Sprintf("TRF-%d", row.ID)
})
return in, out, nil
}
type ActualUsageCostRow struct { incomingLayingQuery := r.withCtx(ctx).
ProductID uint `gorm:"column:product_id"` Table("laying_transfer_targets AS ltt").
ProductName string `gorm:"column:product_name"` Select(`
FlagName string `gorm:"column:flag_name"` pw.product_id AS product_id,
TotalQty float64 `gorm:"column:total_qty"` p.name AS product_name,
TotalPrice float64 `gorm:"column:total_price"` f.name AS flag,
AveragePrice float64 `gorm:"column:average_price"` lt.transfer_date::timestamp AS date,
} COALESCE(lt.transfer_number, '') AS reference,
COALESCE(ltt.total_qty, 0) AS qty_in,
func (r *ClosingRepositoryImpl) GetActualUsageCostByProjectFlockID(ctx context.Context, projectFlockID uint) ([]ActualUsageCostRow, error) { 0 AS qty_out,
if projectFlockID == 0 { COALESCE(p.product_price, 0) AS price
return []ActualUsageCostRow{}, nil `).
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").
Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct).
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)
incomingLaying, err := scanAndGroupDetails(incomingLayingQuery)
if err != nil {
return nil, nil, err
}
for pid, rows := range incomingLaying {
incoming[pid] = append(incoming[pid], rows...)
} }
db := r.DB().WithContext(ctx) outgoingQuery := r.withCtx(ctx).
Table("stock_allocations AS sa").
Select(`
std.product_id AS product_id,
p.name AS product_name,
f.name AS flag,
st.transfer_date::timestamp AS date,
COALESCE(st.movement_number, '') AS reference,
0 AS qty_in,
COALESCE(SUM(sa.qty), 0) AS qty_out,
COALESCE(p.product_price, 0) AS price
`).
Joins("JOIN stock_transfer_details std ON std.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyStockTransferOut.String()).
Joins("JOIN stock_transfers st ON st.id = std.stock_transfer_id").
Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id").
Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
Joins("LEFT JOIN product_warehouses pw_dest ON pw_dest.id = std.dest_product_warehouse_id").
Joins("LEFT JOIN warehouses w_dest ON w_dest.id = pw_dest.warehouse_id").
Joins("JOIN products p ON p.id = std.product_id").
Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct).
Where("sa.status = ?", entity.StockAllocationStatusActive).
Where("w.kandang_id = ?", kandangID).
Where("(w_dest.kandang_id IS NULL OR w_dest.kandang_id <> w.kandang_id)").
Where("f.name IN ?", sapronakFlagsAll).
Group("std.id, std.product_id, p.name, f.name, st.transfer_date, st.movement_number, p.product_price")
outgoing, err := scanAndGroupDetails(outgoingQuery)
if err != nil {
return nil, nil, err
}
// Get all project flock kandang IDs for this project flock outgoingLayingQuery := r.withCtx(ctx).
var pfkIDs []uint Table("stock_allocations AS sa").
err := db.Table("project_flock_kandangs"). Select(`
Where("project_flock_id = ?", projectFlockID). pw.product_id AS product_id,
Pluck("id", &pfkIDs).Error 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").
Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct).
Where("sa.status = ?", entity.StockAllocationStatusActive).
Where("w.kandang_id = ?", kandangID).
Where("(w_dest.kandang_id IS NULL OR w_dest.kandang_id <> w.kandang_id)").
Where("f.name IN ?", sapronakFlagsAll).
Group("lts.id, pw.product_id, p.name, f.name, lt.transfer_date, lt.transfer_number, p.product_price")
outgoingLaying, err := scanAndGroupDetails(outgoingLayingQuery)
if err != nil {
return nil, nil, err
}
for pid, rows := range outgoingLaying {
outgoing[pid] = append(outgoing[pid], rows...)
}
return incoming, outgoing, nil
}
func (r *ClosingRepositoryImpl) FetchSapronakSales(ctx context.Context, projectFlockKandangID uint) (map[uint][]SapronakDetailRow, error) {
query := r.withCtx(ctx).
Table("stock_allocations AS sa").
Select(`
pw.product_id AS product_id,
p.name AS product_name,
f.name AS flag,
COALESCE(mdp.delivery_date, mdp.created_at) AS date,
COALESCE(m.so_number, '') AS reference,
0 AS qty_in,
COALESCE(SUM(sa.qty), 0) AS qty_out,
COALESCE(mdp.unit_price, mp.unit_price, 0) AS price
`).
Joins("JOIN marketing_delivery_products mdp ON mdp.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyMarketingDelivery.String()).
Joins("JOIN marketing_products mp ON mp.id = mdp.marketing_product_id").
Joins("JOIN marketings m ON m.id = mp.marketing_id").
Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id").
Joins("JOIN products p ON p.id = pw.product_id").
Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct).
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")
sales, err := scanAndGroupDetails(query)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if len(pfkIDs) == 0 { nonFifoQuery := r.withCtx(ctx).
return []ActualUsageCostRow{}, nil Table("marketing_delivery_products AS mdp").
}
var rows []ActualUsageCostRow
purchaseStockableKey := fifo.StockableKeyPurchaseItems.String()
transferStockableKey := fifo.StockableKeyStockTransferIn.String()
recordingQuery := db.
Table("recordings AS r").
Select(` Select(`
pw.product_id AS product_id, pw.product_id AS product_id,
p.name AS product_name, p.name AS product_name,
COALESCE(f.name, tf.name) AS flag_name, f.name AS flag,
COALESCE(SUM( COALESCE(mdp.delivery_date, mdp.created_at) AS date,
CASE COALESCE(m.so_number, '') AS reference,
WHEN sa.stockable_type = ? THEN COALESCE(sa.qty, 0) 0 AS qty_in,
WHEN sa.stockable_type = ? THEN COALESCE(std.usage_qty, 0) COALESCE(mdp.usage_qty, 0) AS qty_out,
ELSE 0 COALESCE(mdp.unit_price, mp.unit_price, 0) AS price
END
), 0) AS total_qty,
COALESCE(SUM(
CASE
WHEN sa.stockable_type = ? THEN COALESCE(sa.qty, 0) * COALESCE(pi.price, 0)
WHEN sa.stockable_type = ? THEN COALESCE(std.usage_qty, 0) * COALESCE(tpi.price, 0)
ELSE 0
END
), 0) AS total_price,
COALESCE(SUM(
CASE
WHEN sa.stockable_type = ? THEN COALESCE(sa.qty, 0)
WHEN sa.stockable_type = ? THEN COALESCE(std.usage_qty, 0)
ELSE 0
END
), 0) AS qty_divisor,
COALESCE(SUM(
CASE
WHEN sa.stockable_type = ? THEN COALESCE(sa.qty, 0) * COALESCE(pi.price, 0)
WHEN sa.stockable_type = ? THEN COALESCE(std.usage_qty, 0) * COALESCE(tpi.price, 0)
ELSE 0
END
), 0) / NULLIF(COALESCE(SUM(
CASE
WHEN sa.stockable_type = ? THEN COALESCE(sa.qty, 0)
WHEN sa.stockable_type = ? THEN COALESCE(std.usage_qty, 0)
ELSE 0
END
), 0), 0) AS average_price`,
purchaseStockableKey, transferStockableKey,
purchaseStockableKey, transferStockableKey,
purchaseStockableKey, transferStockableKey,
purchaseStockableKey, transferStockableKey,
purchaseStockableKey, transferStockableKey).
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 products AS p ON p.id = pw.product_id").
Joins("LEFT JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.status = ?",
"recording_stocks", entity.StockAllocationStatusActive).
Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", purchaseStockableKey).
Joins("LEFT JOIN stock_transfer_details AS std ON std.id = sa.stockable_id AND sa.stockable_type = ?", transferStockableKey).
Joins("LEFT JOIN stock_transfers AS st ON st.id = std.stock_transfer_id").
Joins("LEFT JOIN purchase_items AS tpi ON tpi.product_id = std.product_id AND tpi.warehouse_id = st.from_warehouse_id").
Joins("LEFT JOIN flags AS f ON f.flagable_id = pi.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct).
Joins("LEFT JOIN flags AS tf ON tf.flagable_id = std.product_id AND tf.flagable_type = ?", entity.FlagableTypeProduct).
Where("r.project_flock_kandangs_id IN ?", pfkIDs).
Where("r.deleted_at IS NULL").
Group("pw.product_id, p.name, COALESCE(f.name, tf.name)")
if err := recordingQuery.Scan(&rows).Error; err != nil {
return nil, err
}
chickinQuery := db.
Table("project_chickins AS pc").
Select(`
pw.product_id AS product_id,
p.name AS product_name,
f.name AS flag_name,
COALESCE(SUM(pc.usage_qty), 0) AS total_qty,
COALESCE(SUM(pc.usage_qty * COALESCE(pi.price, 0)), 0) AS total_price,
COALESCE(AVG(COALESCE(pi.price, 0)), 0) AS average_price
`). `).
Joins("JOIN product_warehouses AS pw ON pw.id = pc.product_warehouse_id"). Joins("JOIN marketing_products mp ON mp.id = mdp.marketing_product_id").
Joins("JOIN products AS p ON p.id = pw.product_id"). Joins("JOIN marketings m ON m.id = mp.marketing_id").
Joins("LEFT JOIN purchase_items AS pi ON pi.product_warehouse_id = pc.product_warehouse_id"). Joins("JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id").
Joins("LEFT JOIN flags AS f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct). Joins("JOIN products p ON p.id = pw.product_id").
Where("pc.project_flock_kandang_id IN ?", pfkIDs). Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct).
Where("pc.usage_qty > 0"). Joins("LEFT JOIN stock_allocations sa ON sa.usable_id = mdp.id AND sa.usable_type = ? AND sa.status = ?",
Group("pw.product_id, p.name, f.name") 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")
var chickinRows []ActualUsageCostRow nonFifoSales, err := scanAndGroupDetails(nonFifoQuery)
if err := chickinQuery.Scan(&chickinRows).Error; err != nil { if err != nil {
return nil, err return nil, err
} }
rows = append(rows, chickinRows...) for pid, rows := range nonFifoSales {
sales[pid] = append(sales[pid], rows...)
}
return rows, nil return sales, nil
} }
func (r *ClosingRepositoryImpl) GetProductsWithFlagsByIDs(ctx context.Context, productIDs []uint) ([]entity.Product, error) { func (r *ClosingRepositoryImpl) GetProductsWithFlagsByIDs(ctx context.Context, productIDs []uint) ([]entity.Product, error) {
@@ -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))
}
+4 -2
View File
@@ -9,8 +9,8 @@ import (
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
) )
func ClosingRoutes(v1 fiber.Router, u user.UserService, s closing.ClosingService, sapronakSvc closing.SapronakService) { func ClosingRoutes(v1 fiber.Router, u user.UserService, s closing.ClosingService, sapronakSvc closing.SapronakService, closingKeuanganSvc closing.ClosingKeuanganService) {
ctrl := controller.NewClosingController(s, sapronakSvc) ctrl := controller.NewClosingController(s, sapronakSvc, closingKeuanganSvc)
route := v1.Group("/closings") route := v1.Group("/closings")
route.Use(m.Auth(u)) route.Use(m.Auth(u))
@@ -30,9 +30,11 @@ 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)
route.Get("/:projectFlockId/keuangan", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetClosingKeuangan) route.Get("/:projectFlockId/keuangan", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetClosingKeuangan)
route.Get("/:project_flock_id/:project_flock_kandang_id/keuangan", m.RequirePermissions(m.P_ClosingDetail), ctrl.GetClosingKeuanganByKandang)
} }
@@ -41,7 +41,7 @@ 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)
GetClosingKeuangan(ctx *fiber.Ctx, projectFlockID uint) (*dto.ReportResponse, 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,16 +99,33 @@ 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)
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+"%")
} }
@@ -347,8 +364,10 @@ func (s closingService) GetClosingSapronak(c *fiber.Ctx, projectFlockID uint, pa
} }
var projectFlockKandangIDs []uint var projectFlockKandangIDs []uint
if params.Type == validation.SapronakTypeOutgoing { if params.KandangID != nil && *params.KandangID > 0 {
projectFlockKandangIDs, err = s.getProjectFlockKandangIDs(c.Context(), projectFlockID, params.KandangID) projectFlockKandangIDs = []uint{*params.KandangID}
} else if params.Type == validation.SapronakTypeOutgoing {
projectFlockKandangIDs, err = s.getProjectFlockKandangIDs(c.Context(), projectFlockID)
if err != nil { if err != nil {
s.Log.Errorf("Failed to fetch project flock kandang IDs for project flock %d: %+v", projectFlockID, err) s.Log.Errorf("Failed to fetch project flock kandang IDs for project flock %d: %+v", projectFlockID, err)
return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandang") return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandang")
@@ -362,6 +381,7 @@ 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)
@@ -396,6 +416,74 @@ 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) {
if projectFlockID == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid project flock id")
}
if params == nil {
params = &validation.ClosingSapronakQuery{}
}
if err := s.Validate.Struct(params); err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, err.Error())
}
if params.Type != validation.SapronakTypeIncoming && params.Type != validation.SapronakTypeOutgoing {
return nil, fiber.NewError(fiber.StatusBadRequest, "type must be either incoming or outgoing")
}
if _, err := s.Repository.GetByID(c.Context(), projectFlockID, nil); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Project flock tidak ditemukan")
}
s.Log.Errorf("Failed get project flock %d for sapronak closing summary: %+v", projectFlockID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock")
}
warehouseIDs, err := s.getWarehouseIDsByProjectFlock(c.Context(), projectFlockID)
if err != nil {
s.Log.Errorf("Failed to fetch warehouses for project flock %d: %+v", projectFlockID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch warehouses for project flock")
}
var projectFlockKandangIDs []uint
if params.KandangID != nil && *params.KandangID > 0 {
projectFlockKandangIDs = []uint{*params.KandangID}
} else if params.Type == validation.SapronakTypeOutgoing {
projectFlockKandangIDs, err = s.getProjectFlockKandangIDs(c.Context(), projectFlockID)
if err != nil {
s.Log.Errorf("Failed to fetch project flock kandang IDs for project flock %d: %+v", projectFlockID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandang")
}
}
rows, err := s.Repository.GetSapronakSummary(c.Context(), repository.SapronakQueryParams{
Type: params.Type,
WarehouseIDs: warehouseIDs,
ProjectFlockKandangIDs: projectFlockKandangIDs,
Search: params.Search,
})
if err != nil {
s.Log.Errorf("Failed to fetch sapronak %s summary for project flock %d: %+v", params.Type, projectFlockID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch sapronak summary data")
}
items := make([]dto.ClosingSapronakSummaryItemDTO, 0, len(rows))
for _, row := range rows {
items = append(items, dto.ClosingSapronakSummaryItemDTO{
Category: row.Category,
TotalQty: row.TotalQty,
Uom: dto.UomSummaryDTO{
ID: row.UomID,
Name: row.UomName,
},
})
}
return items, nil
}
func (s closingService) getWarehouseIDsByProjectFlock(ctx context.Context, projectFlockID uint) ([]uint, error) { func (s closingService) getWarehouseIDsByProjectFlock(ctx context.Context, projectFlockID uint) ([]uint, error) {
var kandangIDs []uint var kandangIDs []uint
db := s.Repository.DB().WithContext(ctx) db := s.Repository.DB().WithContext(ctx)
@@ -428,14 +516,11 @@ func (s closingService) getWarehouseIDsByProjectFlock(ctx context.Context, proje
return ids, nil return ids, nil
} }
func (s closingService) getProjectFlockKandangIDs(ctx context.Context, projectFlockID uint, kandangID *uint) ([]uint, error) { func (s closingService) getProjectFlockKandangIDs(ctx context.Context, projectFlockID uint) ([]uint, error) {
var ids []uint var ids []uint
query := s.Repository.DB().WithContext(ctx). query := s.Repository.DB().WithContext(ctx).
Model(&entity.ProjectFlockKandang{}). Model(&entity.ProjectFlockKandang{}).
Where("project_flock_id = ?", projectFlockID) Where("project_flock_id = ?", projectFlockID)
if kandangID != nil {
query = query.Where("kandang_id = ?", *kandangID)
}
err := query.Order("id ASC").Pluck("id", &ids).Error err := query.Order("id ASC").Pluck("id", &ids).Error
if err != nil { if err != nil {
return nil, err return nil, err
@@ -595,85 +680,6 @@ func (s closingService) GetOverhead(c *fiber.Ctx, projectFlockID uint, projectFl
return &result, nil return &result, nil
} }
func (s closingService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID uint) (*dto.ReportResponse, error) {
if err := m.EnsureProjectFlockAccess(c, s.Repository.DB(), projectFlockID); err != nil {
return nil, err
}
if err := commonSvc.EnsureRelations(c.Context(),
commonSvc.RelationCheck{Name: "Project Flock", ID: &projectFlockID, Exists: s.ProjectFlockRepo.IdExists},
); err != nil {
return nil, err
}
projectFlock, err := s.ProjectFlockRepo.GetByID(c.Context(), projectFlockID, nil)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock")
}
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")
}
actualUsageRows, err := s.Repository.GetActualUsageCostByProjectFlockID(c.Context(), projectFlockID)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch actual usage cost")
}
purchaseItems := s.convertActualUsageToPurchaseItems(c.Context(), actualUsageRows)
realizations, err := s.ExpenseRealizationRepo.GetByProjectFlockID(c.Context(), projectFlockID)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch realizations")
}
deliveryProducts, err := s.MarketingDeliveryProductRepo.GetDeliveryProductsByProjectFlockID(c.Context(), projectFlockID, func(db *gorm.DB) *gorm.DB {
return db.Preload("MarketingProduct").
Preload("MarketingProduct.ProductWarehouse").
Preload("MarketingProduct.ProductWarehouse.Product")
})
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch delivery products")
}
chickins, err := s.ChickinRepo.GetByProjectFlockID(c.Context(), projectFlockID)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch chickins")
}
totalWeightProduced, _, err := s.RecordingRepo.GetProductionWeightAndQtyByProjectFlockID(c.Context(), projectFlockID)
if err != nil {
s.Log.Warnf("GetProductionWeightAndQtyByProjectFlockID error: %v", err)
}
totalEggWeightKg, err := s.RecordingRepo.GetTotalEggProductionWeightByProjectFlockID(c.Context(), projectFlockID)
if err != nil {
s.Log.Warnf("GetTotalEggProductionWeightByProjectFlockID error: %v", err)
}
totalDepletion, err := s.RecordingRepo.GetTotalDepletionByProjectFlockID(c.Context(), projectFlockID)
if err != nil {
s.Log.Warnf("GetTotalDepletionByProjectFlockID error: %v", err)
}
input := dto.ClosingKeuanganInput{
ProjectFlockCategory: projectFlock.Category,
PurchaseItems: purchaseItems,
Budgets: budgets,
Realizations: realizations,
DeliveryProducts: deliveryProducts,
Chickins: chickins,
TotalWeightProduced: totalWeightProduced,
TotalEggWeightKg: totalEggWeightKg,
TotalDepletion: totalDepletion,
}
report := dto.ToClosingKeuanganReport(input)
return &report, nil
}
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 projectFlockKandangID != nil {
if err := m.EnsureProjectFlockKandangAccess(c, s.Repository.DB(), projectFlockID, *projectFlockKandangID); err != nil { if err := m.EnsureProjectFlockKandangAccess(c, s.Repository.DB(), projectFlockID, *projectFlockKandangID); err != nil {
@@ -711,14 +717,20 @@ func (s closingService) GetExpeditionHPP(c *fiber.Ctx, projectFlockID uint, proj
} }
func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint, kandangID *uint) (*dto.ClosingProductionReportDTO, error) { func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint, kandangID *uint) (*dto.ClosingProductionReportDTO, 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")
} }
projectFlockKandangIDs, err := s.getProjectFlockKandangIDs(c.Context(), projectFlockID, kandangID) var projectFlockKandangIDs []uint
if err != nil { if kandangID != nil && *kandangID > 0 {
s.Log.Errorf("Failed to fetch project flock kandangs for %d: %+v", projectFlockID, err) projectFlockKandangIDs = []uint{*kandangID}
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandangs") } else {
var err error
projectFlockKandangIDs, err = s.getProjectFlockKandangIDs(c.Context(), projectFlockID)
if err != nil {
s.Log.Errorf("Failed to fetch project flock kandangs for %d: %+v", projectFlockID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandangs")
}
} }
if len(projectFlockKandangIDs) == 0 { if len(projectFlockKandangIDs) == 0 {
@@ -748,6 +760,10 @@ 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)
@@ -957,19 +973,19 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint
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 performance.DeffFcr = performance.FcrStd - performance.FcrAct
@@ -979,16 +995,16 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint
} }
if !isGrowing { if !isGrowing {
if productionStandardDetail.TargetHenDayProduction != nil { if productionStandardDetail.TargetHenDayProduction != nil {
performance.HendayStd = productionStandardDetail.TargetHenDayProduction performance.HendayStd = *productionStandardDetail.TargetHenDayProduction
} }
if productionStandardDetail.TargetHenHouseProduction != nil { if productionStandardDetail.TargetHenHouseProduction != nil {
performance.HenHouseStd = productionStandardDetail.TargetHenHouseProduction performance.HenHouseStd = *productionStandardDetail.TargetHenHouseProduction
} }
if productionStandardDetail.TargetEggWeight != nil { if productionStandardDetail.TargetEggWeight != nil {
performance.EggWeightStd = productionStandardDetail.TargetEggWeight performance.EggWeightStd = *productionStandardDetail.TargetEggWeight
} }
if productionStandardDetail.TargetEggMass != nil { if productionStandardDetail.TargetEggMass != nil {
performance.EggMassStd = productionStandardDetail.TargetEggMass performance.EggMassStd = *productionStandardDetail.TargetEggMass
} }
} }
} }
@@ -1127,53 +1143,3 @@ func closestFcrValues(standards []entity.FcrStandard, averageWeight float64) (fl
return closest.Mortality, closest.FcrNumber return closest.Mortality, closest.FcrNumber
} }
func (s closingService) convertActualUsageToPurchaseItems(ctx context.Context, actualUsageRows []repository.ActualUsageCostRow) []entity.PurchaseItem {
if len(actualUsageRows) == 0 {
return []entity.PurchaseItem{}
}
// Collect all product IDs
productIDs := make([]uint, len(actualUsageRows))
for i, row := range actualUsageRows {
productIDs[i] = row.ProductID
}
// Fetch products with flags from repository
products, err := s.Repository.GetProductsWithFlagsByIDs(ctx, productIDs)
if err != nil {
s.Log.Warnf("Failed to fetch products for actual usage: %v", err)
products = []entity.Product{}
}
// Create product map
productMap := make(map[uint]*entity.Product)
for i := range products {
productMap[products[i].Id] = &products[i]
}
// Convert to pseudo purchase items
purchaseItems := make([]entity.PurchaseItem, 0, len(actualUsageRows))
for _, row := range actualUsageRows {
product := productMap[row.ProductID]
// Skip if product not found
if product == nil {
s.Log.Warnf("Product ID %d not found for actual usage", row.ProductID)
continue
}
purchaseItem := entity.PurchaseItem{
Id: 0, // Pseudo item, no ID
ProductId: row.ProductID,
TotalQty: row.TotalQty,
TotalPrice: row.TotalPrice,
Price: row.AveragePrice,
Product: product,
}
purchaseItems = append(purchaseItems, purchaseItem)
}
return purchaseItems
}
@@ -0,0 +1,640 @@
package service
import (
"errors"
"strings"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/modules/closings/dto"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/closings/repositories"
expenseRealizationRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories"
marketingDeliveryProductRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories"
chickinRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories"
projectflockRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
recordingRepository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"github.com/gofiber/fiber/v2"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
)
// ClosingKeuanganService handles closing keuangan business logic
type ClosingKeuanganService interface {
GetClosingKeuangan(ctx *fiber.Ctx, projectFlockID uint) (*dto.ClosingKeuanganData, error)
GetClosingKeuanganByKandang(ctx *fiber.Ctx, projectFlockID uint, projectFlockKandangID uint) (*dto.ClosingKeuanganData, error)
}
type closingKeuanganService struct {
Log *logrus.Logger
ClosingKeuanganRepo repository.ClosingKeuanganRepository
ProjectFlockRepo projectflockRepository.ProjectflockRepository
ProjectFlockKandangRepo projectflockRepository.ProjectFlockKandangRepository
MarketingDeliveryProductRepo marketingDeliveryProductRepository.MarketingDeliveryProductRepository
ExpenseRealizationRepo expenseRealizationRepository.ExpenseRealizationRepository
ProjectBudgetRepo projectflockRepository.ProjectBudgetRepository
ChickinRepo chickinRepository.ProjectChickinRepository
RecordingRepo recordingRepository.RecordingRepository
}
func NewClosingKeuanganService(
closingKeuanganRepo repository.ClosingKeuanganRepository,
projectFlockRepo projectflockRepository.ProjectflockRepository,
projectFlockKandangRepo projectflockRepository.ProjectFlockKandangRepository,
marketingDeliveryProductRepo marketingDeliveryProductRepository.MarketingDeliveryProductRepository,
expenseRealizationRepo expenseRealizationRepository.ExpenseRealizationRepository,
projectBudgetRepo projectflockRepository.ProjectBudgetRepository,
chickinRepo chickinRepository.ProjectChickinRepository,
recordingRepo recordingRepository.RecordingRepository,
) ClosingKeuanganService {
return &closingKeuanganService{
Log: utils.Log,
ClosingKeuanganRepo: closingKeuanganRepo,
ProjectFlockRepo: projectFlockRepo,
ProjectFlockKandangRepo: projectFlockKandangRepo,
MarketingDeliveryProductRepo: marketingDeliveryProductRepo,
ExpenseRealizationRepo: expenseRealizationRepo,
ProjectBudgetRepo: projectBudgetRepo,
ChickinRepo: chickinRepo,
RecordingRepo: recordingRepo,
}
}
func (s closingKeuanganService) GetClosingKeuangan(c *fiber.Ctx, projectFlockID uint) (*dto.ClosingKeuanganData, error) {
if err := commonSvc.EnsureRelations(c.Context(),
commonSvc.RelationCheck{Name: "Project Flock", ID: &projectFlockID, Exists: s.ProjectFlockRepo.IdExists},
); err != nil {
return nil, err
}
projectFlock, err := s.ProjectFlockRepo.GetByID(c.Context(), projectFlockID, nil)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock")
}
budgets, err := s.ProjectBudgetRepo.GetByProjectFlockID(c.Context(), projectFlockID)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch budgets")
}
// Preload Nonstock.Flags manually
var budgetIDs []uint
for _, b := range budgets {
budgetIDs = append(budgetIDs, b.Id)
}
if len(budgetIDs) > 0 {
err = s.ProjectBudgetRepo.DB().WithContext(c.Context()).
Preload("Nonstock.Flags").
Where("id IN ?", budgetIDs).
Find(&budgets).Error
}
// Get all kandang for this project flock
kandangs, err := s.ProjectFlockKandangRepo.GetByProjectFlockID(c.Context(), projectFlockID)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch kandangs")
}
return s.calculateClosingKeuangan(c, projectFlock, budgets, kandangs, projectFlockID)
}
func (s closingKeuanganService) GetClosingKeuanganByKandang(c *fiber.Ctx, projectFlockID uint, projectFlockKandangID uint) (*dto.ClosingKeuanganData, error) {
if err := commonSvc.EnsureRelations(c.Context(),
commonSvc.RelationCheck{Name: "Project Flock", ID: &projectFlockID, Exists: s.ProjectFlockRepo.IdExists},
); err != nil {
return nil, err
}
// Validate and fetch project flock kandang
kandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), projectFlockKandangID)
if err != nil {
return nil, fiber.NewError(fiber.StatusNotFound, "Project flock kandang not found")
}
if kandang.ProjectFlockId != projectFlockID {
return nil, fiber.NewError(fiber.StatusBadRequest, "Project flock kandang does not belong to this project flock")
}
projectFlock, err := s.ProjectFlockRepo.GetByID(c.Context(), projectFlockID, nil)
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock")
}
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
}
kandangs := []entity.ProjectFlockKandang{*kandang}
return s.calculateClosingKeuangan(c, projectFlock, budgets, kandangs, projectFlockID)
}
func (s closingKeuanganService) calculateClosingKeuangan(c *fiber.Ctx, projectFlock *entity.ProjectFlock, budgets []entity.ProjectBudget, kandangs []entity.ProjectFlockKandang, scopeID uint) (*dto.ClosingKeuanganData, error) {
// Define flag filters using constants
pakanFilters := []string{string(utils.FlagPakan), string(utils.FlagPreStarter), string(utils.FlagStarter), string(utils.FlagFinisher)}
ovkFilters := []string{string(utils.FlagOVK), string(utils.FlagObat), string(utils.FlagVitamin), string(utils.FlagKimia)}
ayamFilters := []string{string(utils.FlagDOC), string(utils.FlagPullet), string(utils.FlagLayer)}
allFilters := append(pakanFilters, ovkFilters...)
allFilters = append(allFilters, ayamFilters...)
var allProductUsageRows []repository.ProductUsageRow
// Get ALL product usage
for _, kandang := range kandangs {
rows, err := s.ClosingKeuanganRepo.GetAllProductUsageByProjectFlockKandangID(c.Context(), kandang.Id, allFilters)
if err == nil {
allProductUsageRows = append(allProductUsageRows, rows...)
}
}
// Classify into categories based on flag priority
var pakanProductUsageRows []repository.ProductUsageRow
var ovkProductUsageRows []repository.ProductUsageRow
var ayamProductUsageRows []repository.ProductUsageRow
for _, row := range allProductUsageRows {
// Parse flag names from comma-separated string
flagNames := strings.Split(row.FlagNames, ",")
hasPakanFlag := false
hasOvkFlag := false
hasAyamFlag := false
for _, flag := range flagNames {
flag = strings.TrimSpace(flag)
if containsItem(pakanFilters, flag) {
hasPakanFlag = true
}
if containsItem(ovkFilters, flag) {
hasOvkFlag = true
}
if containsItem(ayamFilters, flag) {
hasAyamFlag = true
}
}
// Priority: PAKAN > OVK > AYAM
if hasPakanFlag {
pakanProductUsageRows = append(pakanProductUsageRows, row)
} else if hasOvkFlag {
ovkProductUsageRows = append(ovkProductUsageRows, row)
} else if hasAyamFlag {
ayamProductUsageRows = append(ayamProductUsageRows, row)
} else {
continue
}
}
// Calculate total price for each category
var totalPakanPrice, totalOvkPrice, totalAyamPrice float64
for _, row := range pakanProductUsageRows {
totalPakanPrice += row.TotalPengeluaran
}
for _, row := range ovkProductUsageRows {
totalOvkPrice += row.TotalPengeluaran
}
for _, row := range ayamProductUsageRows {
totalAyamPrice += row.TotalPengeluaran
}
// Determine if this is per-kandang or per-project-flock scope
isPerKandang := len(kandangs) == 1
var projectFlockKandangID *uint
if isPerKandang {
kandangID := kandangs[0].Id
projectFlockKandangID = &kandangID
}
var err error
// Fetch realizations
var realizations []entity.ExpenseRealization
if isPerKandang && projectFlockKandangID != nil {
realizations, err = s.ExpenseRealizationRepo.GetClosingOverhead(c.Context(), projectFlock.Id, projectFlockKandangID)
} else {
realizations, err = s.ExpenseRealizationRepo.GetClosingOverhead(c.Context(), projectFlock.Id, nil)
}
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch realizations")
}
deliveryProducts, err := s.MarketingDeliveryProductRepo.GetDeliveryProductsByProjectFlockID(c.Context(), projectFlock.Id, func(db *gorm.DB) *gorm.DB {
db = db.Preload("MarketingProduct").
Preload("MarketingProduct.ProductWarehouse").
Preload("MarketingProduct.ProductWarehouse.Product")
return db
})
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch delivery products")
}
// Filter by kandang if scope is per-kandang (manual filtering after fetch)
if isPerKandang && projectFlockKandangID != nil {
filteredProducts := make([]entity.MarketingDeliveryProduct, 0)
for _, dp := range deliveryProducts {
pfKandangID := dp.MarketingProduct.ProductWarehouse.ProjectFlockKandangId
if pfKandangID != nil && *pfKandangID == *projectFlockKandangID {
filteredProducts = append(filteredProducts, dp)
}
}
deliveryProducts = filteredProducts
}
// Fetch chickins
var chickins []entity.ProjectChickin
if isPerKandang && projectFlockKandangID != nil {
chickins, err = s.ChickinRepo.GetByProjectFlockKandangID(c.Context(), *projectFlockKandangID)
} else {
chickins, err = s.ChickinRepo.GetByProjectFlockID(c.Context(), projectFlock.Id)
}
if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch chickins")
}
// Get total depletion
var totalDepletion float64
if isPerKandang && projectFlockKandangID != nil {
totalDepletion, err = s.ClosingKeuanganRepo.GetTotalDepletionByProjectFlockKandangID(c.Context(), *projectFlockKandangID)
} else {
totalDepletion, err = s.RecordingRepo.GetTotalDepletionByProjectFlockID(c.Context(), projectFlock.Id)
}
if err != nil {
totalDepletion = 0
}
totalWeightProduced, _, err := s.RecordingRepo.GetProductionWeightAndQtyByProjectFlockID(c.Context(), projectFlock.Id)
if err != nil {
}
// Try to get actual weight from uniformity data
var totalWeightFromUniformity float64
if isPerKandang && projectFlockKandangID != nil {
totalWeightFromUniformity, err = s.ClosingKeuanganRepo.GetTotalWeightProducedFromUniformityByProjectFlockKandangID(c.Context(), *projectFlockKandangID)
} else {
totalWeightFromUniformity, err = s.RecordingRepo.GetTotalWeightProducedFromUniformityByProjectFlockID(c.Context(), projectFlock.Id)
}
if err != nil {
} else if totalWeightFromUniformity > 0 {
totalWeightProduced = totalWeightFromUniformity
}
// Fetch egg data only for Laying category
var totalEggWeightKg float64
if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) {
// TODO: Replace with actual method to get egg weight from RecordingRepo
// totalEggWeightKg, err = s.RecordingRepo.GetEggWeightByProjectFlockID(c.Context(), projectFlock.Id)
// For now, set to 0 as placeholder
totalEggWeightKg = 0
} else {
totalEggWeightKg = 0
}
// Build new DTO structure
// Calculate totals
var totalPopulation float64
for _, chickin := range chickins {
totalPopulation += chickin.UsageQty
}
// Calculate actual population (total population - depletion)
actualPopulation := totalPopulation - totalDepletion
// Calculate budget totals by category
calculateBudgetByFlag := func(flags []string) float64 {
var total float64
for _, budget := range budgets {
if budget.Nonstock != nil {
for _, nonstockFlag := range budget.Nonstock.Flags {
flagName := strings.ToUpper(nonstockFlag.Name)
for _, targetFlag := range flags {
if flagName == strings.ToUpper(targetFlag) {
total += budget.Price * budget.Qty
break
}
}
}
}
}
return total
}
// Budget per category
budgetPakan := calculateBudgetByFlag([]string{"PAKAN", "PRE-STARTER", "STARTER", "FINISHER"})
budgetOvk := calculateBudgetByFlag([]string{"OVK", "OBAT", "VITAMIN", "KIMIA"})
budgetAyam := calculateBudgetByFlag([]string{"DOC", "PULLET", "LAYER"})
budgetEkspedisi := calculateBudgetByFlag([]string{"EKSPEDISI"})
// Operational budget = total budget - pakan - ovk - ayam - ekspedisi
totalBudgetAmount := 0.0
for _, budget := range budgets {
totalBudgetAmount += budget.Price * budget.Qty
}
budgetOperational := totalBudgetAmount - budgetPakan - budgetOvk - budgetAyam - budgetEkspedisi
// Calculate realization totals
var totalRealizationAmount float64
var totalEkspedisiRealization float64
for _, realization := range realizations {
amount := realization.Price * realization.Qty
totalRealizationAmount += amount
// Check if this is ekspedisi (need to check nonstock flags)
if realization.ExpenseNonstock != nil && realization.ExpenseNonstock.Nonstock != nil {
for _, flag := range realization.ExpenseNonstock.Nonstock.Flags {
if flag.Name == "EKSPEDISI" {
totalEkspedisiRealization += amount
break
}
}
}
}
totalOperationalRealization := totalRealizationAmount - totalEkspedisiRealization
// Filter delivery products based on category
var filteredDeliveryProducts []entity.MarketingDeliveryProduct
for _, delivery := range deliveryProducts {
// Get product from delivery
if delivery.MarketingProduct.ProductWarehouse.Product.Id == 0 {
continue
}
product := delivery.MarketingProduct.ProductWarehouse.Product
isEggProduct := false
isChickenProduct := false
// Check product flags
for _, flag := range product.Flags {
flagName := strings.ToUpper(flag.Name)
// Egg product flags
if flagName == "TELUR" || flagName == "TELURUTUH" || flagName == "TELURPECAH" ||
flagName == "TELURPUTIH" || flagName == "TELURRETAK" {
isEggProduct = true
}
// Chicken product flags
if flagName == "AYAMAFKIR" || flagName == "AYAMCULLING" || flagName == "AYAMMATI" {
isChickenProduct = true
}
}
// Filter based on project flock category
if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) {
// Laying: only egg products
if isEggProduct {
filteredDeliveryProducts = append(filteredDeliveryProducts, delivery)
}
} else {
// Growing/Contract Growing: only chicken products
if isChickenProduct || (!isEggProduct && !isChickenProduct) {
// Include if chicken product or if no specific flags (default to chicken)
filteredDeliveryProducts = append(filteredDeliveryProducts, delivery)
}
}
}
// Calculate total weight sold and sales amount from filtered products
var totalWeightSold float64
var totalSalesAmount float64
for _, delivery := range filteredDeliveryProducts {
totalWeightSold += delivery.TotalWeight
totalSalesAmount += delivery.TotalPrice
}
// Calculate metrics - always use kg ayam for rp_per_kg
calculateMetrics := func(amount float64) (rpPerBird, rpPerKg float64) {
if actualPopulation > 0 {
rpPerBird = amount / actualPopulation // Use actual population
}
if totalWeightProduced > 0 {
rpPerKg = amount / totalWeightProduced
}
return
}
// Calculate metrics for profit loss (use total population and total weight produced)
calculateProfitLossMetrics := func(amount float64) (rpPerBird, rpPerKg float64) {
if totalPopulation > 0 {
rpPerBird = amount / totalPopulation
}
if totalWeightProduced > 0 {
rpPerKg = amount / totalWeightProduced
}
return
}
// Build HPP Items using constants
hppItems := []dto.HPPItem{}
// PAKAN item
pakanBudgetRpPerBird, pakanBudgetRpPerKg := calculateMetrics(budgetPakan)
pakanRealizationRpPerBird, pakanRealizationRpPerKg := calculateMetrics(totalPakanPrice)
hppItems = append(hppItems, dto.ToHPPItem(
1,
"purchase",
string(dto.HPPCodePakan),
"Pembelian Pakan",
dto.ToFinancialMetrics(pakanBudgetRpPerBird, pakanBudgetRpPerKg, budgetPakan),
dto.ToFinancialMetrics(pakanRealizationRpPerBird, pakanRealizationRpPerKg, totalPakanPrice),
))
// OVK item
ovkBudgetRpPerBird, ovkBudgetRpPerKg := calculateMetrics(budgetOvk)
ovkRealizationRpPerBird, ovkRealizationRpPerKg := calculateMetrics(totalOvkPrice)
hppItems = append(hppItems, dto.ToHPPItem(
2,
"purchase",
string(dto.HPPCodeOVK),
"Pembelian OVK",
dto.ToFinancialMetrics(ovkBudgetRpPerBird, ovkBudgetRpPerKg, budgetOvk),
dto.ToFinancialMetrics(ovkRealizationRpPerBird, ovkRealizationRpPerKg, totalOvkPrice),
))
// DOC/DEPRESIASI item
docCode := string(dto.HPPCodeDOC)
docLabel := "Pembelian DOC"
if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) {
docCode = string(dto.HPPCodeDepresiasi)
docLabel = "Depresiasi"
}
docBudgetRpPerBird, docBudgetRpPerKg := calculateMetrics(budgetAyam)
docRealizationRpPerBird, docRealizationRpPerKg := calculateMetrics(totalAyamPrice)
hppItems = append(hppItems, dto.ToHPPItem(
3,
"purchase",
docCode,
docLabel,
dto.ToFinancialMetrics(docBudgetRpPerBird, docBudgetRpPerKg, budgetAyam),
dto.ToFinancialMetrics(docRealizationRpPerBird, docRealizationRpPerKg, totalAyamPrice),
))
// OVERHEAD item
overheadBudgetRpPerBird, overheadBudgetRpPerKg := calculateMetrics(budgetOperational)
overheadRealizationRpPerBird, overheadRealizationRpPerKg := calculateMetrics(totalOperationalRealization)
hppItems = append(hppItems, dto.ToHPPItem(
4,
"overhead",
string(dto.HPPCodeOverhead),
"Pengeluaran Overhead",
dto.ToFinancialMetrics(overheadBudgetRpPerBird, overheadBudgetRpPerKg, budgetOperational),
dto.ToFinancialMetrics(overheadRealizationRpPerBird, overheadRealizationRpPerKg, totalOperationalRealization),
))
// EKSPEDISI item
ekspedisiBudgetRpPerBird, ekspedisiBudgetRpPerKg := calculateMetrics(budgetEkspedisi)
ekspedisiRealizationRpPerBird, ekspedisiRealizationRpPerKg := calculateMetrics(totalEkspedisiRealization)
hppItems = append(hppItems, dto.ToHPPItem(
5,
"overhead",
string(dto.HPPCodeEkspedisi),
"Beban Ekspedisi",
dto.ToFinancialMetrics(ekspedisiBudgetRpPerBird, ekspedisiBudgetRpPerKg, budgetEkspedisi),
dto.ToFinancialMetrics(ekspedisiRealizationRpPerBird, ekspedisiRealizationRpPerKg, totalEkspedisiRealization),
))
// HPP Summary
totalBudgetHpp := budgetPakan + budgetOvk + budgetAyam + budgetOperational + budgetEkspedisi
totalRealizationHpp := totalPakanPrice + totalOvkPrice + totalAyamPrice + totalOperationalRealization + totalEkspedisiRealization
hppBudgetRpPerBird, hppBudgetRpPerKg := calculateMetrics(totalBudgetHpp)
hppRealizationRpPerBird, hppRealizationRpPerKg := calculateMetrics(totalRealizationHpp)
var eggBudgeting, eggRealization *dto.FinancialMetrics
if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) && totalEggWeightKg > 0 {
eggBudgetRpPerKg := totalBudgetHpp / totalEggWeightKg
eggRealizationRpPerKg := totalRealizationHpp / totalEggWeightKg
eggBudgeting = &dto.FinancialMetrics{
RpPerBird: 0,
RpPerKg: eggBudgetRpPerKg,
Amount: totalBudgetHpp,
}
eggRealization = &dto.FinancialMetrics{
RpPerBird: 0,
RpPerKg: eggRealizationRpPerKg,
Amount: totalRealizationHpp,
}
}
hppSummary := dto.ToHPPSummary(
"HPP",
dto.ToFinancialMetrics(hppBudgetRpPerBird, hppBudgetRpPerKg, totalBudgetHpp),
dto.ToFinancialMetrics(hppRealizationRpPerBird, hppRealizationRpPerKg, totalRealizationHpp),
eggBudgeting,
eggRealization,
)
hppSection := dto.ToHPPSection(hppItems, hppSummary)
// Build Profit Loss Items using constants
plItems := []dto.ProfitLossItem{}
// SALES item
salesRpPerBird, salesRpPerKg := calculateProfitLossMetrics(totalSalesAmount)
salesLabel := "Penjualan Ayam"
if projectFlock.Category == string(utils.ProjectFlockCategoryLaying) {
salesLabel = "Penjualan Telur"
}
plItems = append(plItems, dto.ToProfitLossItem(
string(dto.PLCodeSales),
salesLabel,
"income",
salesRpPerBird,
salesRpPerKg,
totalSalesAmount,
))
// SAPRONAK item - combines DOC/Depresiasi + PAKAN + OVK
totalSapronakAmount := totalAyamPrice + totalPakanPrice + totalOvkPrice
sapronakRpPerBird := docRealizationRpPerBird + pakanRealizationRpPerBird + ovkRealizationRpPerBird
sapronakRpPerKg := docRealizationRpPerKg + pakanRealizationRpPerKg + ovkRealizationRpPerKg
sapronakLabel := "Pengeluaran Sapronak"
plItems = append(plItems, dto.ToProfitLossItem(
string(dto.PLCodeSapronak),
sapronakLabel,
"purchase",
sapronakRpPerBird,
sapronakRpPerKg,
totalSapronakAmount,
))
// OVERHEAD item
overheadRpPerBird, overheadRpPerKg := calculateMetrics(totalOperationalRealization)
plItems = append(plItems, dto.ToProfitLossItem(
string(dto.PLCodeOverhead),
"Overhead",
"overhead",
overheadRpPerBird,
overheadRpPerKg,
totalOperationalRealization,
))
// EKSPEDISI item
plItems = append(plItems, dto.ToProfitLossItem(
string(dto.PLCodeEkspedisi),
"Ekspedisi",
"overhead",
ekspedisiRealizationRpPerBird,
ekspedisiRealizationRpPerKg,
totalEkspedisiRealization,
))
// Profit Loss Summary
// Gross Profit = Sales - (DOC + PAKAN + OVK) only
// Gross Profit should NOT include overhead and ekspedisi
costOfGoodsSold := totalAyamPrice + totalPakanPrice + totalOvkPrice
costOfGoodsSoldRpPerBird := sapronakRpPerBird
grossProfit := totalSalesAmount - costOfGoodsSold
grossProfitRpPerBird := salesRpPerBird - costOfGoodsSoldRpPerBird
// Operating Expenses (Overhead + Ekspedisi)
totalOperatingExpenses := totalOperationalRealization + totalEkspedisiRealization
totalOperatingExpensesRpPerBird := overheadRpPerBird + ekspedisiRealizationRpPerBird
// Net Profit = Gross Profit - Operating Expenses
netProfit := grossProfit - totalOperatingExpenses
netProfitRpPerBird := grossProfitRpPerBird - totalOperatingExpensesRpPerBird
plSummary := dto.ToProfitLossSummary(
dto.ToFinancialMetrics(grossProfitRpPerBird, 0, grossProfit),
dto.ToFinancialMetrics(totalOperatingExpensesRpPerBird, 0, totalOperatingExpenses),
dto.ToFinancialMetrics(netProfitRpPerBird, 0, netProfit),
)
profitLossSection := dto.ToProfitLossSection(plItems, plSummary)
// Build complete response
data := dto.ToClosingKeuanganData(hppSection, profitLossSection)
return &data, nil
}
// containsItem checks if a string exists in a slice
func containsItem(slice []string, item string) bool {
for _, s := range slice {
if strings.EqualFold(s, item) {
return true
}
}
return false
}
@@ -2,8 +2,8 @@ package service
import ( import (
"context" "context"
"fmt"
"strings" "strings"
"time"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
@@ -112,7 +112,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, nil, nil, 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")
@@ -126,8 +126,6 @@ func (s sapronakService) computeSapronakReports(ctx context.Context, params *val
KandangName: pfk.Kandang.Name, KandangName: pfk.Kandang.Name,
Period: pfk.Period, Period: pfk.Period,
Status: status, Status: status,
StartDate: nil,
EndDate: nil,
TotalIncomingValue: totalIncoming, TotalIncomingValue: totalIncoming,
TotalUsageValue: totalUsage, TotalUsageValue: totalUsage,
Items: items, Items: items,
@@ -265,6 +263,7 @@ 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(
@@ -274,6 +273,7 @@ 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),
@@ -282,6 +282,7 @@ 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) {
@@ -314,11 +315,12 @@ 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
} }
func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.ProjectFlockKandang, start, end *time.Time, flagFilter string) ([]dto.SapronakItemDTO, []dto.SapronakGroupDTO, float64, float64, error) { func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.ProjectFlockKandang, flagFilter string) ([]dto.SapronakItemDTO, []dto.SapronakGroupDTO, float64, float64, error) {
// For sapronak closing report we intentionally ignore date range // For sapronak closing report we intentionally ignore date range
// and aggregate all historical transactions for the kandang/project. // and aggregate all historical transactions for the kandang/project.
incomingRows, err := s.Repository.FetchSapronakIncoming(ctx, pfk.KandangId) incomingRows, err := s.Repository.FetchSapronakIncoming(ctx, pfk.KandangId)
@@ -345,6 +347,14 @@ 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
@@ -353,6 +363,10 @@ 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.FetchSapronakSales(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 {
@@ -365,6 +379,34 @@ 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
@@ -403,13 +445,17 @@ 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) detailMaps := buildSapronakDetails(incomingDetailsRows, usageDetailsRows, adjIncomingRows, adjOutgoingRows, transIncomingRows, transOutgoingRows, salesOutRows)
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 {
@@ -419,6 +465,22 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
return groupMap[flag] return groupMap[flag]
} }
resolveFlagName := func(productID uint, details []dto.SapronakDetailDTO) (string, string) {
flag := ""
name := ""
if item, ok := itemMap[productID]; ok {
flag = item.Flag
name = item.ProductName
}
if flag == "" && len(details) > 0 {
flag = details[0].Flag
}
if name == "" && len(details) > 0 {
name = details[0].ProductName
}
return flag, name
}
for _, row := range incoming { for _, row := range incoming {
if !matchesFlag(row.Flag) { if !matchesFlag(row.Flag) {
continue continue
@@ -554,19 +616,18 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
} }
for productID, details := range incomingDetails { for productID, details := range incomingDetails {
flag := "" flag, name := resolveFlagName(productID, details)
name := ""
if item, ok := itemMap[productID]; ok {
flag = item.Flag
name = item.ProductName
}
if !matchesFlag(flag) { if !matchesFlag(flag) {
continue continue
} }
group := ensureGroup(flag) group := ensureGroup(flag)
for _, d := range details { for _, d := range details {
d.Flag = flag if d.Flag == "" {
d.ProductName = name d.Flag = flag
}
if d.ProductName == "" {
d.ProductName = name
}
group.Items = append(group.Items, d) group.Items = append(group.Items, d)
group.TotalMasuk += d.QtyMasuk group.TotalMasuk += d.QtyMasuk
group.TotalNilai += d.Nilai group.TotalNilai += d.Nilai
@@ -575,19 +636,18 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
} }
for productID, details := range adjIncoming { for productID, details := range adjIncoming {
flag := "" flag, name := resolveFlagName(productID, details)
name := ""
if item, ok := itemMap[productID]; ok {
flag = item.Flag
name = item.ProductName
}
if !matchesFlag(flag) { if !matchesFlag(flag) {
continue continue
} }
group := ensureGroup(flag) group := ensureGroup(flag)
for _, d := range details { for _, d := range details {
d.Flag = flag if d.Flag == "" {
d.ProductName = name d.Flag = flag
}
if d.ProductName == "" {
d.ProductName = name
}
group.Items = append(group.Items, d) group.Items = append(group.Items, d)
group.TotalMasuk += d.QtyMasuk group.TotalMasuk += d.QtyMasuk
group.TotalNilai += d.Nilai group.TotalNilai += d.Nilai
@@ -596,19 +656,18 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
} }
for productID, details := range usageDetails { for productID, details := range usageDetails {
flag := "" flag, name := resolveFlagName(productID, details)
name := ""
if item, ok := itemMap[productID]; ok {
flag = item.Flag
name = item.ProductName
}
if !matchesFlag(flag) { if !matchesFlag(flag) {
continue continue
} }
group := ensureGroup(flag) group := ensureGroup(flag)
for _, d := range details { for _, d := range details {
d.Flag = flag if d.Flag == "" {
d.ProductName = name d.Flag = flag
}
if d.ProductName == "" {
d.ProductName = name
}
group.Items = append(group.Items, d) group.Items = append(group.Items, d)
group.TotalKeluar += d.QtyKeluar group.TotalKeluar += d.QtyKeluar
group.SaldoAkhir -= d.QtyKeluar group.SaldoAkhir -= d.QtyKeluar
@@ -616,19 +675,18 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
} }
for productID, details := range adjOutgoing { for productID, details := range adjOutgoing {
flag := "" flag, name := resolveFlagName(productID, details)
name := ""
if item, ok := itemMap[productID]; ok {
flag = item.Flag
name = item.ProductName
}
if !matchesFlag(flag) { if !matchesFlag(flag) {
continue continue
} }
group := ensureGroup(flag) group := ensureGroup(flag)
for _, d := range details { for _, d := range details {
d.Flag = flag if d.Flag == "" {
d.ProductName = name d.Flag = flag
}
if d.ProductName == "" {
d.ProductName = name
}
group.Items = append(group.Items, d) group.Items = append(group.Items, d)
group.TotalKeluar += d.QtyKeluar group.TotalKeluar += d.QtyKeluar
group.SaldoAkhir -= d.QtyKeluar group.SaldoAkhir -= d.QtyKeluar
@@ -636,19 +694,18 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
} }
for productID, details := range transIncoming { for productID, details := range transIncoming {
flag := "" flag, name := resolveFlagName(productID, details)
name := ""
if item, ok := itemMap[productID]; ok {
flag = item.Flag
name = item.ProductName
}
if !matchesFlag(flag) { if !matchesFlag(flag) {
continue continue
} }
group := ensureGroup(flag) group := ensureGroup(flag)
for _, d := range details { for _, d := range details {
d.Flag = flag if d.Flag == "" {
d.ProductName = name d.Flag = flag
}
if d.ProductName == "" {
d.ProductName = name
}
group.Items = append(group.Items, d) group.Items = append(group.Items, d)
group.TotalMasuk += d.QtyMasuk group.TotalMasuk += d.QtyMasuk
group.TotalNilai += d.Nilai group.TotalNilai += d.Nilai
@@ -657,19 +714,37 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
} }
for productID, details := range transOutgoing { for productID, details := range transOutgoing {
flag := "" flag, name := resolveFlagName(productID, details)
name := ""
if item, ok := itemMap[productID]; ok {
flag = item.Flag
name = item.ProductName
}
if !matchesFlag(flag) { if !matchesFlag(flag) {
continue continue
} }
group := ensureGroup(flag) group := ensureGroup(flag)
for _, d := range details { for _, d := range details {
d.Flag = flag if d.Flag == "" {
d.ProductName = name d.Flag = flag
}
if d.ProductName == "" {
d.ProductName = name
}
group.Items = append(group.Items, d)
group.TotalKeluar += d.QtyKeluar
group.SaldoAkhir -= d.QtyKeluar
}
}
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.Items = append(group.Items, d)
group.TotalKeluar += d.QtyKeluar group.TotalKeluar += d.QtyKeluar
group.SaldoAkhir -= d.QtyKeluar group.SaldoAkhir -= d.QtyKeluar
@@ -9,9 +9,11 @@ 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 (
@@ -24,4 +26,5 @@ 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"}, "log_types": []string{"TRANSFER", "ADJUSTMENT", "MARKETING", "CHICKIN", "PURCHASE", "RECORDING"},
"transaction_types": []string{"INCREASE", "DECREASE"}, "transaction_types": []string{"INCREASE", "DECREASE"},
}, },
"supplier_categories": []string{ "supplier_categories": []string{
+14 -14
View File
@@ -15,49 +15,49 @@ func DailyChecklistRoutes(v1 fiber.Router, u user.UserService, s dailyChecklist.
route := v1.Group("/daily-checklists") route := v1.Group("/daily-checklists")
route.Use(m.Auth(u)) route.Use(m.Auth(u))
route.Get("/", ctrl.GetAll) route.Get("/", m.RequirePermissions(m.P_DailyChecklistGetAll), ctrl.GetAll)
route.Get("/report", ctrl.GetReport) route.Get("/report", m.RequirePermissions(m.P_DailyChecklistReports), ctrl.GetReport)
route.Get("/summary", ctrl.GetSummary) route.Get("/summary", m.RequirePermissions(m.P_DailyChecklistDashboardList), ctrl.GetSummary)
route.Get("/report", ctrl.GetReport) // route.Get("/report", ctrl.GetReport)
// upsert daily checklist // upsert daily checklist
route.Post("/", ctrl.CreateOne) route.Post("/", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.CreateOne)
// get detail data daily checklist by id // get detail data daily checklist by id
route.Get("/relation/:idDailyChecklist", ctrl.GetOne) route.Get("/relation/:idDailyChecklist", m.RequirePermissions(m.P_DailyChecklistGetOne), ctrl.GetOne)
// get phases by daily checklist id // get phases by daily checklist id
route.Get("/phase/:idDailyChecklist", ctrl.GetPhaseByIdChecklist) route.Get("/phase/:idDailyChecklist", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.GetPhaseByIdChecklist)
// create task // create task
/* /*
ketika add phase ketika add phase
*/ */
route.Post("/phase/:idDailyChecklist", ctrl.CreateDailyChecklistPhase) route.Post("/phase/:idDailyChecklist", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.CreateDailyChecklistPhase)
// create assigment // create assigment
/* /*
ketika add ABK ketika add ABK
*/ */
route.Post("/assignment/:idDailyChecklist", ctrl.CreateAssignment) route.Post("/assignment/:idDailyChecklist", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.CreateAssignment)
// remove assignment // remove assignment
/* /*
ketika remove ABK ketika remove ABK
*/ */
route.Delete("/:idDailyChecklist/assignments/:idEmployee", ctrl.RemoveAssignment) route.Delete("/:idDailyChecklist/assignments/:idEmployee", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.RemoveAssignment)
//get all tasks //get all tasks
route.Get("/tasks", ctrl.GetAllTasks) route.Get("/tasks", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.GetAllTasks)
// update assignment // update assignment
/* /*
ketika check dan uncheck tugas oleh ABK ketika check dan uncheck tugas oleh ABK
*/ */
route.Post("/assignment", ctrl.UpdateAssignment) route.Post("/assignment", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.UpdateAssignment)
route.Patch("/:idDailyChecklist", ctrl.UpdateOne) route.Patch("/:idDailyChecklist", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.UpdateOne)
route.Delete("/:idDailyChecklist", ctrl.DeleteOne) route.Delete("/:idDailyChecklist", m.RequirePermissions(m.P_DailyChecklistCreateOne), ctrl.DeleteOne)
} }
@@ -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,max=100,gt=0"` Limit int `query:"limit" validate:"required,number,min=1,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"`
@@ -285,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.qty * re.weight), 0)"). Select("COALESCE(SUM(re.weight * 1000), 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").
@@ -648,7 +648,7 @@ func (r *DashboardRepositoryImpl) GetEggWeightWeeklyGrams(ctx context.Context, s
Table("recording_eggs AS re"). Table("recording_eggs AS re").
Select(` Select(`
((r.day - 1) / 7 + 1) AS week, ((r.day - 1) / 7 + 1) AS week,
COALESCE(SUM(re.qty * re.weight), 0) AS egg_weight_grams`). COALESCE(SUM(re.weight * 1000), 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").
@@ -70,7 +70,8 @@ func (r *ExpenseRealizationRepositoryImpl) GetClosingOverhead(ctx context.Contex
Joins("JOIN expenses ON expenses.id = expense_nonstocks.expense_id"). Joins("JOIN expenses ON expenses.id = expense_nonstocks.expense_id").
Joins("LEFT JOIN project_flock_kandangs ON project_flock_kandangs.id = expense_nonstocks.project_flock_kandang_id"). Joins("LEFT JOIN project_flock_kandangs ON project_flock_kandangs.id = expense_nonstocks.project_flock_kandang_id").
Joins("LEFT JOIN kandangs ON kandangs.id = expense_nonstocks.kandang_id"). Joins("LEFT JOIN kandangs ON kandangs.id = expense_nonstocks.kandang_id").
Where("expenses.realization_date IS NOT NULL") Where("expenses.realization_date IS NOT NULL").
Where("expenses.category = ?", "BOP")
if projectFlockKandangID != nil { if projectFlockKandangID != nil {
db = db.Where(`( db = db.Where(`(
@@ -411,6 +411,10 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
updateBody["supplier_id"] = *req.SupplierID updateBody["supplier_id"] = *req.SupplierID
} }
if req.Notes != nil {
updateBody["notes"] = *req.Notes
}
if req.LocationID != nil { if req.LocationID != nil {
locationID := uint(*req.LocationID) locationID := uint(*req.LocationID)
updateBody["location_id"] = locationID updateBody["location_id"] = locationID
@@ -583,20 +587,28 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
if err != nil { if err != nil {
return fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context") return fiber.NewError(fiber.StatusUnauthorized, "Failed to get actor ID from context")
} }
if *latestApproval.Action != entity.ApprovalActionUpdated {
if *latestApproval.Action != entity.ApprovalActionUpdated && latestApproval.StepNumber > uint16(utils.ExpenseStepPengajuan) {
approvalAction := entity.ApprovalActionUpdated approvalAction := entity.ApprovalActionUpdated
previousStep := approvalutils.ApprovalStep(latestApproval.StepNumber) - 1
if previousStep < utils.ExpenseStepPengajuan {
previousStep = utils.ExpenseStepPengajuan
}
if _, err := approvalSvcTx.CreateApproval( if _, err := approvalSvcTx.CreateApproval(
c.Context(), c.Context(),
utils.ApprovalWorkflowExpense, utils.ApprovalWorkflowExpense,
id, id,
utils.ExpenseStepPengajuan, previousStep,
&approvalAction, &approvalAction,
actorID, actorID,
nil); err != nil { nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to reset approval step") return fiber.NewError(fiber.StatusInternalServerError, "Failed to reset approval step")
} }
} }
if s.DocumentSvc != nil && len(req.Documents) > 0 { if s.DocumentSvc != nil && len(req.Documents) > 0 {
@@ -31,6 +31,7 @@ type Update struct {
Category *string `form:"category" json:"category" validate:"omitempty,oneof=BOP NON-BOP"` Category *string `form:"category" json:"category" validate:"omitempty,oneof=BOP NON-BOP"`
SupplierID *uint64 `form:"supplier_id" json:"supplier_id" validate:"omitempty,gt=0"` SupplierID *uint64 `form:"supplier_id" json:"supplier_id" validate:"omitempty,gt=0"`
LocationID *uint64 `form:"location_id" json:"location_id" validate:"omitempty,gt=0"` LocationID *uint64 `form:"location_id" json:"location_id" validate:"omitempty,gt=0"`
Notes *string `form:"notes" json:"notes" validate:"omitempty,max=500"`
Documents []*multipart.FileHeader `form:"documents" json:"documents" validate:"omitempty,dive"` Documents []*multipart.FileHeader `form:"documents" json:"documents" validate:"omitempty,dive"`
ExpenseNonstocks *[]ExpenseNonstock `form:"expense_nonstocks" json:"expense_nonstocks" validate:"omitempty,min=1,dive"` ExpenseNonstocks *[]ExpenseNonstock `form:"expense_nonstocks" json:"expense_nonstocks" validate:"omitempty,min=1,dive"`
} }
@@ -100,38 +100,42 @@ func ToProductWarehouseDTO(e *entity.ProductWarehouse) *ProductWarehouseDTO {
} }
} }
func ToAdjustmentRelationDTO(e *entity.StockLog) AdjustmentRelationDTO { func ToAdjustmentRelationDTO(e *entity.AdjustmentStock) AdjustmentRelationDTO {
return AdjustmentRelationDTO{ return AdjustmentRelationDTO{
Id: e.Id, Id: e.Id,
Note: e.Notes, Note: e.StockLog.Notes,
Increase: e.Increase, Increase: e.TotalQty,
Decrease: e.Decrease, Decrease: e.UsageQty,
ProductWarehouseId: e.ProductWarehouseId, ProductWarehouseId: e.ProductWarehouseId,
ProductWarehouse: ToProductWarehouseDTO(e.ProductWarehouse), ProductWarehouse: ToProductWarehouseDTO(e.ProductWarehouse),
} }
} }
func ToAdjustmentListDTO(e *entity.StockLog) AdjustmentListDTO { func ToAdjustmentListDTO(e *entity.AdjustmentStock) AdjustmentListDTO {
var createdUser *userDTO.UserRelationDTO var createdUser *userDTO.UserRelationDTO
if e.CreatedUser != nil { if e.StockLog != nil && e.StockLog.CreatedUser != nil {
createdUser = &userDTO.UserRelationDTO{ createdUser = &userDTO.UserRelationDTO{
Id: e.CreatedUser.Id, Id: e.StockLog.CreatedUser.Id,
IdUser: e.CreatedUser.IdUser, IdUser: e.StockLog.CreatedUser.IdUser,
Email: e.CreatedUser.Email, Email: e.StockLog.CreatedUser.Email,
Name: e.CreatedUser.Name, Name: e.StockLog.CreatedUser.Name,
} }
} }
createdAt := time.Time{}
if e.StockLog != nil {
createdAt = e.StockLog.CreatedAt
}
return AdjustmentListDTO{ return AdjustmentListDTO{
AdjustmentRelationDTO: ToAdjustmentRelationDTO(e), AdjustmentRelationDTO: ToAdjustmentRelationDTO(e),
CreatedUser: createdUser, CreatedUser: createdUser,
CreatedAt: e.CreatedAt, CreatedAt: createdAt,
} }
} }
func ToAdjustmentDetailDTO(e *entity.StockLog) AdjustmentDetailDTO { func ToAdjustmentDetailDTO(e *entity.AdjustmentStock) AdjustmentDetailDTO {
return AdjustmentDetailDTO{ return AdjustmentDetailDTO{
AdjustmentListDTO: ToAdjustmentListDTO(e), AdjustmentListDTO: ToAdjustmentListDTO(e),
// UpdatedAt: e.UpdatedAt,
} }
} }
@@ -33,6 +33,14 @@ func (r *adjustmentStockRepositoryImpl) CreateOne(ctx context.Context, data *ent
func (r *adjustmentStockRepositoryImpl) GetByStockLogID(ctx context.Context, stockLogID uint) (*entity.AdjustmentStock, error) { func (r *adjustmentStockRepositoryImpl) GetByStockLogID(ctx context.Context, stockLogID uint) (*entity.AdjustmentStock, error) {
var record entity.AdjustmentStock var record entity.AdjustmentStock
err := r.db.WithContext(ctx). err := r.db.WithContext(ctx).
Preload("StockLog").
Preload("StockLog.ProductWarehouse").
Preload("StockLog.ProductWarehouse.Product").
Preload("StockLog.ProductWarehouse.Warehouse").
Preload("StockLog.CreatedUser").
Preload("ProductWarehouse").
Preload("ProductWarehouse.Product").
Preload("ProductWarehouse.Warehouse").
Where("stock_log_id = ?", stockLogID). Where("stock_log_id = ?", stockLogID).
First(&record).Error First(&record).Error
if err != nil { if err != nil {
@@ -25,9 +25,9 @@ import (
) )
type AdjustmentService interface { type AdjustmentService interface {
Adjustment(ctx *fiber.Ctx, req *validation.Create) (*entity.StockLog, error) Adjustment(ctx *fiber.Ctx, req *validation.Create) (*entity.AdjustmentStock, error)
GetOne(ctx *fiber.Ctx, id uint) (*entity.StockLog, error) GetOne(ctx *fiber.Ctx, id uint) (*entity.AdjustmentStock, error)
AdjustmentHistory(ctx *fiber.Ctx, query *validation.Query) ([]*entity.StockLog, int64, error) AdjustmentHistory(ctx *fiber.Ctx, query *validation.Query) ([]*entity.AdjustmentStock, int64, error)
} }
type adjustmentService struct { type adjustmentService struct {
@@ -73,14 +73,8 @@ func (s *adjustmentService) withRelations(db *gorm.DB) *gorm.DB {
Preload("CreatedUser") Preload("CreatedUser")
} }
func (s *adjustmentService) GetOne(c *fiber.Ctx, id uint) (*entity.StockLog, error) { func (s *adjustmentService) GetOne(c *fiber.Ctx, id uint) (*entity.AdjustmentStock, error) {
if err := m.EnsureStockLogAccess(c, s.StockLogsRepository.DB(), id); err != nil { adjustmentStock, err := s.AdjustmentStockRepository.GetByStockLogID(c.Context(), id)
return nil, err
}
stockLog, err := s.StockLogsRepository.GetByID(c.Context(), id, func(db *gorm.DB) *gorm.DB {
return s.withRelations(db).Preload("ProductWarehouse.Product.ProductCategory")
})
if err != nil { if 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")
@@ -89,14 +83,10 @@ func (s *adjustmentService) GetOne(c *fiber.Ctx, id uint) (*entity.StockLog, err
return nil, err return nil, err
} }
if stockLog.LoggableType != string(utils.StockLogTypeAdjustment) { return adjustmentStock, nil
return nil, fiber.NewError(fiber.StatusNotFound, "Adjustment not found")
}
return stockLog, nil
} }
func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*entity.StockLog, error) { func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*entity.AdjustmentStock, error) {
if err := s.Validate.Struct(req); err != nil { if err := s.Validate.Struct(req); err != nil {
return nil, err return nil, err
} }
@@ -105,11 +95,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},
@@ -120,12 +105,13 @@ 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 createdLogId uint var createdAdjustmentStockId uint
var projectFlockKandangID *uint var projectFlockKandangID *uint
pfkID, err := s.getActiveProjectFlockKandangID(ctx, uint(req.WarehouseID)) pfkID, err := s.getActiveProjectFlockKandangID(ctx, uint(req.WarehouseID))
@@ -160,7 +146,8 @@ 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")
@@ -180,14 +167,14 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
newLog.Increase = afterQuantity newLog.Increase = afterQuantity
} else { } else {
if productWarehouse.Quantity < req.Quantity { if productWarehouse.Quantity < req.Quantity {
return fiber.NewError(fiber.StatusBadRequest, "Insufficient stock for adjustment") 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))
} }
afterQuantity -= req.Quantity afterQuantity -= req.Quantity
newLog.Decrease = afterQuantity 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
} }
@@ -196,7 +183,7 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
ProductWarehouseId: productWarehouse.Id, ProductWarehouseId: productWarehouse.Id,
} }
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")
} }
@@ -221,7 +208,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, // Don't allow pending for adjustment AllowPending: false,
Tx: tx, Tx: tx,
}) })
if err != nil { if err != nil {
@@ -229,24 +216,27 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
} }
} }
// Update ProductWarehouse quantity (for backward compatibility/reporting) // LEGACY: Update ProductWarehouse quantity (for backward compatibility/reporting)
productWarehouse.Quantity = afterQuantity productWarehouse.Quantity = afterQuantity
if err := s.ProductWarehouseRepo.WithTx(tx).UpdateOne(ctx, productWarehouse.Id, productWarehouse, nil); err != nil { 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) s.Log.Errorf("Failed to update product warehouse quantity: %+v", err)
return err return err
} }
createdLogId = newLog.Id createdAdjustmentStockId = adjustmentStock.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, createdLogId) return s.GetOne(c, createdAdjustmentStockId)
} }
func (s *adjustmentService) getActiveProjectFlockKandangID(ctx context.Context, warehouseID uint) (uint, error) { func (s *adjustmentService) getActiveProjectFlockKandangID(ctx context.Context, warehouseID uint) (uint, error) {
@@ -275,26 +265,24 @@ 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.StockLog, int64, error) { func (s *adjustmentService) AdjustmentHistory(c *fiber.Ctx, query *validation.Query) ([]*entity.AdjustmentStock, 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")
} }
if query.WarehouseID > 0 && !isWarehousesExist { if query.WarehouseID > 0 && !isWarehousesExist {
return nil, 0, fiber.NewError(fiber.StatusNotFound, "Warehouse not found") return nil, 0, fiber.NewError(fiber.StatusNotFound, "Warehouse not found")
} }
if query.WarehouseID > 0 {
if err := m.EnsureWarehouseAccess(c, s.WarehouseRepo.DB(), uint(query.WarehouseID)); err != nil {
return nil, 0, err
}
}
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")
@@ -303,35 +291,51 @@ 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 scopeErr error var adjustmentStocks []entity.AdjustmentStock
stockLogs, total, err := s.StockLogsRepository.GetAll(c.Context(), offset, query.Limit, func(db *gorm.DB) *gorm.DB { var total int64
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")
db, scopeErr = m.ApplyLocationScope(c, db, "w.location_id")
db = s.withRelations(db)
db = db.Where("loggable_type = ?", string(utils.StockLogTypeAdjustment)) q := s.AdjustmentStockRepository.DB().WithContext(c.Context()).Model(&entity.AdjustmentStock{}).
Preload("StockLog").
Preload("StockLog.ProductWarehouse").
Preload("StockLog.ProductWarehouse.Product").
Preload("StockLog.ProductWarehouse.Warehouse").
Preload("StockLog.CreatedUser").
Preload("ProductWarehouse").
Preload("ProductWarehouse.Product").
Preload("ProductWarehouse.Warehouse")
if query.TransactionType != "" { if query.ProductID > 0 {
db = db.Where("transaction_type = ?", strings.ToUpper(query.TransactionType)) q = q.Joins("JOIN stock_logs ON stock_logs.id = adjustment_stocks.stock_log_id").
} Joins("JOIN product_warehouses ON product_warehouses.id = stock_logs.product_warehouse_id").
db = s.StockLogsRepository.ApplyProductWarehouseFilters(db, uint(query.ProductID), uint(query.WarehouseID)) Where("product_warehouses.product_id = ?", query.ProductID)
return db.Order("created_at DESC")
})
if scopeErr != nil {
return nil, 0, scopeErr
} }
if query.WarehouseID > 0 {
q = q.Joins("JOIN stock_logs ON stock_logs.id = adjustment_stocks.stock_log_id").
Joins("JOIN product_warehouses ON product_warehouses.id = stock_logs.product_warehouse_id").
Where("product_warehouses.warehouse_id = ?", query.WarehouseID)
}
if query.TransactionType != "" {
q = q.Joins("JOIN stock_logs ON stock_logs.id = adjustment_stocks.stock_log_id").
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.StockLog, len(stockLogs)) result := make([]*entity.AdjustmentStock, len(adjustmentStocks))
for i, v := range stockLogs { for i := range adjustmentStocks {
result[i] = &v result[i] = &adjustmentStocks[i]
} }
return result, total, nil return result, total, nil
@@ -5,6 +5,7 @@ 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"
) )
// === DTO Structs === // === DTO Structs ===
@@ -16,60 +17,29 @@ 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 *WarehouseRelationDTO `json:"warehouse,omitempty"` Warehouse *warehouseDTO.WarehouseRelationDTO `json:"warehouse,omitempty"`
ProjectFlockKandang *ProjectFlockKandangRelationDTO `json:"project_flock_kandang,omitempty"` ProjectFlockKandang *ProjectFlockKandangRelationDTO `json:"project_flock_kandang,omitempty"`
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
} }
// Nested DTOs for relations type ProductWarehousNestedDTO struct {
type ProductRelationDTO struct { Id uint `json:"id"`
Id uint `json:"id"` Product *productDTO.ProductRelationDTO `json:"product,omitempty"`
Name string `json:"name"` Warehouse *warehouseDTO.WarehouseRelationDTO `json:"warehouse,omitempty"`
Sku string `json:"sku"`
Flags []string `json:"flags"`
} }
type WarehouseRelationDTO struct { type UserRelationDTO struct {
Id uint `json:"id"` Id uint `json:"id"`
Name string `json:"name"` Username string `json:"username"`
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 {
@@ -96,65 +66,28 @@ 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)
// Tambahkan flock name ke product name jika ada project flock // Create a copy with flock name appended if exists
if e.ProjectFlockKandang != nil && e.ProjectFlockKandang.ProjectFlock.Id != 0 { if e.ProjectFlockKandang != nil && e.ProjectFlockKandang.ProjectFlock.Id != 0 {
product.Name = product.Name + " (" + e.ProjectFlockKandang.ProjectFlock.FlockName + ")" productCopy := product
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 := WarehouseRelationDTO{ warehouse := warehouseDTO.ToWarehouseRelationDTO(e.Warehouse)
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
} }
@@ -168,7 +101,6 @@ 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,
@@ -179,15 +111,6 @@ func ToProductWarehouseListDTO(e entity.ProductWarehouse) ProductWarehouseListDT
dto.ProjectFlockKandang = pfkDTO dto.ProjectFlockKandang = pfkDTO
} }
// Map CreatedUser relation jika ada
// if e.CreatedUser.Id != 0 {
// user := UserRelationDTO{
// Id: e.CreatedUser.Id,
// Username: e.CreatedUser.Name,
// }
// dto.CreatedUser = &user
// }
return dto return dto
} }
@@ -205,23 +128,13 @@ func ToProductWarehouseDetailDTO(e entity.ProductWarehouse) ProductWarehouseDeta
} }
} }
func ToKandangRelationDTO(e entity.Kandang) KandangRelationDTO { func ToProductWarehouseNestedDTO(e entity.ProductWarehouse) ProductWarehousNestedDTO {
return KandangRelationDTO{ product := productDTO.ToProductRelationDTO(e.Product)
Id: e.Id, warehouse := warehouseDTO.ToWarehouseRelationDTO(e.Warehouse)
Name: e.Name,
}
}
func ToLocationRelationDTO(e entity.Location) LocationRelationDTO { return ProductWarehousNestedDTO{
return LocationRelationDTO{ Id: e.Id,
Id: e.Id, Product: &product,
Name: e.Name, Warehouse: &warehouse,
}
}
func ToAreaRelationDTO(e entity.Area) AreaRelationDTO {
return AreaRelationDTO{
Id: e.Id,
Name: e.Name,
} }
} }
@@ -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"
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"
m "gitlab.com/mbugroup/lti-api.git/internal/middleware" m "gitlab.com/mbugroup/lti-api.git/internal/middleware"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"github.com/sirupsen/logrus"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -40,6 +40,7 @@ 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").
@@ -4,20 +4,17 @@ import (
"time" "time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
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 TransferRelationDTO struct { type TransferRelationDTO struct {
Id uint64 `json:"id"` Id uint64 `json:"id"`
TransferReason string `json:"transfer_reason"` MovementNumber string `json:"movement_number"`
TransferDate string `json:"transfer_date"` TransferReason string `json:"transfer_reason"`
SourceWarehouse *WarehouseDetailDTO `json:"source_warehouse,omitempty"` TransferDate string `json:"transfer_date"`
DestinationWarehouse *WarehouseDetailDTO `json:"destination_warehouse,omitempty"` SourceWarehouse *warehouseDTO.WarehouseRelationDTO `json:"source_warehouse,omitempty"`
} DestinationWarehouse *warehouseDTO.WarehouseRelationDTO `json:"destination_warehouse,omitempty"`
type WarehouseSimpleDTO struct {
Id uint `json:"id"`
Name string `json:"name"`
} }
type ProductSimpleDTO struct { type ProductSimpleDTO struct {
@@ -25,16 +22,6 @@ type ProductSimpleDTO struct {
Name string `json:"name"` Name string `json:"name"`
} }
type AreaDTO struct {
Id uint `json:"id"`
Name string `json:"name"`
}
type LocationDTO struct {
Id uint `json:"id"`
Name string `json:"name"`
}
type SupplierSimpleDTO struct { type SupplierSimpleDTO struct {
Id uint `json:"id"` Id uint `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -48,13 +35,6 @@ type DocumentDTO struct {
Size float64 `json:"size"` Size float64 `json:"size"`
} }
type WarehouseDetailDTO struct {
Id uint `json:"id"`
Name string `json:"name"`
Location *LocationDTO `json:"location"`
Area *AreaDTO `json:"area"`
}
type TransferListDTO struct { type TransferListDTO struct {
TransferRelationDTO TransferRelationDTO
CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"` CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"`
@@ -97,16 +77,19 @@ type TransferDeliveryItemDTO struct {
} }
func ToTransferRelationDTO(e entity.StockTransfer) TransferRelationDTO { func ToTransferRelationDTO(e entity.StockTransfer) TransferRelationDTO {
var sourceWarehouse *WarehouseDetailDTO var sourceWarehouse *warehouseDTO.WarehouseRelationDTO
if e.FromWarehouse != nil && e.FromWarehouse.Id != 0 { if e.FromWarehouse != nil && e.FromWarehouse.Id != 0 {
sourceWarehouse = toWarehouseDetailDTO(e.FromWarehouse) mapped := warehouseDTO.ToWarehouseRelationDTO(*e.FromWarehouse)
sourceWarehouse = &mapped
} }
var destinationWarehouse *WarehouseDetailDTO var destinationWarehouse *warehouseDTO.WarehouseRelationDTO
if e.ToWarehouse != nil && e.ToWarehouse.Id != 0 { if e.ToWarehouse != nil && e.ToWarehouse.Id != 0 {
destinationWarehouse = toWarehouseDetailDTO(e.ToWarehouse) mapped := warehouseDTO.ToWarehouseRelationDTO(*e.ToWarehouse)
destinationWarehouse = &mapped
} }
return TransferRelationDTO{ return TransferRelationDTO{
Id: e.Id, Id: e.Id,
MovementNumber: e.MovementNumber,
TransferReason: e.Reason, TransferReason: e.Reason,
TransferDate: e.CreatedAt.Format("2006-01-02"), TransferDate: e.CreatedAt.Format("2006-01-02"),
SourceWarehouse: sourceWarehouse, SourceWarehouse: sourceWarehouse,
@@ -114,38 +97,6 @@ func ToTransferRelationDTO(e entity.StockTransfer) TransferRelationDTO {
} }
} }
func toAreaDTO(a *entity.Area) *AreaDTO {
if a == nil {
return nil
}
return &AreaDTO{
Id: a.Id,
Name: a.Name,
}
}
func toLocationDTO(l *entity.Location) *LocationDTO {
if l == nil {
return nil
}
return &LocationDTO{
Id: l.Id,
Name: l.Name,
}
}
func toWarehouseDetailDTO(w *entity.Warehouse) *WarehouseDetailDTO {
if w == nil {
return nil
}
return &WarehouseDetailDTO{
Id: w.Id,
Name: w.Name,
Location: toLocationDTO(w.Location),
Area: toAreaDTO(&w.Area),
}
}
func ToTransferListDTO(e entity.StockTransfer) TransferListDTO { func ToTransferListDTO(e entity.StockTransfer) TransferListDTO {
var createdUser *userDTO.UserRelationDTO var createdUser *userDTO.UserRelationDTO
if e.CreatedUser != nil { if e.CreatedUser != nil {
@@ -40,6 +40,6 @@ func (r *StockTransferRepositoryImpl) GenerateMovementNumber(ctx context.Context
if err != nil { if err != nil {
return "", err return "", err
} }
movementNumber := fmt.Sprintf("ST-%05d", seq) movementNumber := fmt.Sprintf("PND-LTI-%05d", seq)
return movementNumber, nil return movementNumber, nil
} }
@@ -15,8 +15,8 @@ func TransferRoutes(v1 fiber.Router, u user.UserService, s transfer.TransferServ
route := v1.Group("/transfers") route := v1.Group("/transfers")
route.Use(m.Auth(u)) route.Use(m.Auth(u))
route.Get("/",m.RequirePermissions(m.P_TransferGetAll), ctrl.GetAll) route.Get("/", m.RequirePermissions(m.P_TransferGetAll), ctrl.GetAll)
route.Post("/",m.RequirePermissions(m.P_TransferCreateOne), ctrl.CreateOne) route.Post("/", m.RequirePermissions(m.P_TransferCreateOne), ctrl.CreateOne)
route.Get("/:id",m.RequirePermissions(m.P_TransferGetOne), ctrl.GetOne) route.Get("/:id", m.RequirePermissions(m.P_TransferGetOne), ctrl.GetOne)
} }
@@ -113,7 +113,11 @@ func (s transferService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit
Where("w_from.location_id IN ? OR w_to.location_id IN ?", scope.IDs, scope.IDs) Where("w_from.location_id IN ? OR w_to.location_id IN ?", scope.IDs, scope.IDs)
} }
if params.Search != "" { if params.Search != "" {
db = db.Where("movement_number ILIKE ?", "%"+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").
Joins("LEFT JOIN warehouses AS to_warehouses ON to_warehouses.id = stock_transfers.to_warehouse_id").
Where("movement_number ILIKE ? OR from_warehouses.name ILIKE ? OR to_warehouses.name ILIKE ?",
searchTerm, searchTerm, searchTerm)
} }
return db.Order("created_at DESC").Order("updated_at DESC") return db.Order("created_at DESC").Order("updated_at DESC")
}) })
@@ -154,9 +158,10 @@ func (s transferService) GetOne(c *fiber.Ctx, id uint) (*entity.StockTransfer, e
}) })
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Transfer not found") return nil, fiber.NewError(fiber.StatusNotFound, fmt.Sprintf("Transfer dengan ID %d tidak ditemukan", id))
} }
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to get transfer") s.Log.Errorf("Failed to fetch transfer by ID %d: %+v", id, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data transfer")
} }
return transferPtr, nil return transferPtr, nil
@@ -172,12 +177,13 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
) )
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("Stok produk %d tidak tersedia di gudang asal", product.ProductID)) 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.StatusInternalServerError, "Gagal cek stok produk di gudang asal") s.Log.Errorf("Failed to fetch product warehouse for product_id=%d, warehouse_id=%d: %+v", product.ProductID, req.SourceWarehouseID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengecek stok produk")
} }
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 cukup", product.ProductID)) 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))
} }
pwIDs = append(pwIDs, sourcePW.Id) pwIDs = append(pwIDs, sourcePW.Id)
} }
@@ -195,12 +201,15 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
return nil, err return nil, err
} }
projectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), destPfkID) if destPfkID > 0 {
if err != nil { projectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), destPfkID)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data project flock") if err != nil {
} s.Log.Errorf("Failed to fetch project flock kandang by ID %d: %+v", destPfkID, err)
if projectFlockKandang.ClosedAt != nil { return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data project flock")
return nil, fiber.NewError(fiber.StatusBadRequest, "Project flock tujuan sudah closing") }
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)
@@ -228,16 +237,18 @@ 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("Supplier dengan ID %d tidak ditemukan", delivery.SupplierID)) return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Supplier dengan ID %d tidak ditemukan", delivery.SupplierID))
} }
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal cek data supplier") s.Log.Errorf("Failed to fetch supplier by ID %d: %+v", delivery.SupplierID, err)
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 dengan ID %d bukan kategori BOP", delivery.SupplierID)) 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))
} }
} }
movementNumber, err := s.StockTransferRepo.GenerateMovementNumber(c.Context()) movementNumber, err := s.StockTransferRepo.GenerateMovementNumber(c.Context())
if err != nil { if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to generate movement number") s.Log.Errorf("Failed to generate movement number: %+v", err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat nomor transfer")
} }
transferDate, _ := utils.ParseDateString(req.TransferDate) transferDate, _ := utils.ParseDateString(req.TransferDate)
@@ -275,16 +286,18 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
) )
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok produk %d tidak tersedia di gudang asal", product.ProductID)) return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Produk %d tidak ditemukan di gudang asal (ID: %d)", product.ProductID, req.SourceWarehouseID))
} }
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data product warehouse source") s.Log.Errorf("Failed to fetch source product warehouse for product_id=%d, warehouse_id=%d: %+v", product.ProductID, req.SourceWarehouseID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data stok gudang asal")
} }
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) {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data product warehouse destination") s.Log.Errorf("Failed to fetch dest product warehouse for product_id=%d, warehouse_id=%d: %+v", product.ProductID, req.DestinationWarehouseID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data stok gudang tujuan")
} }
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
ctx := c.Context() ctx := c.Context()
@@ -292,14 +305,21 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
if err != nil { if err != nil {
return err return err
} }
var pfkID *uint
if projectFlockKandangID > 0 {
pfkID = &projectFlockKandangID
}
destPW = &entity.ProductWarehouse{ destPW = &entity.ProductWarehouse{
ProductId: uint(product.ProductID), ProductId: uint(product.ProductID),
WarehouseId: uint(req.DestinationWarehouseID), WarehouseId: uint(req.DestinationWarehouseID),
Quantity: 0, Quantity: 0,
ProjectFlockKandangId: &projectFlockKandangID, ProjectFlockKandangId: pfkID,
} }
if err := productWarehouseRepoTX.CreateOne(c.Context(), destPW, nil); err != nil { if err := productWarehouseRepoTX.CreateOne(c.Context(), destPW, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat product warehouse destination") s.Log.Errorf("Failed to create product warehouse for product_id=%d, warehouse_id=%d: %+v", product.ProductID, req.DestinationWarehouseID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat data stok gudang tujuan")
} }
} }
@@ -345,7 +365,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
for _, prod := range item.Products { for _, prod := range item.Products {
detail, ok := detailMap[uint64(prod.ProductID)] detail, ok := detailMap[uint64(prod.ProductID)]
if !ok { if !ok {
return fmt.Errorf("produk %d tidak ditemukan di detail", prod.ProductID) return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Produk %d tidak ditemukan dalam daftar transfer untuk delivery #%d", prod.ProductID, i+1))
} }
deliveryItems = append(deliveryItems, &entity.StockTransferDeliveryItem{ deliveryItems = append(deliveryItems, &entity.StockTransferDeliveryItem{
StockTransferDeliveryId: delivery.Id, StockTransferDeliveryId: delivery.Id,
@@ -389,9 +409,9 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
Files: documentFiles, Files: documentFiles,
}) })
if err != nil { if err != nil {
s.Log.WithError(err).Errorf("Failed to upload document for delivery %d (delivery_id: %d, filename: %s)", s.Log.Errorf("Failed to upload document for delivery %d (delivery_id=%d, filename=%s): %+v",
deliveryIdx+1, delivery.Id, file.Filename) deliveryIdx+1, delivery.Id, file.Filename, err)
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to upload document for delivery %d: %v", deliveryIdx+1, err)) return fiber.NewError(fiber.StatusInternalServerError, "Gagal mengunggah dokumen")
} }
} }
} }
@@ -408,7 +428,7 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
Tx: tx, Tx: tx,
}) })
if err != nil { if err != nil {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak cukup di gudang asal untuk produk %d: %v", product.ProductID, err)) return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak mencukupi untuk produk %d di gudang asal. Error: %v", product.ProductID, err))
} }
if err := tx.Model(&entity.StockTransferDetail{}). if err := tx.Model(&entity.StockTransferDetail{}).
@@ -417,7 +437,8 @@ 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 {
return fmt.Errorf("gagal update usage tracking: %w", err) s.Log.Errorf("Failed to update tracking usage for detail_id=%d, product_id=%d: %+v", detail.Id, product.ProductID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memperbarui data tracking")
} }
note := fmt.Sprintf("Transfer #%s", entityTransfer.MovementNumber) note := fmt.Sprintf("Transfer #%s", entityTransfer.MovementNumber)
@@ -430,7 +451,8 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
Tx: tx, Tx: tx,
}) })
if err != nil { if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Gagal menambah stok di gudang tujuan untuk produk %d: %v", product.ProductID, err)) s.Log.Errorf("Failed to replenish stock for product_id=%d, pw_id=%d, qty=%.2f: %+v", product.ProductID, *detail.DestProductWarehouseID, product.ProductQty, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal menambah stok gudang tujuan")
} }
if err := tx.Model(&entity.StockTransferDetail{}). if err := tx.Model(&entity.StockTransferDetail{}).
@@ -438,7 +460,8 @@ 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 {
return fmt.Errorf("gagal update total tracking: %w", err) s.Log.Errorf("Failed to update tracking total for detail_id=%d, product_id=%d: %+v", detail.Id, product.ProductID, err)
return fiber.NewError(fiber.StatusInternalServerError, "Gagal memperbarui data tracking")
} }
} }
@@ -472,7 +495,10 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
}) })
if err != nil { if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to process transfer transaction: %v", err)) if fiberErr, ok := err.(*fiber.Error); ok {
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))
@@ -482,8 +508,8 @@ 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 %d: %+v", entityTransfer.Id, err) 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("Failed to sync expense: %v", err)) return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal sinkronisasi data expense. Silakan cek manual di module expense")
} }
} }
@@ -497,32 +523,27 @@ func (s *transferService) notifyExpenseItemsDelivered(c *fiber.Ctx, transferID u
return s.ExpenseBridge.OnItemsDelivered(c, transferID, payloads) return s.ExpenseBridge.OnItemsDelivered(c, transferID, payloads)
} }
func (s *transferService) notifyExpenseDetailsDeleted(ctx context.Context, transferID uint64, items []entity.StockTransferDetail) error {
if s.ExpenseBridge == nil || transferID == 0 || len(items) == 0 {
return nil
}
return s.ExpenseBridge.OnItemsDeleted(ctx, transferID, items)
}
func (s *transferService) getActiveProjectFlockKandangID(ctx context.Context, warehouseID uint) (uint, error) { func (s *transferService) getActiveProjectFlockKandangID(ctx context.Context, warehouseID uint) (uint, error) {
warehouse, err := s.WarehouseRepo.GetByID(ctx, warehouseID, nil) warehouse, err := s.WarehouseRepo.GetByID(ctx, warehouseID, nil)
if err != nil { if err != nil {
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, "Gagal mengambil data gudang") return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data gudang")
} }
if warehouse.KandangId == nil || *warehouse.KandangId == 0 { if warehouse.KandangId == nil || *warehouse.KandangId == 0 {
return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Gudang %d belum terhubung ke kandang", warehouseID)) return 0, nil
} }
projectFlockKandang, err := s.ProjectFlockKandangRepo.GetActiveByKandangID(ctx, uint(*warehouse.KandangId)) projectFlockKandang, err := s.ProjectFlockKandangRepo.GetActiveByKandangID(ctx, uint(*warehouse.KandangId))
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Kandang %d belum memiliki project flock aktif", *warehouse.KandangId)) return 0, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Tidak ada project flock aktif untuk kandang %d", *warehouse.KandangId))
} }
return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil project flock kandang") s.Log.Errorf("Failed to fetch active project flock kandang for kandang_id=%d: %+v", *warehouse.KandangId, err)
return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data project flock")
} }
return uint(projectFlockKandang.Id), nil return uint(projectFlockKandang.Id), nil
@@ -140,12 +140,21 @@ func (b *transferExpenseBridge) markExpensesUpdated(ctx context.Context, expense
if actorID == 0 { if actorID == 0 {
actorID = 1 actorID = 1
} }
svc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(b.db)) approvalRepo := commonRepo.NewApprovalRepository(b.db)
action := entity.ApprovalActionUpdated svc := commonSvc.NewApprovalService(approvalRepo)
action := entity.ApprovalActionCreated
for id := range expenseIDs { for id := range expenseIDs {
if _, err := svc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(id), utils.ExpenseStepFinance, &action, actorID, nil); err != nil { latestApproval, err := approvalRepo.LatestByTarget(ctx, string(utils.ApprovalWorkflowExpense), uint(id), nil)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err return err
} }
if latestApproval == nil {
if _, err := svc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(id), utils.ExpenseStepFinance, &action, actorID, nil); err != nil {
return err
}
}
} }
return nil return nil
} }
@@ -9,6 +9,7 @@ import (
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"
) )
@@ -68,10 +69,10 @@ 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 *productwarehouseDTO.WarehouseRelationDTO `json:"warehouse,omitempty"` Warehouse *warehouseDTO.WarehouseRelationDTO `json:"warehouse,omitempty"`
Deliveries []DeliveryItemDTO `json:"deliveries"` Deliveries []DeliveryItemDTO `json:"deliveries"`
} }
type DeliveryMarketingProductDTO struct { type DeliveryMarketingProductDTO struct {
@@ -286,7 +287,7 @@ func groupDeliveryProducts(products []MarketingDeliveryProductDTO, soNumber stri
if !exists { if !exists {
group = &DeliveryGroupDTO{ group = &DeliveryGroupDTO{
DeliveryDate: product.DeliveryDate, DeliveryDate: product.DeliveryDate,
Warehouse: &productwarehouseDTO.WarehouseRelationDTO{ Warehouse: &warehouseDTO.WarehouseRelationDTO{
Id: warehouseId, Id: warehouseId,
Name: warehouseName, Name: warehouseName,
}, },
+3 -1
View File
@@ -16,6 +16,7 @@ import (
rCustomer "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/repositories" rCustomer "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/repositories"
rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories"
rProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rProjectFlockKandang "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
rShared "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories"
rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories" rUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/repositories"
sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services" sUser "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
"gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils"
@@ -32,6 +33,7 @@ func (MarketingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
userRepo := rUser.NewUserRepository(db) userRepo := rUser.NewUserRepository(db)
customerRepo := rCustomer.NewCustomerRepository(db) customerRepo := rCustomer.NewCustomerRepository(db)
productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db)
stockLogRepo := rShared.NewStockLogRepository(db)
stockAllocationRepo := commonRepo.NewStockAllocationRepository(db) stockAllocationRepo := commonRepo.NewStockAllocationRepository(db)
fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log) fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log)
@@ -63,7 +65,7 @@ func (MarketingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db) projectFlockKandangRepo := rProjectFlockKandang.NewProjectFlockKandangRepository(db)
salesOrdersService := service.NewSalesOrdersService(marketingRepo, customerRepo, productWarehouseRepo, userRepo, approvalSvc, warehouseRepo, projectFlockKandangRepo, validate) salesOrdersService := service.NewSalesOrdersService(marketingRepo, customerRepo, productWarehouseRepo, userRepo, approvalSvc, warehouseRepo, projectFlockKandangRepo, validate)
deliveryOrdersService := service.NewDeliveryOrdersService(marketingRepo, marketingProductRepo, marketingDeliveryProductRepo, approvalSvc, fifoService, validate) deliveryOrdersService := service.NewDeliveryOrdersService(marketingRepo, marketingProductRepo, marketingDeliveryProductRepo, stockLogRepo, approvalSvc, fifoService, validate)
userService := sUser.NewUserService(userRepo, validate) userService := sUser.NewUserService(userRepo, validate)
RegisterRoutes(router, userService, salesOrdersService, deliveryOrdersService) RegisterRoutes(router, userService, salesOrdersService, deliveryOrdersService)
@@ -140,16 +140,7 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetAllWithFilters(ctx context.C
Joins("JOIN marketings ON marketings.id = marketing_products.marketing_id"). Joins("JOIN marketings ON marketings.id = marketing_products.marketing_id").
Where("marketing_delivery_products.delivery_date IS NOT NULL") Where("marketing_delivery_products.delivery_date IS NOT NULL")
if len(filters.AllowedLocationIDs) > 0 { if filters.ProductId > 0 || filters.WarehouseId > 0 || filters.AreaId > 0 || filters.LocationId > 0 || filters.Search != "" || filters.MarketingType != "" {
if !containsJoin(db, "product_warehouses") {
db = db.Joins("JOIN product_warehouses ON product_warehouses.id = marketing_products.product_warehouse_id")
}
db = db.Joins("JOIN project_flock_kandangs ON project_flock_kandangs.id = product_warehouses.project_flock_kandang_id").
Joins("JOIN project_flocks ON project_flocks.id = project_flock_kandangs.project_flock_id").
Where("project_flocks.location_id IN ?", filters.AllowedLocationIDs)
}
if filters.ProductId > 0 || filters.WarehouseId > 0 || filters.Search != "" || filters.MarketingType != "" {
db = db.Joins("LEFT JOIN product_warehouses ON product_warehouses.id = marketing_products.product_warehouse_id") db = db.Joins("LEFT JOIN product_warehouses ON product_warehouses.id = marketing_products.product_warehouse_id")
} }
@@ -157,18 +148,30 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetAllWithFilters(ctx context.C
db = db.Joins("LEFT JOIN products ON products.id = product_warehouses.product_id") db = db.Joins("LEFT JOIN products ON products.id = product_warehouses.product_id")
} }
if filters.WarehouseId > 0 { if filters.WarehouseId > 0 || filters.Search != "" {
db = db.Joins("LEFT JOIN warehouses ON warehouses.id = product_warehouses.warehouse_id") db = db.Joins("LEFT JOIN warehouses ON warehouses.id = product_warehouses.warehouse_id")
} }
if filters.Search != "" { if filters.Search != "" {
db = db.Joins("LEFT JOIN customers ON customers.id = marketings.customer_id") db = db.Joins("LEFT JOIN customers ON customers.id = marketings.customer_id")
} db = db.Joins("LEFT JOIN users AS sales_users ON sales_users.id = marketings.sales_person_id")
if filters.Search != "" {
searchPattern := "%" + filters.Search + "%" searchPattern := "%" + filters.Search + "%"
db = db.Where("marketing_delivery_products.vehicle_number ILIKE ? OR marketings.so_number ILIKE ? OR customers.name ILIKE ? OR products.name ILIKE ?", db = db.Where(`(
searchPattern, searchPattern, searchPattern, searchPattern) marketing_delivery_products.vehicle_number ILIKE ? OR
customers.name ILIKE ? OR
warehouses.name ILIKE ? OR
products.name ILIKE ? OR
sales_users.name ILIKE ? OR
CONCAT(
marketings.so_number,
'-',
COALESCE(TO_CHAR(marketing_delivery_products.delivery_date, 'YYYYMMDD'), ''),
'-',
COALESCE(product_warehouses.warehouse_id::text, '')
) ILIKE ?
)`,
searchPattern, searchPattern, searchPattern, searchPattern, searchPattern, searchPattern)
} }
if filters.CustomerId > 0 { if filters.CustomerId > 0 {
@@ -187,6 +190,19 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetAllWithFilters(ctx context.C
db = db.Where("product_warehouses.warehouse_id = ?", filters.WarehouseId) db = db.Where("product_warehouses.warehouse_id = ?", filters.WarehouseId)
} }
if filters.AreaId > 0 || filters.LocationId > 0 {
db = db.Joins("LEFT JOIN project_flock_kandangs ON project_flock_kandangs.id = product_warehouses.project_flock_kandang_id").
Joins("LEFT JOIN project_flocks ON project_flocks.id = project_flock_kandangs.project_flock_id")
if filters.AreaId > 0 {
db = db.Where("project_flocks.area_id = ?", filters.AreaId)
}
if filters.LocationId > 0 {
db = db.Where("project_flocks.location_id = ?", filters.LocationId)
}
}
if filters.MarketingType != "" { if filters.MarketingType != "" {
db = db.Joins("LEFT JOIN flags ON flags.flagable_id = products.id AND flags.flagable_type = 'products'"). db = db.Joins("LEFT JOIN flags ON flags.flagable_id = products.id AND flags.flagable_type = 'products'").
Group("marketing_delivery_products.id") Group("marketing_delivery_products.id")
@@ -195,7 +211,6 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetAllWithFilters(ctx context.C
case "ayam": case "ayam":
db = db.Where("flags.name IN (?)", []string{ db = db.Where("flags.name IN (?)", []string{
string(utils.FlagDOC), string(utils.FlagPullet), string(utils.FlagLayer), string(utils.FlagDOC), string(utils.FlagPullet), string(utils.FlagLayer),
string(utils.FlagAyamAfkir), string(utils.FlagAyamCulling), string(utils.FlagAyamMati),
}) })
case "telur": case "telur":
db = db.Where("flags.name IN (?)", []string{ db = db.Where("flags.name IN (?)", []string{
@@ -210,8 +225,12 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetAllWithFilters(ctx context.C
} }
} }
if filters.FilterBy != "" && (filters.StartDate != "" || filters.EndDate != "") { if filters.StartDate != "" || filters.EndDate != "" {
if filters.FilterBy == "so_date" { filterBy := filters.FilterBy
if filterBy == "" {
filterBy = "so_date"
}
if filterBy == "so_date" {
if filters.StartDate != "" { if filters.StartDate != "" {
if startDate, err := utils.ParseDateString(filters.StartDate); err == nil { if startDate, err := utils.ParseDateString(filters.StartDate); err == nil {
db = db.Where("marketings.so_date >= ?", startDate) db = db.Where("marketings.so_date >= ?", startDate)
@@ -223,7 +242,7 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetAllWithFilters(ctx context.C
db = db.Where("marketings.so_date < ?", nextDate) db = db.Where("marketings.so_date < ?", nextDate)
} }
} }
} else if filters.FilterBy == "realization_date" { } else if filterBy == "realization_date" {
if filters.StartDate != "" { if filters.StartDate != "" {
if startDate, err := utils.ParseDateString(filters.StartDate); err == nil { if startDate, err := utils.ParseDateString(filters.StartDate); err == nil {
db = db.Where("marketing_delivery_products.delivery_date >= ?", startDate) db = db.Where("marketing_delivery_products.delivery_date >= ?", startDate)
@@ -26,7 +26,10 @@ func NewMarketingProductRepository(db *gorm.DB) MarketingProductRepository {
func (r *MarketingProductRepositoryImpl) GetByMarketingID(ctx context.Context, marketingID uint) ([]entity.MarketingProduct, error) { func (r *MarketingProductRepositoryImpl) GetByMarketingID(ctx context.Context, marketingID uint) ([]entity.MarketingProduct, error) {
var products []entity.MarketingProduct var products []entity.MarketingProduct
if err := r.DB().WithContext(ctx).Where("marketing_id = ?", marketingID).Find(&products).Error; err != nil { if err := r.DB().WithContext(ctx).
Preload("ProductWarehouse.Product.Flags").
Where("marketing_id = ?", marketingID).
Find(&products).Error; err != nil {
return nil, err return nil, err
} }
if len(products) == 0 { if len(products) == 0 {
@@ -14,6 +14,7 @@ import (
"gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/dto" "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/dto"
marketingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" marketingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/validations" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/validations"
rShared "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
@@ -34,6 +35,7 @@ type deliveryOrdersService struct {
MarketingRepo marketingRepo.MarketingRepository MarketingRepo marketingRepo.MarketingRepository
MarketingProductRepo marketingRepo.MarketingProductRepository MarketingProductRepo marketingRepo.MarketingProductRepository
MarketingDeliveryProductRepo marketingRepo.MarketingDeliveryProductRepository MarketingDeliveryProductRepo marketingRepo.MarketingDeliveryProductRepository
StockLogRepo rShared.StockLogRepository
ApprovalSvc commonSvc.ApprovalService ApprovalSvc commonSvc.ApprovalService
FifoSvc commonSvc.FifoService FifoSvc commonSvc.FifoService
} }
@@ -42,6 +44,7 @@ func NewDeliveryOrdersService(
marketingRepo marketingRepo.MarketingRepository, marketingRepo marketingRepo.MarketingRepository,
marketingProductRepo marketingRepo.MarketingProductRepository, marketingProductRepo marketingRepo.MarketingProductRepository,
marketingDeliveryProductRepo marketingRepo.MarketingDeliveryProductRepository, marketingDeliveryProductRepo marketingRepo.MarketingDeliveryProductRepository,
stockLogRepo rShared.StockLogRepository,
approvalSvc commonSvc.ApprovalService, approvalSvc commonSvc.ApprovalService,
fifoSvc commonSvc.FifoService, fifoSvc commonSvc.FifoService,
validate *validator.Validate, validate *validator.Validate,
@@ -51,6 +54,7 @@ func NewDeliveryOrdersService(
MarketingRepo: marketingRepo, MarketingRepo: marketingRepo,
MarketingProductRepo: marketingProductRepo, MarketingProductRepo: marketingProductRepo,
MarketingDeliveryProductRepo: marketingDeliveryProductRepo, MarketingDeliveryProductRepo: marketingDeliveryProductRepo,
StockLogRepo: stockLogRepo,
ApprovalSvc: approvalSvc, ApprovalSvc: approvalSvc,
FifoSvc: fifoSvc, FifoSvc: fifoSvc,
} }
@@ -275,9 +279,25 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Delivery
itemDeliveryDate = &parsedDate itemDeliveryDate = &parsedDate
} }
// Hitung total_weight dan total_price otomatis isPakanOrOVK := false
if foundMarketingProduct.ProductWarehouse.Product.Id != 0 && len(foundMarketingProduct.ProductWarehouse.Product.Flags) > 0 {
for _, flag := range foundMarketingProduct.ProductWarehouse.Product.Flags {
if flag.Name == string(utils.FlagPakan) || flag.Name == string(utils.FlagOVK) {
isPakanOrOVK = true
break
}
}
}
totalWeight := requestedProduct.Qty * requestedProduct.AvgWeight totalWeight := requestedProduct.Qty * requestedProduct.AvgWeight
totalPrice := requestedProduct.UnitPrice * requestedProduct.Qty var totalPrice float64
if isPakanOrOVK {
totalPrice = requestedProduct.Qty * requestedProduct.UnitPrice
} else {
totalPrice = totalWeight * requestedProduct.UnitPrice
}
deliveryProduct.ProductWarehouseId = foundMarketingProduct.ProductWarehouseId deliveryProduct.ProductWarehouseId = foundMarketingProduct.ProductWarehouseId
deliveryProduct.UnitPrice = requestedProduct.UnitPrice deliveryProduct.UnitPrice = requestedProduct.UnitPrice
@@ -289,7 +309,7 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Delivery
if requestedProduct.Qty > 0 { if requestedProduct.Qty > 0 {
if err := s.consumeDeliveryStock(c.Context(), dbTransaction, deliveryProduct, foundMarketingProduct, requestedProduct.Qty); err != nil { if err := s.consumeDeliveryStock(c.Context(), dbTransaction, deliveryProduct, foundMarketingProduct, requestedProduct.Qty, actorID); err != nil {
return err return err
} }
} }
@@ -341,7 +361,12 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO
return nil, err return nil, err
} }
err := s.MarketingRepo.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { actorID, err := m.ActorIDFromContext(c)
if err != nil {
return nil, err
}
err = s.MarketingRepo.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error {
marketingProductRepositoryTx := marketingRepo.NewMarketingProductRepository(dbTransaction) marketingProductRepositoryTx := marketingRepo.NewMarketingProductRepository(dbTransaction)
marketingDeliveryProductRepositoryTx := marketingRepo.NewMarketingDeliveryProductRepository(dbTransaction) marketingDeliveryProductRepositoryTx := marketingRepo.NewMarketingDeliveryProductRepository(dbTransaction)
@@ -393,9 +418,26 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO
oldRequestedQty := deliveryProduct.UsageQty + deliveryProduct.PendingQty oldRequestedQty := deliveryProduct.UsageQty + deliveryProduct.PendingQty
// Hitung total_weight dan total_price otomatis // Cek apakah product punya flag PAKAN atau OVK
isPakanOrOVK := false
if foundMarketingProduct.ProductWarehouse.Product.Id != 0 && len(foundMarketingProduct.ProductWarehouse.Product.Flags) > 0 {
for _, flag := range foundMarketingProduct.ProductWarehouse.Product.Flags {
if flag.Name == string(utils.FlagPakan) || flag.Name == string(utils.FlagOVK) {
isPakanOrOVK = true
break
}
}
}
totalWeight := requestedProduct.Qty * requestedProduct.AvgWeight totalWeight := requestedProduct.Qty * requestedProduct.AvgWeight
totalPrice := requestedProduct.UnitPrice * requestedProduct.Qty var totalPrice float64
if isPakanOrOVK {
totalPrice = requestedProduct.Qty * requestedProduct.UnitPrice
} else {
totalPrice = totalWeight * requestedProduct.UnitPrice
}
deliveryProduct.ProductWarehouseId = foundMarketingProduct.ProductWarehouseId deliveryProduct.ProductWarehouseId = foundMarketingProduct.ProductWarehouseId
deliveryProduct.UnitPrice = requestedProduct.UnitPrice deliveryProduct.UnitPrice = requestedProduct.UnitPrice
@@ -408,13 +450,13 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO
if requestedProduct.Qty != oldRequestedQty { if requestedProduct.Qty != oldRequestedQty {
if oldRequestedQty > 0 { if oldRequestedQty > 0 {
if err := s.releaseDeliveryStock(c.Context(), dbTransaction, deliveryProduct, foundMarketingProduct); err != nil { if err := s.releaseDeliveryStock(c.Context(), dbTransaction, deliveryProduct, foundMarketingProduct, actorID); err != nil {
return err return err
} }
} }
if requestedProduct.Qty > 0 { if requestedProduct.Qty > 0 {
if err := s.consumeDeliveryStock(c.Context(), dbTransaction, deliveryProduct, foundMarketingProduct, requestedProduct.Qty); err != nil { if err := s.consumeDeliveryStock(c.Context(), dbTransaction, deliveryProduct, foundMarketingProduct, requestedProduct.Qty, actorID); err != nil {
return err return err
} }
} }
@@ -439,7 +481,7 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO
return s.getMarketingWithDeliveries(c, id) return s.getMarketingWithDeliveries(c, id)
} }
func (s deliveryOrdersService) consumeDeliveryStock(ctx context.Context, tx *gorm.DB, deliveryProduct *entity.MarketingDeliveryProduct, marketingProduct *entity.MarketingProduct, requestedQty float64) error { func (s deliveryOrdersService) consumeDeliveryStock(ctx context.Context, tx *gorm.DB, deliveryProduct *entity.MarketingDeliveryProduct, marketingProduct *entity.MarketingProduct, requestedQty float64, actorID uint) error {
if marketingProduct == nil || marketingProduct.ProductWarehouseId == 0 { if marketingProduct == nil || marketingProduct.ProductWarehouseId == 0 {
return fiber.NewError(fiber.StatusInternalServerError, "Product warehouse not found") return fiber.NewError(fiber.StatusInternalServerError, "Product warehouse not found")
} }
@@ -459,6 +501,20 @@ func (s deliveryOrdersService) consumeDeliveryStock(ctx context.Context, tx *gor
deliveryProductRepo := marketingRepo.NewMarketingDeliveryProductRepository(tx) deliveryProductRepo := marketingRepo.NewMarketingDeliveryProductRepository(tx)
if err == nil && result.UsageQuantity > 0 {
if actorID > 0 {
decreaseLog := &entity.StockLog{
Decrease: result.UsageQuantity,
LoggableType: string(utils.StockLogTypeMarketing),
LoggableId: deliveryProduct.Id,
ProductWarehouseId: marketingProduct.ProductWarehouseId,
CreatedBy: actorID,
Notes: "",
}
s.StockLogRepo.WithTx(tx).CreateOne(ctx, decreaseLog, nil)
}
}
if err != nil { if err != nil {
pwRepo := productWarehouseRepo.NewProductWarehouseRepository(tx) pwRepo := productWarehouseRepo.NewProductWarehouseRepository(tx)
pw, err2 := pwRepo.GetByID(ctx, marketingProduct.ProductWarehouseId, nil) pw, err2 := pwRepo.GetByID(ctx, marketingProduct.ProductWarehouseId, nil)
@@ -467,12 +523,31 @@ func (s deliveryOrdersService) consumeDeliveryStock(ctx context.Context, tx *gor
} }
if pw == nil || pw.Quantity < requestedQty { if pw == nil || pw.Quantity < requestedQty {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Insufficient stock. Available: %.2f, Requested: %.2f", func() float64 { if pw != nil { return pw.Quantity } else { return 0 } }(), requestedQty)) return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Insufficient stock. Available: %.2f, Requested: %.2f", func() float64 {
if pw != nil {
return pw.Quantity
} else {
return 0
}
}(), requestedQty))
} }
if err := deliveryProductRepo.UpdateFifoFields(ctx, deliveryProduct.Id, requestedQty, 0); err != nil { if err := deliveryProductRepo.UpdateFifoFields(ctx, deliveryProduct.Id, requestedQty, 0); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update delivery product") return fiber.NewError(fiber.StatusInternalServerError, "Failed to update delivery product")
} }
if actorID > 0 {
decreaseLog := &entity.StockLog{
Decrease: requestedQty,
LoggableType: string(utils.StockLogTypeMarketing),
LoggableId: deliveryProduct.Id,
ProductWarehouseId: marketingProduct.ProductWarehouseId,
CreatedBy: actorID,
Notes: "",
}
s.StockLogRepo.WithTx(tx).CreateOne(ctx, decreaseLog, nil)
}
return nil return nil
} }
@@ -483,7 +558,7 @@ func (s deliveryOrdersService) consumeDeliveryStock(ctx context.Context, tx *gor
return nil return nil
} }
func (s deliveryOrdersService) releaseDeliveryStock(ctx context.Context, tx *gorm.DB, deliveryProduct *entity.MarketingDeliveryProduct, marketingProduct *entity.MarketingProduct) error { func (s deliveryOrdersService) releaseDeliveryStock(ctx context.Context, tx *gorm.DB, deliveryProduct *entity.MarketingDeliveryProduct, marketingProduct *entity.MarketingProduct, actorID uint) error {
if deliveryProduct == nil || deliveryProduct.Id == 0 { if deliveryProduct == nil || deliveryProduct.Id == 0 {
return nil return nil
} }
@@ -510,6 +585,18 @@ func (s deliveryOrdersService) releaseDeliveryStock(ctx context.Context, tx *gor
return err return err
} }
if actorID > 0 && currentUsage > 0 {
increaseLog := &entity.StockLog{
Increase: currentUsage,
LoggableType: string(utils.StockLogTypeMarketing),
LoggableId: deliveryProduct.Id,
ProductWarehouseId: marketingProduct.ProductWarehouseId,
CreatedBy: actorID,
Notes: "",
}
s.StockLogRepo.WithTx(tx).CreateOne(ctx, increaseLog, nil)
}
if err := deliveryProductRepo.ResetFifoFields(ctx, deliveryProduct.Id); err != nil { if err := deliveryProductRepo.ResetFifoFields(ctx, deliveryProduct.Id); err != nil {
return err return err
} }
@@ -306,9 +306,32 @@ func (s salesOrdersService) UpdateOne(c *fiber.Ctx, req *validation.Update, id u
for _, rp := range req.MarketingProducts { for _, rp := range req.MarketingProducts {
if old, ok := oldByPW[rp.ProductWarehouseId]; ok { if old, ok := oldByPW[rp.ProductWarehouseId]; ok {
// Hitung total_weight dan total_price otomatis // Get product untuk cek flag PAKAN atau OVK
productWarehouse, err := s.ProductWarehouseRepo.GetByID(c.Context(), rp.ProductWarehouseId, func(db *gorm.DB) *gorm.DB {
return db.Preload("Product.Flags")
})
if err != nil {
return err
}
// Cek apakah product punya flag PAKAN atau OVK
isPakanOrOVK := false
if productWarehouse.Product.Id != 0 && len(productWarehouse.Product.Flags) > 0 {
for _, flag := range productWarehouse.Product.Flags {
if flag.Name == string(utils.FlagPakan) || flag.Name == string(utils.FlagOVK) {
isPakanOrOVK = true
break
}
}
}
totalWeight := rp.Qty * rp.AvgWeight totalWeight := rp.Qty * rp.AvgWeight
totalPrice := rp.UnitPrice * rp.Qty var totalPrice float64
if isPakanOrOVK {
totalPrice = rp.Qty * rp.UnitPrice
} else {
totalPrice = totalWeight * rp.UnitPrice
}
updateBody := map[string]any{ updateBody := map[string]any{
"product_warehouse_id": rp.ProductWarehouseId, "product_warehouse_id": rp.ProductWarehouseId,
@@ -616,9 +639,34 @@ func (s salesOrdersService) Approval(c *fiber.Ctx, req *validation.Approve) ([]e
func (s *salesOrdersService) createMarketingProductWithDelivery(ctx context.Context, marketingId uint, rp validation.CreateMarketingProduct, marketingProductRepo repository.MarketingProductRepository, invDeliveryRepo repository.MarketingDeliveryProductRepository) error { func (s *salesOrdersService) createMarketingProductWithDelivery(ctx context.Context, marketingId uint, rp validation.CreateMarketingProduct, marketingProductRepo repository.MarketingProductRepository, invDeliveryRepo repository.MarketingDeliveryProductRepository) error {
// Hitung total_weight dan total_price otomatis // Get product untuk cek flag PAKAN atau OVK
productWarehouse, err := s.ProductWarehouseRepo.GetByID(ctx, rp.ProductWarehouseId, func(db *gorm.DB) *gorm.DB {
return db.Preload("Product.Flags")
})
if err != nil {
return err
}
// Cek apakah product punya flag PAKAN atau OVK
isPakanOrOVK := false
if productWarehouse.Product.Id != 0 && len(productWarehouse.Product.Flags) > 0 {
for _, flag := range productWarehouse.Product.Flags {
if flag.Name == string(utils.FlagPakan) || flag.Name == string(utils.FlagOVK) {
isPakanOrOVK = true
break
}
}
}
totalWeight := rp.Qty * rp.AvgWeight totalWeight := rp.Qty * rp.AvgWeight
totalPrice := rp.UnitPrice * rp.Qty var totalPrice float64
if isPakanOrOVK {
// PAKAN atau OVK: qty × unit_price
totalPrice = rp.Qty * rp.UnitPrice
} else {
// Produk lain: total_weight × unit_price
totalPrice = totalWeight * rp.UnitPrice
}
marketingProduct := &entity.MarketingProduct{ marketingProduct := &entity.MarketingProduct{
MarketingId: marketingId, MarketingId: marketingId,
@@ -15,9 +15,9 @@ func ConfigChecklistRoutes(v1 fiber.Router, u user.UserService, s configChecklis
route := v1.Group("/config-checklists") route := v1.Group("/config-checklists")
route.Use(m.Auth(u)) route.Use(m.Auth(u))
route.Get("/", ctrl.GetAll) route.Get("/", m.RequirePermissions(m.P_DailyChecklistActivityConfig), ctrl.GetAll)
route.Post("/", ctrl.CreateOne) route.Post("/", m.RequirePermissions(m.P_DailyChecklistActivityConfig), ctrl.CreateOne)
route.Get("/:id", ctrl.GetOne) route.Get("/:id", m.RequirePermissions(m.P_DailyChecklistActivityConfig), ctrl.GetOne)
route.Patch("/:id", ctrl.UpdateOne) route.Patch("/:id", m.RequirePermissions(m.P_DailyChecklistActivityConfig), ctrl.UpdateOne)
route.Delete("/:id", ctrl.DeleteOne) route.Delete("/:id", m.RequirePermissions(m.P_DailyChecklistActivityConfig), ctrl.DeleteOne)
} }
@@ -76,6 +76,9 @@ func (s *configChecklistService) 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 req.PercentageThresholdBad > req.PercentageThresholdEnough {
return nil, fiber.NewError(fiber.StatusBadRequest, "percentage_threshold_bad cannot be greater than percentage_threshold_enough")
}
date, err := time.Parse("2006-01-02", req.Date) date, err := time.Parse("2006-01-02", req.Date)
if err != nil { if err != nil {
@@ -100,6 +103,11 @@ func (s configChecklistService) UpdateOne(c *fiber.Ctx, req *validation.Update,
if err := s.Validate.Struct(req); err != nil { if err := s.Validate.Struct(req); err != nil {
return nil, err return nil, err
} }
if req.PercentageThresholdBad != nil && req.PercentageThresholdEnough != nil {
if *req.PercentageThresholdBad > *req.PercentageThresholdEnough {
return nil, fiber.NewError(fiber.StatusBadRequest, "percentage_threshold_bad cannot be greater than percentage_threshold_enough")
}
}
updateBody := make(map[string]any) updateBody := make(map[string]any)
@@ -14,6 +14,7 @@ type CustomerRelationDTO struct {
Name string `json:"name"` Name string `json:"name"`
Type string `json:"type"` Type string `json:"type"`
AccountNumber string `json:"account_number"` AccountNumber string `json:"account_number"`
Address string `json:"address,omitempty"`
Balance float64 `json:"balance"` Balance float64 `json:"balance"`
Pic *userDTO.UserRelationDTO `json:"pic,omitempty"` Pic *userDTO.UserRelationDTO `json:"pic,omitempty"`
} }
@@ -52,6 +53,8 @@ func ToCustomerRelationDTO(e entity.Customer) CustomerRelationDTO {
Name: e.Name, Name: e.Name,
Type: e.Type, Type: e.Type,
AccountNumber: e.AccountNumber, AccountNumber: e.AccountNumber,
Address: e.Address,
Balance: e.Balance,
Pic: pic, Pic: pic,
} }
} }
+5 -5
View File
@@ -15,9 +15,9 @@ func EmployeesRoutes(v1 fiber.Router, u user.UserService, s employees.EmployeesS
route := v1.Group("/employees") route := v1.Group("/employees")
route.Use(m.Auth(u)) route.Use(m.Auth(u))
route.Get("/", ctrl.GetAll) route.Get("/", m.RequirePermissions(m.P_DailyChecklistEmployee), ctrl.GetAll)
route.Post("/", ctrl.CreateOne) route.Post("/", m.RequirePermissions(m.P_DailyChecklistEmployee), ctrl.CreateOne)
route.Get("/:id", ctrl.GetOne) route.Get("/:id", m.RequirePermissions(m.P_DailyChecklistEmployee), ctrl.GetOne)
route.Patch("/:id", ctrl.UpdateOne) route.Patch("/:id", m.RequirePermissions(m.P_DailyChecklistEmployee), ctrl.UpdateOne)
route.Delete("/:id", ctrl.DeleteOne) route.Delete("/:id", m.RequirePermissions(m.P_DailyChecklistEmployee), ctrl.DeleteOne)
} }
@@ -37,7 +37,6 @@ type NonstockSupplierDTO struct {
Name string `json:"name"` Name string `json:"name"`
Alias string `json:"alias"` Alias string `json:"alias"`
Category string `json:"category"` Category string `json:"category"`
Price float64 `json:"price"`
} }
// === Mapper Functions === // === Mapper Functions ===
@@ -121,7 +120,6 @@ func toNonstockSupplierDTOs(relations []entity.NonstockSupplier) []NonstockSuppl
Name: relation.Supplier.Name, Name: relation.Supplier.Name,
Alias: relation.Supplier.Alias, Alias: relation.Supplier.Alias,
Category: relation.Supplier.Category, Category: relation.Supplier.Category,
Price: relation.Price,
}) })
} }
@@ -61,30 +61,20 @@ func (r *NonstockRepositoryImpl) SyncSuppliersDiff(ctx context.Context, tx *gorm
return err return err
} }
existingMap := make(map[uint]entity.NonstockSupplier, len(existing)) existingMap := make(map[uint]struct{}, len(existing))
for _, rel := range existing { for _, rel := range existing {
existingMap[rel.SupplierId] = rel existingMap[rel.SupplierId] = struct{}{}
} }
incomingMap := make(map[uint]struct{}, len(suppliers)) incomingMap := make(map[uint]struct{}, len(suppliers))
for _, rel := range suppliers { for _, rel := range suppliers {
incomingMap[rel.SupplierId] = struct{}{} incomingMap[rel.SupplierId] = struct{}{}
if existingRel, exists := existingMap[rel.SupplierId]; exists { if _, exists := existingMap[rel.SupplierId]; exists {
if existingRel.Price != rel.Price {
if err := db.WithContext(ctx).
Model(&entity.NonstockSupplier{}).
Where("nonstock_id = ? AND supplier_id = ?", nonstockID, rel.SupplierId).
Update("price", rel.Price).
Error; err != nil {
return err
}
}
continue continue
} }
record := entity.NonstockSupplier{ record := entity.NonstockSupplier{
NonstockId: nonstockID, NonstockId: nonstockID,
SupplierId: rel.SupplierId, SupplierId: rel.SupplierId,
Price: rel.Price,
} }
if err := db.WithContext(ctx).Create(&record).Error; err != nil { if err := db.WithContext(ctx).Create(&record).Error; err != nil {
return err return err
@@ -115,19 +115,18 @@ func (s *nonstockService) CreateOne(c *fiber.Ctx, req *validation.Create) (*enti
supplierLinks []entity.NonstockSupplier supplierLinks []entity.NonstockSupplier
supplierIDs []uint supplierIDs []uint
) )
if len(req.Suppliers) > 0 { if len(req.SupplierIDs) > 0 {
seen := make(map[uint]struct{}, len(req.Suppliers)) seen := make(map[uint]struct{}, len(req.SupplierIDs))
supplierLinks = make([]entity.NonstockSupplier, 0, len(req.Suppliers)) supplierLinks = make([]entity.NonstockSupplier, 0, len(req.SupplierIDs))
supplierIDs = make([]uint, 0, len(req.Suppliers)) supplierIDs = make([]uint, 0, len(req.SupplierIDs))
for _, supplier := range req.Suppliers { for _, supplierID := range req.SupplierIDs {
if _, exists := seen[supplier.SupplierID]; exists { if _, exists := seen[supplierID]; exists {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Duplicate supplier_id %d", supplier.SupplierID)) return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Duplicate supplier_id %d", supplierID))
} }
seen[supplier.SupplierID] = struct{}{} seen[supplierID] = struct{}{}
supplierIDs = append(supplierIDs, supplier.SupplierID) supplierIDs = append(supplierIDs, supplierID)
supplierLinks = append(supplierLinks, entity.NonstockSupplier{ supplierLinks = append(supplierLinks, entity.NonstockSupplier{
SupplierId: supplier.SupplierID, SupplierId: supplierID,
Price: supplier.Price,
}) })
} }
supplierList, supplierErr := s.Repository.GetSuppliersByIDs(ctx, supplierIDs) supplierList, supplierErr := s.Repository.GetSuppliersByIDs(ctx, supplierIDs)
@@ -212,21 +211,20 @@ func (s nonstockService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint
var supplierLinks []entity.NonstockSupplier var supplierLinks []entity.NonstockSupplier
var supplierUpdate bool var supplierUpdate bool
if req.Suppliers != nil { if req.SupplierIDs != nil {
supplierUpdate = true supplierUpdate = true
if len(*req.Suppliers) > 0 { if len(*req.SupplierIDs) > 0 {
seen := make(map[uint]struct{}, len(*req.Suppliers)) seen := make(map[uint]struct{}, len(*req.SupplierIDs))
supplierLinks = make([]entity.NonstockSupplier, 0, len(*req.Suppliers)) supplierLinks = make([]entity.NonstockSupplier, 0, len(*req.SupplierIDs))
supplierIDs := make([]uint, 0, len(*req.Suppliers)) supplierIDs := make([]uint, 0, len(*req.SupplierIDs))
for _, supplier := range *req.Suppliers { for _, supplierID := range *req.SupplierIDs {
if _, exists := seen[supplier.SupplierID]; exists { if _, exists := seen[supplierID]; exists {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Duplicate supplier_id %d", supplier.SupplierID)) return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Duplicate supplier_id %d", supplierID))
} }
seen[supplier.SupplierID] = struct{}{} seen[supplierID] = struct{}{}
supplierIDs = append(supplierIDs, supplier.SupplierID) supplierIDs = append(supplierIDs, supplierID)
supplierLinks = append(supplierLinks, entity.NonstockSupplier{ supplierLinks = append(supplierLinks, entity.NonstockSupplier{
SupplierId: supplier.SupplierID, SupplierId: supplierID,
Price: supplier.Price,
}) })
} }
@@ -1,22 +1,17 @@
package validation package validation
type SupplierPrice struct {
SupplierID uint `json:"supplier_id" validate:"required,gt=0"`
Price float64 `json:"price" validate:"required,gte=0"`
}
type Create struct { type Create struct {
Name string `json:"name" validate:"required_strict,min=3,max=50"` Name string `json:"name" validate:"required_strict,min=3,max=50"`
UomID uint `json:"uom_id" validate:"required,gt=0"` UomID uint `json:"uom_id" validate:"required,gt=0"`
Suppliers []SupplierPrice `json:"suppliers,omitempty" validate:"omitempty,dive"` SupplierIDs []uint `json:"supplier_ids,omitempty" validate:"omitempty,dive,gt=0"`
Flags []string `json:"flags" validate:"dive,max=50"` Flags []string `json:"flags" validate:"dive,max=50"`
} }
type Update struct { type Update struct {
Name *string `json:"name,omitempty" validate:"omitempty,min=3,max=50"` Name *string `json:"name,omitempty" validate:"omitempty,min=3,max=50"`
UomID *uint `json:"uom_id,omitempty" validate:"omitempty,gt=0"` UomID *uint `json:"uom_id,omitempty" validate:"omitempty,gt=0"`
Suppliers *[]SupplierPrice `json:"suppliers,omitempty" validate:"omitempty,dive"` SupplierIDs *[]uint `json:"supplier_ids,omitempty" validate:"omitempty,dive,gt=0"`
Flags *[]string `json:"flags,omitempty" validate:"omitempty,dive,max=50"` Flags *[]string `json:"flags,omitempty" validate:"omitempty,dive,max=50"`
} }
type Query struct { type Query struct {
@@ -15,9 +15,9 @@ func PhaseActivityRoutes(v1 fiber.Router, u user.UserService, s phaseActivity.Ph
route := v1.Group("/phase-activities") route := v1.Group("/phase-activities")
route.Use(m.Auth(u)) route.Use(m.Auth(u))
route.Get("/", ctrl.GetAll) route.Get("/", m.RequirePermissions(m.P_DailyChecklistActivity), ctrl.GetAll)
route.Post("/", ctrl.CreateOne) route.Post("/", m.RequirePermissions(m.P_DailyChecklistActivity), ctrl.CreateOne)
route.Get("/:id", ctrl.GetOne) route.Get("/:id", m.RequirePermissions(m.P_DailyChecklistActivity), ctrl.GetOne)
route.Patch("/:id", ctrl.UpdateOne) route.Patch("/:id", m.RequirePermissions(m.P_DailyChecklistActivity), ctrl.UpdateOne)
route.Delete("/:id", ctrl.DeleteOne) route.Delete("/:id", m.RequirePermissions(m.P_DailyChecklistActivity), ctrl.DeleteOne)
} }
@@ -110,6 +110,17 @@ func (s *phaseActivityService) CreateOne(c *fiber.Ctx, req *validation.Create) (
return nil, fiber.NewError(fiber.StatusBadRequest, "time_type cannot be empty") return nil, fiber.NewError(fiber.StatusBadRequest, "time_type cannot be empty")
} }
existing, err := s.Repository.First(c.Context(), func(db *gorm.DB) *gorm.DB {
return db.Where("phase_id = ? AND name = ? AND time_type = ?", phase.Id, name, timeType)
})
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
s.Log.Errorf("Failed to check phaseActivity uniqueness: %+v", err)
return nil, err
}
if existing != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "phase activity with same name and time_type already exists")
}
createBody := &entity.PhaseActivity{ createBody := &entity.PhaseActivity{
PhaseId: phase.Id, PhaseId: phase.Id,
Name: name, Name: name,
+5 -5
View File
@@ -15,9 +15,9 @@ func PhasesRoutes(v1 fiber.Router, u user.UserService, s phases.PhasesService) {
route := v1.Group("/phases") route := v1.Group("/phases")
route.Use(m.Auth(u)) route.Use(m.Auth(u))
route.Get("/", ctrl.GetAll) route.Get("/", m.RequirePermissions(m.P_DailyChecklistActivity), ctrl.GetAll)
route.Post("/", ctrl.CreateOne) route.Post("/", m.RequirePermissions(m.P_DailyChecklistActivity), ctrl.CreateOne)
route.Get("/:id", ctrl.GetOne) route.Get("/:id", m.RequirePermissions(m.P_DailyChecklistActivity), ctrl.GetOne)
route.Patch("/:id", ctrl.UpdateOne) route.Patch("/:id", m.RequirePermissions(m.P_DailyChecklistActivity), ctrl.UpdateOne)
route.Delete("/:id", ctrl.DeleteOne) route.Delete("/:id", m.RequirePermissions(m.P_DailyChecklistActivity), ctrl.DeleteOne)
} }
@@ -28,6 +28,7 @@ func (u *SupplierController) GetAll(c *fiber.Ctx) error {
Limit: c.QueryInt("limit", 10), Limit: c.QueryInt("limit", 10),
Search: c.Query("search", ""), Search: c.Query("search", ""),
Category: c.Query("category", ""), Category: c.Query("category", ""),
Flag: c.Query("flag", ""),
} }
if query.Page < 1 || query.Limit < 1 { if query.Page < 1 || query.Limit < 1 {
@@ -10,7 +10,6 @@ import (
type SupplierNonstockDTO struct { type SupplierNonstockDTO struct {
Id uint `json:"id"` Id uint `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Price float64 `json:"price"`
Uom *uomDTO.UomRelationDTO `json:"uom,omitempty"` Uom *uomDTO.UomRelationDTO `json:"uom,omitempty"`
Flags []string `json:"flags"` Flags []string `json:"flags"`
} }
@@ -43,7 +42,6 @@ func toSupplierNonstockDTOs(relations []entity.NonstockSupplier) []SupplierNonst
result = append(result, SupplierNonstockDTO{ result = append(result, SupplierNonstockDTO{
Id: Nonstock.Id, Id: Nonstock.Id,
Name: Nonstock.Name, Name: Nonstock.Name,
Price: relation.Price,
Uom: uomRef, Uom: uomRef,
Flags: flags, Flags: flags,
}) })
@@ -47,6 +47,10 @@ func (s supplierService) withRelations(db *gorm.DB) *gorm.DB {
Preload("NonstockSuppliers.Nonstock.Flags") Preload("NonstockSuppliers.Nonstock.Flags")
} }
func (s supplierService) withListRelations(db *gorm.DB) *gorm.DB {
return db.Preload("CreatedUser")
}
func (s supplierService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Supplier, int64, error) { func (s supplierService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Supplier, 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
@@ -63,7 +67,7 @@ func (s supplierService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit
offset := (params.Page - 1) * params.Limit offset := (params.Page - 1) * params.Limit
suppliers, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { suppliers, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db) db = s.withListRelations(db)
if params.Search != "" { if params.Search != "" {
return db.Where("name ILIKE ?", "%"+params.Search+"%") return db.Where("name ILIKE ?", "%"+params.Search+"%")
} }
@@ -72,7 +76,23 @@ func (s supplierService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit
db = db.Where("category ILIKE ?", "%"+params.Category+"%") db = db.Where("category ILIKE ?", "%"+params.Category+"%")
} }
return db.Order("created_at DESC").Order("updated_at DESC") if params.Flag != "" {
flag := strings.ToUpper(params.Flag)
db = db.Where(`
EXISTS (
SELECT 1
FROM nonstock_suppliers nsup
JOIN nonstocks n ON n.id = nsup.nonstock_id
JOIN flags f ON f.flagable_id = n.id AND f.flagable_type = ?
WHERE nsup.supplier_id = suppliers.id
AND UPPER(f.name) = ?
)`,
entity.FlagableTypeNonstock,
flag,
)
}
return db.Order("suppliers.created_at DESC").Order("suppliers.updated_at DESC")
}) })
if err != nil { if err != nil {
@@ -32,7 +32,8 @@ 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"`
Flag string `query:"flag" validate:"omitempty"`
Search string `query:"search" validate:"omitempty,max=50"` Search string `query:"search" validate:"omitempty,max=50"`
Category string `query:"category" validate:"omitempty,max=50"` Category string `query:"category" validate:"omitempty,max=50"`
} }
@@ -125,7 +125,11 @@ func (s *warehouseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
} }
typ := strings.ToUpper(req.Type) typ := strings.ToUpper(req.Type)
if err := validateWarehouseTypeRequirements(typ, &req.AreaId, req.LocationId, req.KandangId); err != nil { createValidationOpts := WarehouseTypeValidationOptions{
LocationProvided: req.LocationId != nil,
KandangProvided: req.KandangId != nil,
}
if err := validateWarehouseTypeRequirements(typ, &req.AreaId, &req.LocationId, &req.KandangId, createValidationOpts); err != nil {
return nil, err return nil, err
} }
if err := m.EnsureAreaAccess(c, s.Repository.DB(), req.AreaId); err != nil { if err := m.EnsureAreaAccess(c, s.Repository.DB(), req.AreaId); err != nil {
@@ -251,9 +255,22 @@ func (s warehouseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
finalKandangId = req.KandangId finalKandangId = req.KandangId
} }
if err := validateWarehouseTypeRequirements(finalType, &finalAreaId, finalLocationId, finalKandangId); err != nil { originalLocationId := finalLocationId
originalKandangId := finalKandangId
updateValidationOpts := WarehouseTypeValidationOptions{
AutoClear: true,
LocationProvided: req.LocationId != nil,
KandangProvided: req.KandangId != nil,
}
if err := validateWarehouseTypeRequirements(finalType, &finalAreaId, &finalLocationId, &finalKandangId, updateValidationOpts); err != nil {
return nil, err return nil, err
} }
if originalLocationId != finalLocationId {
updateBody["location_id"] = nil
}
if originalKandangId != finalKandangId {
updateBody["kandang_id"] = nil
}
if len(updateBody) == 0 { if len(updateBody) == 0 {
return s.GetOne(c, id) return s.GetOne(c, id)
@@ -285,47 +302,65 @@ func (s warehouseService) DeleteOne(c *fiber.Ctx, id uint) error {
return nil return nil
} }
func validateWarehouseTypeRequirements(typ string, areaID *uint, locationID *uint, kandangID *uint) error { type WarehouseTypeValidationOptions struct {
AutoClear bool
LocationProvided bool
KandangProvided bool
}
func validateWarehouseTypeRequirements(typ string, areaID *uint, locationID **uint, kandangID **uint, opts WarehouseTypeValidationOptions) error {
switch utils.WarehouseType(typ) { switch utils.WarehouseType(typ) {
case utils.WarehouseTypeArea: case utils.WarehouseTypeArea:
if areaID == nil || *areaID == 0 { if areaID == nil || *areaID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "area_id is required when type is AREA") return fiber.NewError(fiber.StatusBadRequest, "area_id is required when type is AREA")
} }
if locationID != nil { if locationID != nil && *locationID != nil {
return fiber.NewError(fiber.StatusBadRequest, "location_id must not be provided when type is AREA") if opts.AutoClear && !opts.LocationProvided {
*locationID = nil
} else {
return fiber.NewError(fiber.StatusBadRequest, "location_id must not be provided when type is AREA")
}
} }
if kandangID != nil { if kandangID != nil && *kandangID != nil {
return fiber.NewError(fiber.StatusBadRequest, "kandang_id must not be provided when type is AREA") if opts.AutoClear && !opts.KandangProvided {
*kandangID = nil
} else {
return fiber.NewError(fiber.StatusBadRequest, "kandang_id must not be provided when type is AREA")
}
} }
return nil return nil
case utils.WarehouseTypeLokasi: case utils.WarehouseTypeLokasi:
if areaID == nil || *areaID == 0 { if areaID == nil || *areaID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "area_id is required when type is LOCATION") return fiber.NewError(fiber.StatusBadRequest, "area_id is required when type is LOCATION")
} }
if locationID == nil { if locationID == nil || *locationID == nil {
return fiber.NewError(fiber.StatusBadRequest, "location_id is required when type is LOCATION") return fiber.NewError(fiber.StatusBadRequest, "location_id is required when type is LOCATION")
} }
if *locationID == 0 { if **locationID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "location_id must be greater than 0 when type is LOCATION") return fiber.NewError(fiber.StatusBadRequest, "location_id must be greater than 0 when type is LOCATION")
} }
if kandangID != nil { if kandangID != nil && *kandangID != nil {
return fiber.NewError(fiber.StatusBadRequest, "kandang_id must not be provided when type is LOCATION") if opts.AutoClear && !opts.KandangProvided {
*kandangID = nil
} else {
return fiber.NewError(fiber.StatusBadRequest, "kandang_id must not be provided when type is LOCATION")
}
} }
return nil return nil
case utils.WarehouseTypeKandang: case utils.WarehouseTypeKandang:
if areaID == nil || *areaID == 0 { if areaID == nil || *areaID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "area_id is required when type is KANDANG") return fiber.NewError(fiber.StatusBadRequest, "area_id is required when type is KANDANG")
} }
if locationID == nil { if locationID == nil || *locationID == nil {
return fiber.NewError(fiber.StatusBadRequest, "location_id is required when type is KANDANG") return fiber.NewError(fiber.StatusBadRequest, "location_id is required when type is KANDANG")
} }
if *locationID == 0 { if **locationID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "location_id must be greater than 0 when type is KANDANG") return fiber.NewError(fiber.StatusBadRequest, "location_id must be greater than 0 when type is KANDANG")
} }
if kandangID == nil { if kandangID == nil || *kandangID == nil {
return fiber.NewError(fiber.StatusBadRequest, "kandang_id is required when type is KANDANG") return fiber.NewError(fiber.StatusBadRequest, "kandang_id is required when type is KANDANG")
} }
if *kandangID == 0 { if **kandangID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "kandang_id must be greater than 0 when type is KANDANG") return fiber.NewError(fiber.StatusBadRequest, "kandang_id must be greater than 0 when type is KANDANG")
} }
return nil return nil
@@ -200,9 +200,9 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti
newChikins = append(newChikins, newChickin) newChikins = append(newChikins, newChickin)
totalPopulationQty, err := s.ProjectflockPopulationRepo.GetTotalQtyByProjectFlockKandangID(c.Context(), req.ProjectFlockKandangId) totalPopulationQty, err := s.ProjectflockPopulationRepo.GetTotalQtyByProductWarehouseID(c.Context(), chickinReq.ProductWarehouseId)
if err != nil { if err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to get total population quantity for project_flock_kandang %d", req.ProjectFlockKandangId)) return nil, fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to get total population quantity for product warehouse %d", chickinReq.ProductWarehouseId))
} }
availableQty := productWarehouse.Quantity - totalPopulationQty availableQty := productWarehouse.Quantity - totalPopulationQty
@@ -584,7 +584,6 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit
return updated, nil return updated, nil
} }
// autoAddFlagToProduct adds target flag to product if not already present (idempotent)
func (s *chickinService) autoAddFlagToProduct(ctx context.Context, tx *gorm.DB, productID uint, targetFlag utils.FlagType) error { func (s *chickinService) autoAddFlagToProduct(ctx context.Context, tx *gorm.DB, productID uint, targetFlag utils.FlagType) error {
if s.ProductRepo == nil { if s.ProductRepo == nil {
return nil return nil
@@ -1,6 +1,7 @@
package dto package dto
import ( import (
"strconv"
"time" "time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
@@ -53,6 +54,7 @@ type ProjectFlockKandangListDTO struct {
ProjectFlockKandangRelationDTO ProjectFlockKandangRelationDTO
ProjectFlock *ProjectFlockDTO `json:"project_flock,omitempty"` ProjectFlock *ProjectFlockDTO `json:"project_flock,omitempty"`
Kandang *kandangDTO.KandangRelationDTO `json:"kandang,omitempty"` Kandang *kandangDTO.KandangRelationDTO `json:"kandang,omitempty"`
NameWithPeriod string `json:"name_with_period"`
CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"` CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
Approval *approvalDTO.ApprovalRelationDTO `json:"approval,omitempty"` Approval *approvalDTO.ApprovalRelationDTO `json:"approval,omitempty"`
@@ -104,6 +106,7 @@ func ToProjectFlockKandangDetailDTOWithAvailableQty(e entity.ProjectFlockKandang
ProjectFlockKandangRelationDTO: ToProjectFlockKandangRelationDTO(e), ProjectFlockKandangRelationDTO: ToProjectFlockKandangRelationDTO(e),
ProjectFlock: toProjectFlockDTO(projectFlockSummary), ProjectFlock: toProjectFlockDTO(projectFlockSummary),
Kandang: toKandangRelation(e.Kandang), Kandang: toKandangRelation(e.Kandang),
NameWithPeriod: toNameWithPeriod(e.Kandang, e.Period),
CreatedAt: e.CreatedAt, CreatedAt: e.CreatedAt,
CreatedUser: toCreatedUserDTO(e.ProjectFlock), CreatedUser: toCreatedUserDTO(e.ProjectFlock),
Approval: toApprovalDTOSelector(e, func(x entity.ProjectFlockKandang) *entity.Approval { return x.LatestProjectFlockApproval }), Approval: toApprovalDTOSelector(e, func(x entity.ProjectFlockKandang) *entity.Approval { return x.LatestProjectFlockApproval }),
@@ -126,6 +129,16 @@ func toKandangRelation(kandang entity.Kandang) *kandangDTO.KandangRelationDTO {
return &mapped return &mapped
} }
func toNameWithPeriod(kandang entity.Kandang, period int) string {
if kandang.Name == "" {
return ""
}
if period == 0 {
return kandang.Name
}
return kandang.Name + " Period " + strconv.Itoa(period)
}
func toApprovalDTOSelector( func toApprovalDTOSelector(
e entity.ProjectFlockKandang, selector func(entity.ProjectFlockKandang) *entity.Approval) *approvalDTO.ApprovalRelationDTO { e entity.ProjectFlockKandang, selector func(entity.ProjectFlockKandang) *entity.Approval) *approvalDTO.ApprovalRelationDTO {
approval := selector(e) approval := selector(e)
@@ -147,6 +160,7 @@ func ToProjectFlockKandangListDTO(e entity.ProjectFlockKandang) ProjectFlockKand
ProjectFlockKandangRelationDTO: ToProjectFlockKandangRelationDTO(e), ProjectFlockKandangRelationDTO: ToProjectFlockKandangRelationDTO(e),
ProjectFlock: toProjectFlockDTO(projectFlockSummary), ProjectFlock: toProjectFlockDTO(projectFlockSummary),
Kandang: toKandangRelation(e.Kandang), Kandang: toKandangRelation(e.Kandang),
NameWithPeriod: toNameWithPeriod(e.Kandang, e.Period),
CreatedAt: e.CreatedAt, CreatedAt: e.CreatedAt,
CreatedUser: toCreatedUserDTO(e.ProjectFlock), CreatedUser: toCreatedUserDTO(e.ProjectFlock),
Approval: toApprovalDTOSelector(e, func(x entity.ProjectFlockKandang) *entity.Approval { return x.LatestProjectFlockApproval }), Approval: toApprovalDTOSelector(e, func(x entity.ProjectFlockKandang) *entity.Approval { return x.LatestProjectFlockApproval }),
@@ -287,6 +287,11 @@ func (u *ProjectflockController) LookupProjectFlockKandang(c *fiber.Ctx) error {
} else { } else {
dtoResult.AvailableQuantity = population dtoResult.AvailableQuantity = population
} }
if chickinDate, err := u.ProjectflockService.GetProjectFlockKandangChickinDate(c, result.Id); err != nil {
return err
} else if chickinDate != nil {
dtoResult.ChickInDate = chickinDate
}
if warehouse, werr := u.ProjectflockService.GetWarehouseByKandangID(c, result.KandangId); werr != nil { if warehouse, werr := u.ProjectflockService.GetWarehouseByKandangID(c, result.KandangId); werr != nil {
return werr return werr
} else if warehouse != nil { } else if warehouse != nil {
@@ -1,6 +1,8 @@
package dto package dto
import ( import (
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
areaDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/dto" areaDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/areas/dto"
fcrDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/fcrs/dto" fcrDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/fcrs/dto"
@@ -38,6 +40,7 @@ type ProjectFlockKandangDTO struct {
ProjectFlock *ProjectFlockWithPivotDTO `json:"project_flock,omitempty"` ProjectFlock *ProjectFlockWithPivotDTO `json:"project_flock,omitempty"`
AvailableQuantity float64 `json:"available_quantity"` AvailableQuantity float64 `json:"available_quantity"`
Population *float64 `json:"population,omitempty"` Population *float64 `json:"population,omitempty"`
ChickInDate *time.Time `json:"chick_in_date,omitempty"`
} }
func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangDTO { func ToProjectFlockKandangDTO(e entity.ProjectFlockKandang) ProjectFlockKandangDTO {
@@ -2,6 +2,7 @@ package repository
import ( import (
"context" "context"
"math"
"gitlab.com/mbugroup/lti-api.git/internal/common/repository" "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
@@ -16,6 +17,7 @@ type ProjectFlockPopulationRepository interface {
GetTotalQtyByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error) GetTotalQtyByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error)
GetTotalQtyByProductWarehouseID(ctx context.Context, productWarehouseID uint) (float64, error) GetTotalQtyByProductWarehouseID(ctx context.Context, productWarehouseID uint) (float64, error)
GetAvailableQtyByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error) GetAvailableQtyByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (float64, error)
GetTotalChickInByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (int64, error)
CreateOne(ctx context.Context, entity *entity.ProjectFlockPopulation, modifier func(*gorm.DB) *gorm.DB) error CreateOne(ctx context.Context, entity *entity.ProjectFlockPopulation, modifier func(*gorm.DB) *gorm.DB) error
PatchOne(ctx context.Context, id uint, updates map[string]any, modifier func(*gorm.DB) *gorm.DB) error PatchOne(ctx context.Context, id uint, updates map[string]any, modifier func(*gorm.DB) *gorm.DB) error
@@ -111,7 +113,7 @@ func (r *projectFlockPopulationRepositoryImpl) GetTotalQtyByProductWarehouseID(c
err := r.DB().WithContext(ctx). err := r.DB().WithContext(ctx).
Model(&entity.ProjectFlockPopulation{}). Model(&entity.ProjectFlockPopulation{}).
Where("product_warehouse_id = ?", productWarehouseID). Where("product_warehouse_id = ?", productWarehouseID).
Select("COALESCE(SUM(total_qty), 0)"). Select("COALESCE(SUM(total_qty - total_used_qty), 0)").
Scan(&total).Error Scan(&total).Error
if err != nil { if err != nil {
return 0, err return 0, err
@@ -135,3 +137,22 @@ func (r *projectFlockPopulationRepositoryImpl) GetAvailableQtyByProjectFlockKand
} }
return total, nil return total, nil
} }
func (r *projectFlockPopulationRepositoryImpl) GetTotalChickInByProjectFlockKandangID(ctx context.Context, projectFlockKandangID uint) (int64, error) {
var total float64
err := r.DB().WithContext(ctx).
Table("project_flock_populations").
Select("COALESCE(SUM(project_flock_populations.total_qty - project_flock_populations.total_used_qty), 0) AS total_qty").
Joins("JOIN project_chickins ON project_chickins.id = project_flock_populations.project_chickin_id").
Where("project_chickins.project_flock_kandang_id = ?", projectFlockKandangID).
Scan(&total).Error
if err != nil {
return 0, err
}
if total < 0 {
total = 0
}
return int64(math.Round(total)), nil
}
@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"strconv" "strconv"
"strings" "strings"
"time"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" 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"
@@ -42,6 +43,7 @@ type ProjectflockService interface {
DeleteOne(ctx *fiber.Ctx, id uint) error DeleteOne(ctx *fiber.Ctx, id uint) error
GetProjectFlockKandangByProjectAndKandang(ctx *fiber.Ctx, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, float64, error) GetProjectFlockKandangByProjectAndKandang(ctx *fiber.Ctx, projectFlockID uint, kandangID uint) (*entity.ProjectFlockKandang, float64, error)
GetProjectFlockKandangPopulation(ctx *fiber.Ctx, projectFlockKandangID uint) (float64, error) GetProjectFlockKandangPopulation(ctx *fiber.Ctx, projectFlockKandangID uint) (float64, error)
GetProjectFlockKandangChickinDate(ctx *fiber.Ctx, projectFlockKandangID uint) (*time.Time, error)
GetPeriodSummary(ctx *fiber.Ctx, locationID uint) ([]KandangPeriodSummary, error) GetPeriodSummary(ctx *fiber.Ctx, locationID uint) ([]KandangPeriodSummary, error)
GetProjectPeriods(ctx *fiber.Ctx, projectIDs []uint) (map[uint]int, error) GetProjectPeriods(ctx *fiber.Ctx, projectIDs []uint) (map[uint]int, error)
Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.ProjectFlock, error) Approval(ctx *fiber.Ctx, req *validation.Approve) ([]entity.ProjectFlock, error)
@@ -473,6 +475,35 @@ func (s projectflockService) GetProjectFlockKandangPopulation(ctx *fiber.Ctx, pr
return total, nil return total, nil
} }
func (s projectflockService) GetProjectFlockKandangChickinDate(ctx *fiber.Ctx, projectFlockKandangID uint) (*time.Time, error) {
if s.PopulationRepo == nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Project flock population repository is not configured")
}
if projectFlockKandangID == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is required")
}
populations, err := s.PopulationRepo.GetByProjectFlockKandangID(ctx.Context(), projectFlockKandangID)
if err != nil {
s.Log.Errorf("Failed to fetch populations for project flock kandang %d: %+v", projectFlockKandangID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock kandang chick in date")
}
var earliest *time.Time
for _, pop := range populations {
if pop.ProjectChickin == nil || pop.ProjectChickin.ChickInDate.IsZero() {
continue
}
chickinDate := pop.ProjectChickin.ChickInDate
if earliest == nil || chickinDate.Before(*earliest) {
copy := chickinDate
earliest = &copy
}
}
return earliest, nil
}
func (s projectflockService) GetProjectFlockKandangByParams(ctx *fiber.Ctx, idStr string, projectFlockIdStr string, kandangIdStr string) (*entity.ProjectFlockKandang, float64, error) { func (s projectflockService) GetProjectFlockKandangByParams(ctx *fiber.Ctx, idStr string, projectFlockIdStr string, kandangIdStr string) (*entity.ProjectFlockKandang, float64, error) {
idStr = strings.TrimSpace(idStr) idStr = strings.TrimSpace(idStr)
projectFlockIdStr = strings.TrimSpace(projectFlockIdStr) projectFlockIdStr = strings.TrimSpace(projectFlockIdStr)
@@ -3,6 +3,8 @@ package controller
import ( import (
"math" "math"
"strconv" "strconv"
"strings"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/dto" "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/dto"
service "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services" service "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services"
@@ -26,8 +28,9 @@ func (u *RecordingController) GetAll(c *fiber.Ctx) error {
projectFlockID := c.QueryInt("project_flock_kandang_id", 0) projectFlockID := c.QueryInt("project_flock_kandang_id", 0)
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"),
} }
if projectFlockID > 0 { if projectFlockID > 0 {
query.ProjectFlockKandangId = uint(projectFlockID) query.ProjectFlockKandangId = uint(projectFlockID)
@@ -81,7 +84,16 @@ func (u *RecordingController) GetNextDay(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is required") return fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is required")
} }
nextDay, err := u.RecordingService.GetNextDay(c, uint(projectFlockID)) recordTime := time.Now().UTC()
if recordDate := strings.TrimSpace(c.Query("record_date")); recordDate != "" {
parsed, err := time.Parse("2006-01-02", recordDate)
if err != nil {
return fiber.NewError(fiber.StatusBadRequest, "record_date must be in YYYY-MM-DD format")
}
recordTime = parsed.UTC()
}
nextDay, err := u.RecordingService.GetNextDay(c, uint(projectFlockID), recordTime)
if err != nil { if err != nil {
return err return err
} }
@@ -15,13 +15,13 @@ import (
// === DTO Structs === // === DTO Structs ===
type RecordingProjectFlockDTO struct { type RecordingProjectFlockDTO struct {
ProjectFlockKandangId uint `json:"project_flock_kandang_id"` ProjectFlockKandangId uint `json:"project_flock_kandang_id"`
FlockName string `json:"flock_name"` FlockName string `json:"flock_name"`
ProjectFlockCategory string `json:"project_flock_category"` ProjectFlockCategory string `json:"project_flock_category"`
Period int `json:"period"` Period int `json:"period"`
ProductionStandart *RecordingProductionStandardDTO `json:"production_standart,omitempty"` ProductionStandart *RecordingProductionStandardDTO `json:"production_standart,omitempty"`
Fcr *RecordingFcrDTO `json:"fcr,omitempty"` Fcr *RecordingFcrDTO `json:"fcr,omitempty"`
TotalChickQty float64 `json:"total_chick_qty"` TotalChickQty float64 `json:"total_chick_qty"`
} }
type RecordingProductionStandardDTO struct { type RecordingProductionStandardDTO struct {
@@ -53,6 +53,13 @@ type RecordingLocationDTO struct {
Address string `json:"address"` Address string `json:"address"`
} }
type RecordingKandangDTO struct {
Id uint `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Capacity float64 `json:"capacity"`
}
type RecordingWarehouseDTO struct { type RecordingWarehouseDTO struct {
Id uint `json:"id"` Id uint `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -82,12 +89,14 @@ type RecordingListDTO struct {
CreatedUser *userDTO.UserRelationDTO `json:"created_user,omitempty"` CreatedUser *userDTO.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"`
Kandang *RecordingKandangDTO `json:"kandang,omitempty"`
Location *RecordingLocationDTO `json:"location,omitempty"`
} }
type RecordingDetailDTO struct { type RecordingDetailDTO struct {
RecordingListDTO RecordingListDTO
Warehouse *RecordingWarehouseDTO `json:"warehouse,omitempty"` ProductCategory string `json:"product_category"`
ProductCategory string `json:"product_category"` Warehouse *RecordingWarehouseDTO `json:"warehouse,omitempty"`
Depletions []RecordingDepletionDTO `json:"depletions"` Depletions []RecordingDepletionDTO `json:"depletions"`
Stocks []RecordingStockDTO `json:"stocks"` Stocks []RecordingStockDTO `json:"stocks"`
Eggs []RecordingEggDTO `json:"eggs"` Eggs []RecordingEggDTO `json:"eggs"`
@@ -133,11 +142,11 @@ func ToRecordingDetailDTO(e entity.Recording) RecordingDetailDTO {
return RecordingDetailDTO{ return RecordingDetailDTO{
RecordingListDTO: listDTO, RecordingListDTO: listDTO,
Warehouse: recordingWarehouseDTO(e), ProductCategory: recordingProductCategory(e),
ProductCategory: recordingProductCategory(e), Warehouse: recordingWarehouseDTO(e),
Depletions: ToRecordingDepletionDTOs(e.Depletions), Depletions: ToRecordingDepletionDTOs(e.Depletions),
Stocks: ToRecordingStockDTOs(e.Stocks), Stocks: ToRecordingStockDTOs(e.Stocks),
Eggs: ToRecordingEggDTOs(e.Eggs), Eggs: ToRecordingEggDTOs(e.Eggs),
} }
} }
@@ -203,6 +212,8 @@ func toRecordingListDTO(e entity.Recording) RecordingListDTO {
CreatedAt: e.CreatedAt, CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt, UpdatedAt: e.UpdatedAt,
CreatedUser: createdUser, CreatedUser: createdUser,
Kandang: recordingKandangDTO(e),
Location: recordingKandangLocationDTO(e),
} }
} }
@@ -214,20 +225,20 @@ func toRecordingRelationDTO(e entity.Recording) RecordingRelationDTO {
} }
return RecordingRelationDTO{ return RecordingRelationDTO{
Id: e.Id, Id: e.Id,
ProjectFlock: toRecordingProjectFlockDTO(e), ProjectFlock: toRecordingProjectFlockDTO(e),
RecordDatetime: e.RecordDatetime, RecordDatetime: e.RecordDatetime,
Day: intValue(e.Day), Day: intValue(e.Day),
TotalDepletionQty: floatValue(e.TotalDepletionQty), TotalDepletionQty: floatValue(e.TotalDepletionQty),
CumDepletionRate: floatValue(e.CumDepletionRate), CumDepletionRate: floatValue(e.CumDepletionRate),
CumIntake: intValue(e.CumIntake), CumIntake: intValue(e.CumIntake),
FcrValue: floatValue(e.FcrValue), FcrValue: floatValue(e.FcrValue),
HenDay: floatValue(e.HenDay), HenDay: floatValue(e.HenDay),
HenHouse: floatValue(e.HenHouse), HenHouse: floatValue(e.HenHouse),
FeedIntake: floatValue(e.FeedIntake), FeedIntake: floatValue(e.FeedIntake),
EggMass: floatValue(e.EggMass), EggMass: floatValue(e.EggMass),
EggWeight: floatValue(e.EggWeight), EggWeight: floatValue(e.EggWeight),
Approval: latestApproval, Approval: latestApproval,
} }
} }
@@ -321,6 +332,34 @@ func recordingWarehouseDTO(e entity.Recording) *RecordingWarehouseDTO {
return mapWarehouseDTO(&pw.Warehouse) return mapWarehouseDTO(&pw.Warehouse)
} }
func recordingKandangDTO(e entity.Recording) *RecordingKandangDTO {
if e.ProjectFlockKandang == nil || e.ProjectFlockKandang.Kandang.Id == 0 {
return nil
}
kandang := e.ProjectFlockKandang.Kandang
return &RecordingKandangDTO{
Id: kandang.Id,
Name: kandang.Name,
Status: kandang.Status,
Capacity: kandang.Capacity,
}
}
func recordingKandangLocationDTO(e entity.Recording) *RecordingLocationDTO {
if e.ProjectFlockKandang == nil || e.ProjectFlockKandang.Kandang.Id == 0 {
return nil
}
location := e.ProjectFlockKandang.Kandang.Location
if location.Id == 0 {
return nil
}
return &RecordingLocationDTO{
Id: location.Id,
Name: location.Name,
Address: location.Address,
}
}
func primaryProductWarehouse(e entity.Recording) *entity.ProductWarehouse { func primaryProductWarehouse(e entity.Recording) *entity.ProductWarehouse {
if len(e.Stocks) > 0 { if len(e.Stocks) > 0 {
pw := e.Stocks[0].ProductWarehouse pw := e.Stocks[0].ProductWarehouse
@@ -16,6 +16,7 @@ import (
rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
rRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" rRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories"
sRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services" sRecording "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/services"
rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
@@ -31,6 +32,7 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
projectFlockPopulationRepo := rProjectFlock.NewProjectFlockPopulationRepository(db) projectFlockPopulationRepo := rProjectFlock.NewProjectFlockPopulationRepository(db)
productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db) productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db)
stockAllocationRepo := commonRepo.NewStockAllocationRepository(db) stockAllocationRepo := commonRepo.NewStockAllocationRepository(db)
stockLogRepo := rStockLogs.NewStockLogRepository(db)
productionStandardRepo := rProductionStandard.NewProductionStandardRepository(db) productionStandardRepo := rProductionStandard.NewProductionStandardRepository(db)
productionStandardDetailRepo := rProductionStandard.NewProductionStandardDetailRepository(db) productionStandardDetailRepo := rProductionStandard.NewProductionStandardDetailRepository(db)
standardGrowthDetailRepo := rProductionStandard.NewStandardGrowthDetailRepository(db) standardGrowthDetailRepo := rProductionStandard.NewStandardGrowthDetailRepository(db)
@@ -43,6 +45,22 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
) )
fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log) fifoService := commonSvc.NewFifoService(db, stockAllocationRepo, productWarehouseRepo, utils.Log)
if err := fifoService.RegisterStockable(fifo.StockableConfig{
Key: fifo.StockableKeyRecordingEgg,
Table: "recording_eggs",
Columns: fifo.StockableColumns{
ID: "id",
ProductWarehouseID: "product_warehouse_id",
TotalQuantity: "total_qty",
TotalUsedQuantity: "total_used",
CreatedAt: "created_at",
},
OrderBy: []string{"created_at ASC", "id ASC"},
}); err != nil {
if !strings.Contains(strings.ToLower(err.Error()), "already registered") {
panic(fmt.Sprintf("failed to register recording egg stockable workflow: %v", err))
}
}
if err := fifoService.RegisterUsable(fifo.UsableConfig{ if err := fifoService.RegisterUsable(fifo.UsableConfig{
Key: fifo.UsableKeyRecordingStock, Key: fifo.UsableKeyRecordingStock,
Table: "recording_stocks", Table: "recording_stocks",
@@ -58,6 +76,28 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
panic(fmt.Sprintf("failed to register recording usable workflow: %v", err)) panic(fmt.Sprintf("failed to register recording usable workflow: %v", err))
} }
} }
if err := fifoService.RegisterUsable(fifo.UsableConfig{
Key: fifo.UsableKeyRecordingDepletion,
Table: "recording_depletions",
Columns: fifo.UsableColumns{
ID: "id",
ProductWarehouseID: "source_product_warehouse_id",
UsageQuantity: "qty",
PendingQuantity: "pending_qty",
CreatedAt: "id",
},
ExcludedStockables: []fifo.StockableKey{
fifo.StockableKeyTransferToLayingIn,
fifo.StockableKeyStockTransferIn,
fifo.StockableKeyAdjustmentIn,
fifo.StockableKeyPurchaseItems,
fifo.StockableKeyRecordingEgg,
},
}); err != nil {
if !strings.Contains(strings.ToLower(err.Error()), "already registered") {
panic(fmt.Sprintf("failed to register recording depletion usable workflow: %v", err))
}
}
approvalRepo := commonRepo.NewApprovalRepository(db) approvalRepo := commonRepo.NewApprovalRepository(db)
approvalService := commonSvc.NewApprovalService(approvalRepo) approvalService := commonSvc.NewApprovalService(approvalRepo)
@@ -75,6 +115,7 @@ func (RecordingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate
approvalRepo, approvalRepo,
approvalService, approvalService,
fifoService, fifoService,
stockLogRepo,
productionStandardService, productionStandardService,
validate, validate,
) )
@@ -17,6 +17,7 @@ type RecordingRepository interface {
repository.BaseRepository[entity.Recording] repository.BaseRepository[entity.Recording]
WithRelations(db *gorm.DB) *gorm.DB WithRelations(db *gorm.DB) *gorm.DB
ApplySearchFilters(db *gorm.DB, rawSearch string) *gorm.DB
GetLatestByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (*entity.Recording, error) GetLatestByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (*entity.Recording, error)
GenerateNextDay(tx *gorm.DB, projectFlockKandangId uint) (int, error) GenerateNextDay(tx *gorm.DB, projectFlockKandangId uint) (int, error)
@@ -24,6 +25,7 @@ type RecordingRepository interface {
DeleteStocks(tx *gorm.DB, recordingID uint) error DeleteStocks(tx *gorm.DB, recordingID uint) error
ListStocks(tx *gorm.DB, recordingID uint) ([]entity.RecordingStock, error) ListStocks(tx *gorm.DB, recordingID uint) ([]entity.RecordingStock, error)
UpdateStockUsage(tx *gorm.DB, stockID uint, usageQty, pendingQty float64) error UpdateStockUsage(tx *gorm.DB, stockID uint, usageQty, pendingQty float64) error
UpdateDepletionPending(tx *gorm.DB, depletionID uint, pendingQty float64) error
CreateDepletions(tx *gorm.DB, depletions []entity.RecordingDepletion) error CreateDepletions(tx *gorm.DB, depletions []entity.RecordingDepletion) error
DeleteDepletions(tx *gorm.DB, recordingID uint) error DeleteDepletions(tx *gorm.DB, recordingID uint) error
@@ -44,6 +46,7 @@ type RecordingRepository interface {
GetEggSummaryByRecording(tx *gorm.DB, recordingID uint) (totalQty float64, totalWeightGrams float64, err error) GetEggSummaryByRecording(tx *gorm.DB, recordingID uint) (totalQty float64, totalWeightGrams float64, err error)
GetCumulativeEggQtyByProjectFlockKandang(tx *gorm.DB, projectFlockKandangId uint, recordTime time.Time) (float64, error) GetCumulativeEggQtyByProjectFlockKandang(tx *gorm.DB, projectFlockKandangId uint, recordTime time.Time) (float64, error)
GetFcrStandardNumber(tx *gorm.DB, fcrId uint, currentWeightKg float64) (float64, bool, error) GetFcrStandardNumber(tx *gorm.DB, fcrId uint, currentWeightKg float64) (float64, bool, error)
GetTotalWeightProducedFromUniformityByProjectFlockID(ctx context.Context, projectFlockID uint) (float64, error)
GetProductionWeightAndQtyByProjectFlockID(ctx context.Context, projectFlockID uint) (totalWeight float64, totalQty float64, err error) GetProductionWeightAndQtyByProjectFlockID(ctx context.Context, projectFlockID uint) (totalWeight float64, totalQty float64, err error)
GetTotalDepletionByProjectFlockID(ctx context.Context, projectFlockID uint) (totalDepletion float64, err error) GetTotalDepletionByProjectFlockID(ctx context.Context, projectFlockID uint) (totalDepletion float64, err error)
GetLatestAvgWeightByProjectFlockID(ctx context.Context, projectFlockID uint) (avgWeight float64, err error) GetLatestAvgWeightByProjectFlockID(ctx context.Context, projectFlockID uint) (avgWeight float64, err error)
@@ -83,6 +86,7 @@ func (r *RecordingRepositoryImpl) WithRelations(db *gorm.DB) *gorm.DB {
Preload("CreatedUser"). Preload("CreatedUser").
Preload("ProjectFlockKandang"). Preload("ProjectFlockKandang").
Preload("ProjectFlockKandang.Kandang"). Preload("ProjectFlockKandang.Kandang").
Preload("ProjectFlockKandang.Kandang.Location").
Preload("ProjectFlockKandang.ProjectFlock"). Preload("ProjectFlockKandang.ProjectFlock").
Preload("ProjectFlockKandang.ProjectFlock.ProductionStandard"). Preload("ProjectFlockKandang.ProjectFlock.ProductionStandard").
Preload("ProjectFlockKandang.ProjectFlock.Fcr"). Preload("ProjectFlockKandang.ProjectFlock.Fcr").
@@ -106,6 +110,42 @@ func (r *RecordingRepositoryImpl) WithRelations(db *gorm.DB) *gorm.DB {
Preload("Eggs.ProductWarehouse.Warehouse.Location") Preload("Eggs.ProductWarehouse.Warehouse.Location")
} }
func (r *RecordingRepositoryImpl) ApplySearchFilters(db *gorm.DB, rawSearch string) *gorm.DB {
normalized := strings.ToLower(strings.TrimSpace(rawSearch))
if normalized == "" {
return db
}
likeQuery := "%" + normalized + "%"
subQuery := db.Session(&gorm.Session{NewDB: true}).
Table("recordings").
Select("recordings.id").
Joins("LEFT JOIN project_flock_kandangs pfk ON pfk.id = recordings.project_flock_kandangs_id").
Joins("LEFT JOIN project_flocks pf ON pf.id = pfk.project_flock_id").
Joins("LEFT JOIN kandangs k ON k.id = pfk.kandang_id").
Joins("LEFT JOIN locations l ON l.id = k.location_id").
Joins("LEFT JOIN recording_stocks rs ON rs.recording_id = recordings.id").
Joins("LEFT JOIN recording_depletions rd ON rd.recording_id = recordings.id").
Joins("LEFT JOIN recording_eggs re ON re.recording_id = recordings.id").
Joins("LEFT JOIN product_warehouses pws ON pws.id = rs.product_warehouse_id").
Joins("LEFT JOIN product_warehouses pwd ON pwd.id = rd.product_warehouse_id").
Joins("LEFT JOIN product_warehouses pwe ON pwe.id = re.product_warehouse_id").
Joins("LEFT JOIN warehouses ws ON ws.id = pws.warehouse_id").
Joins("LEFT JOIN warehouses wd ON wd.id = pwd.warehouse_id").
Joins("LEFT JOIN warehouses we ON we.id = pwe.warehouse_id").
Where(`
LOWER(pf.flock_name) LIKE ?
OR LOWER(k.name) LIKE ?
OR LOWER(l.name) LIKE ?
OR LOWER(l.address) LIKE ?
OR LOWER(ws.name) LIKE ?
OR LOWER(wd.name) LIKE ?
OR LOWER(we.name) LIKE ?`,
likeQuery, likeQuery, likeQuery, likeQuery, likeQuery, likeQuery, likeQuery,
)
return db.Where("recordings.id IN (?)", subQuery)
}
func (r *RecordingRepositoryImpl) GetLatestByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (*entity.Recording, error) { func (r *RecordingRepositoryImpl) GetLatestByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (*entity.Recording, error) {
if projectFlockKandangId == 0 { if projectFlockKandangId == 0 {
return nil, errors.New("project_flock_kandang_id is required") return nil, errors.New("project_flock_kandang_id is required")
@@ -131,6 +171,7 @@ func (r *RecordingRepositoryImpl) GenerateNextDay(tx *gorm.DB, projectFlockKanda
var days []int var days []int
if err := tx.Model(&entity.Recording{}). if err := tx.Model(&entity.Recording{}).
Where("project_flock_kandangs_id = ?", projectFlockKandangId). Where("project_flock_kandangs_id = ?", projectFlockKandangId).
Where("deleted_at IS NULL").
Where("day IS NOT NULL"). Where("day IS NOT NULL").
Pluck("day", &days).Error; err != nil { Pluck("day", &days).Error; err != nil {
return 0, err return 0, err
@@ -166,6 +207,12 @@ func (r *RecordingRepositoryImpl) UpdateStockUsage(tx *gorm.DB, stockID uint, us
}).Error }).Error
} }
func (r *RecordingRepositoryImpl) UpdateDepletionPending(tx *gorm.DB, depletionID uint, pendingQty float64) error {
return tx.Model(&entity.RecordingDepletion{}).
Where("id = ?", depletionID).
Update("pending_qty", pendingQty).Error
}
func (r *RecordingRepositoryImpl) CreateDepletions(tx *gorm.DB, depletions []entity.RecordingDepletion) error { func (r *RecordingRepositoryImpl) CreateDepletions(tx *gorm.DB, depletions []entity.RecordingDepletion) error {
if len(depletions) == 0 { if len(depletions) == 0 {
return nil return nil
@@ -321,38 +368,25 @@ func (r *RecordingRepositoryImpl) GetTotalChickinByProjectFlockKandang(tx *gorm.
} }
func (r *RecordingRepositoryImpl) GetFeedUsageInGrams(tx *gorm.DB, recordingID uint) (float64, error) { func (r *RecordingRepositoryImpl) GetFeedUsageInGrams(tx *gorm.DB, recordingID uint) (float64, error) {
var rows []struct { var result struct {
TotalQty float64 TotalQty float64
UomName string
} }
if err := tx. if err := tx.
Table("recording_stocks"). Table("recording_stocks").
Select("COALESCE(recording_stocks.usage_qty, 0) + COALESCE(recording_stocks.pending_qty, 0) AS total_qty, LOWER(uoms.name) AS uom_name"). Select("COALESCE(SUM(COALESCE(recording_stocks.usage_qty, 0) + COALESCE(recording_stocks.pending_qty, 0)), 0) AS total_qty").
Joins("JOIN product_warehouses ON product_warehouses.id = recording_stocks.product_warehouse_id"). Joins("JOIN product_warehouses ON product_warehouses.id = recording_stocks.product_warehouse_id").
Joins("JOIN products ON products.id = product_warehouses.product_id"). Joins("JOIN products ON products.id = product_warehouses.product_id").
Joins("JOIN uoms ON uoms.id = products.uom_id").
Joins("JOIN flags ON flags.flagable_id = products.id AND flags.flagable_type = ? AND UPPER(flags.name) = ?", entity.FlagableTypeProduct, "PAKAN"). Joins("JOIN flags ON flags.flagable_id = products.id AND flags.flagable_type = ? AND UPPER(flags.name) = ?", entity.FlagableTypeProduct, "PAKAN").
Where("recording_stocks.recording_id = ?", recordingID). Where("recording_stocks.recording_id = ?", recordingID).
Scan(&rows).Error; err != nil { Scan(&result).Error; err != nil {
return 0, err return 0, err
} }
var total float64 if result.TotalQty <= 0 {
for _, row := range rows { return 0, nil
if row.TotalQty <= 0 {
continue
}
switch strings.TrimSpace(row.UomName) {
case "kilogram", "kg", "kilograms", "kilo":
total += row.TotalQty * 1000
case "gram", "g", "grams":
total += row.TotalQty
default:
total += row.TotalQty
}
} }
return total, nil return result.TotalQty * 1000, nil
} }
func (r *RecordingRepositoryImpl) GetEggSummaryByRecording(tx *gorm.DB, recordingID uint) (totalQty float64, totalWeightGrams float64, err error) { func (r *RecordingRepositoryImpl) GetEggSummaryByRecording(tx *gorm.DB, recordingID uint) (totalQty float64, totalWeightGrams float64, err error) {
@@ -366,7 +400,7 @@ func (r *RecordingRepositoryImpl) GetEggSummaryByRecording(tx *gorm.DB, recordin
} }
err = tx. err = tx.
Table("recording_eggs"). Table("recording_eggs").
Select("COALESCE(SUM(recording_eggs.qty), 0) AS total_qty, COALESCE(SUM(recording_eggs.qty * COALESCE(recording_eggs.weight, 0)), 0) AS total_weight_grams"). Select("COALESCE(SUM(recording_eggs.qty), 0) AS total_qty, COALESCE(SUM(COALESCE(recording_eggs.weight, 0) * 1000), 0) AS total_weight_grams").
Where("recording_eggs.recording_id = ?", recordingID). Where("recording_eggs.recording_id = ?", recordingID).
Scan(&result).Error Scan(&result).Error
if err != nil { if err != nil {
@@ -452,7 +486,7 @@ func (r *RecordingRepositoryImpl) GetTotalEggProductionWeightByProjectFlockID(ct
var result float64 var result float64
err := r.DB().WithContext(ctx). err := r.DB().WithContext(ctx).
Table("recording_eggs"). Table("recording_eggs").
Select("COALESCE(SUM(recording_eggs.qty * recording_eggs.weight), 0) / 1000"). Select("COALESCE(SUM(recording_eggs.weight), 0)").
Joins("JOIN recordings ON recordings.id = recording_eggs.recording_id"). Joins("JOIN recordings ON recordings.id = recording_eggs.recording_id").
Joins("JOIN project_flock_kandangs ON project_flock_kandangs.id = recordings.project_flock_kandangs_id"). Joins("JOIN project_flock_kandangs ON project_flock_kandangs.id = recordings.project_flock_kandangs_id").
Where("project_flock_kandangs.project_flock_id = ?", projectFlockID). Where("project_flock_kandangs.project_flock_id = ?", projectFlockID).
@@ -548,3 +582,30 @@ func nextRecordingDay(days []int) int {
return len(normalized) + 1 return len(normalized) + 1
} }
// GetTotalWeightProducedFromUniformityByProjectFlockID calculates total weight produced from uniformity data
// It takes the latest uniformity record per kandang and calculates: SUM(mean_weight * chick_qty_of_weight / 1000)
func (r *RecordingRepositoryImpl) GetTotalWeightProducedFromUniformityByProjectFlockID(ctx context.Context, projectFlockID uint) (float64, error) {
if projectFlockID == 0 {
return 0, nil
}
var result struct {
TotalWeight float64
}
err := r.DB().WithContext(ctx).
Table("project_flock_kandang_uniformity").
Select("COALESCE(SUM((mean_up / 1.10) * chick_qty_of_weight / 1000), 0) as total_weight").
Joins("JOIN ("+
" SELECT pfku.project_flock_kandang_id, MAX(pfku.id) as latest_id "+
" FROM project_flock_kandang_uniformity pfku "+
" JOIN project_flock_kandangs pfk ON pfk.id = pfku.project_flock_kandang_id "+
" WHERE pfk.project_flock_id = ? "+
" GROUP BY pfku.project_flock_kandang_id "+
") latest ON latest.project_flock_kandang_id = project_flock_kandang_uniformity.project_flock_kandang_id "+
"AND project_flock_kandang_uniformity.id = latest.latest_id", projectFlockID).
Scan(&result).Error
return result.TotalWeight, err
}
@@ -14,6 +14,7 @@ import (
rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" rProjectFlock "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" repository "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/validations"
rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils"
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
@@ -31,7 +32,7 @@ import (
type RecordingService interface { type RecordingService interface {
GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.Recording, int64, error) GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.Recording, int64, error)
GetOne(ctx *fiber.Ctx, id uint) (*entity.Recording, error) GetOne(ctx *fiber.Ctx, id uint) (*entity.Recording, error)
GetNextDay(ctx *fiber.Ctx, projectFlockKandangId uint) (int, error) GetNextDay(ctx *fiber.Ctx, projectFlockKandangId uint, recordTime time.Time) (int, error)
CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.Recording, error) CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.Recording, error)
UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Recording, error) UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.Recording, error)
DeleteOne(ctx *fiber.Ctx, id uint) error DeleteOne(ctx *fiber.Ctx, id uint) error
@@ -39,11 +40,12 @@ type RecordingService interface {
} }
type RecordingFIFOIntegrationService interface { type RecordingFIFOIntegrationService interface {
ConsumeRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock) error ConsumeRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock, note string, actorID uint) error
ReleaseRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock) error ReleaseRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock, note string, actorID uint) error
} }
var recordingStockUsableKey = fifo.UsableKeyRecordingStock var recordingStockUsableKey = fifo.UsableKeyRecordingStock
var recordingDepletionUsableKey = fifo.UsableKeyRecordingDepletion
type recordingService struct { type recordingService struct {
Log *logrus.Logger Log *logrus.Logger
@@ -56,6 +58,7 @@ type recordingService struct {
ApprovalSvc commonSvc.ApprovalService ApprovalSvc commonSvc.ApprovalService
ProductionStandardSvc sProductionStandard.ProductionStandardService ProductionStandardSvc sProductionStandard.ProductionStandardService
FifoSvc commonSvc.FifoService FifoSvc commonSvc.FifoService
StockLogRepo rStockLogs.StockLogRepository
} }
func NewRecordingService( func NewRecordingService(
@@ -66,6 +69,7 @@ func NewRecordingService(
approvalRepo commonRepo.ApprovalRepository, approvalRepo commonRepo.ApprovalRepository,
approvalSvc commonSvc.ApprovalService, approvalSvc commonSvc.ApprovalService,
fifoSvc commonSvc.FifoService, fifoSvc commonSvc.FifoService,
stockLogRepo rStockLogs.StockLogRepository,
productionStandardSvc sProductionStandard.ProductionStandardService, productionStandardSvc sProductionStandard.ProductionStandardService,
validate *validator.Validate, validate *validator.Validate,
) RecordingService { ) RecordingService {
@@ -80,6 +84,7 @@ func NewRecordingService(
ApprovalSvc: approvalSvc, ApprovalSvc: approvalSvc,
ProductionStandardSvc: productionStandardSvc, ProductionStandardSvc: productionStandardSvc,
FifoSvc: fifoSvc, FifoSvc: fifoSvc,
StockLogRepo: stockLogRepo,
} }
} }
@@ -87,12 +92,14 @@ func NewRecordingFIFOIntegrationService(
repo repository.RecordingRepository, repo repository.RecordingRepository,
productWarehouseRepo rProductWarehouse.ProductWarehouseRepository, productWarehouseRepo rProductWarehouse.ProductWarehouseRepository,
fifoSvc commonSvc.FifoService, fifoSvc commonSvc.FifoService,
stockLogRepo rStockLogs.StockLogRepository,
) RecordingFIFOIntegrationService { ) RecordingFIFOIntegrationService {
return &recordingService{ return &recordingService{
Log: utils.Log, Log: utils.Log,
Repository: repo, Repository: repo,
ProductWarehouseRepo: productWarehouseRepo, ProductWarehouseRepo: productWarehouseRepo,
FifoSvc: fifoSvc, FifoSvc: fifoSvc,
StockLogRepo: stockLogRepo,
} }
} }
@@ -127,7 +134,8 @@ func (s recordingService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
if params.ProjectFlockKandangId != 0 { if params.ProjectFlockKandangId != 0 {
db = db.Where("project_flock_kandangs_id = ?", params.ProjectFlockKandangId) db = db.Where("project_flock_kandangs_id = ?", params.ProjectFlockKandangId)
} }
return db.Order("record_datetime DESC").Order("created_at DESC") db = s.Repository.ApplySearchFilters(db, params.Search)
return db.Order("recordings.record_datetime DESC").Order("recordings.created_at DESC")
}) })
if scopeErr != nil { if scopeErr != nil {
@@ -170,7 +178,7 @@ func (s recordingService) GetOne(c *fiber.Ctx, id uint) (*entity.Recording, erro
return recording, nil return recording, nil
} }
func (s recordingService) GetNextDay(c *fiber.Ctx, projectFlockKandangId uint) (int, error) { func (s recordingService) GetNextDay(c *fiber.Ctx, projectFlockKandangId uint, recordTime time.Time) (int, error) {
if projectFlockKandangId == 0 { if projectFlockKandangId == 0 {
return 0, fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is required") return 0, fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is required")
} }
@@ -178,14 +186,16 @@ func (s recordingService) GetNextDay(c *fiber.Ctx, projectFlockKandangId uint) (
return 0, err return 0, err
} }
db := s.Repository.DB().WithContext(c.Context()) if recordTime.IsZero() {
next, err := s.Repository.GenerateNextDay(db, projectFlockKandangId) recordTime = time.Now().UTC()
}
day, err := s.computeRecordingDay(c.Context(), projectFlockKandangId, recordTime)
if err != nil { if err != nil {
s.Log.Errorf("Failed to compute next recording day for project_flock_kandang_id=%d: %+v", projectFlockKandangId, err) s.Log.Errorf("Failed to compute recording day for project_flock_kandang_id=%d: %+v", projectFlockKandangId, err)
return 0, err return 0, err
} }
return next, nil return day, nil
} }
func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Recording, error) { func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*entity.Recording, error) {
@@ -230,12 +240,14 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
} }
} }
day, err := s.computeRecordingDay(ctx, pfk.Id, recordTime)
if err != nil {
return nil, err
}
if !isLaying && len(req.Eggs) > 0 { if !isLaying && len(req.Eggs) > 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "Egg details permitted only for laying project flocks") return nil, fiber.NewError(fiber.StatusBadRequest, "Egg details permitted only for laying project flocks")
} }
if isLaying && len(req.Eggs) == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "Egg details are required for laying project flocks")
}
if err := s.ensureProductWarehousesExist(c, req.Stocks, req.Depletions, req.Eggs); err != nil { if err := s.ensureProductWarehousesExist(c, req.Stocks, req.Depletions, req.Eggs); err != nil {
return nil, err return nil, err
@@ -246,13 +258,8 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
} }
var createdRecording entity.Recording var createdRecording entity.Recording
transactionErr := s.Repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { transactionErr := s.Repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
nextDay, err := s.Repository.GenerateNextDay(tx, req.ProjectFlockKandangId)
if err != nil {
s.Log.Errorf("Failed to determine recording day: %+v", err)
return err
}
if s.ProductionStandardSvc != nil { if s.ProductionStandardSvc != nil {
if err := s.ProductionStandardSvc.EnsureWeekAvailable(ctx, pfk.ProjectFlock.ProductionStandardId, category, nextDay); err != nil { if err := s.ProductionStandardSvc.EnsureWeekAvailable(ctx, pfk.ProjectFlock.ProductionStandardId, category, day); err != nil {
return err return err
} }
} }
@@ -266,7 +273,6 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
return fiber.NewError(fiber.StatusBadRequest, "Recording for this project flock today already exists") return fiber.NewError(fiber.StatusBadRequest, "Recording for this project flock today already exists")
} }
day := nextDay
createdRecording = entity.Recording{ createdRecording = entity.Recording{
ProjectFlockKandangId: req.ProjectFlockKandangId, ProjectFlockKandangId: req.ProjectFlockKandangId,
RecordDatetime: recordTime, RecordDatetime: recordTime,
@@ -299,23 +305,47 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
} }
applyStockDesiredQuantities(mappedStocks, stockDesired, s.FifoSvc != nil) applyStockDesiredQuantities(mappedStocks, stockDesired, s.FifoSvc != nil)
if err := s.consumeRecordingStocks(ctx, tx, mappedStocks); err != nil { note := fmt.Sprintf("Recording-Create#%d", createdRecording.Id)
if err := s.consumeRecordingStocks(ctx, tx, mappedStocks, note, actorID); err != nil {
return err return err
} }
mappedDepletions := recordingutil.MapDepletions(createdRecording.Id, req.Depletions) mappedDepletions := recordingutil.MapDepletions(createdRecording.Id, req.Depletions)
if s.FifoSvc != nil && len(mappedDepletions) > 0 {
sourceWarehouseID, err := s.resolvePopulationWarehouseID(ctx, req.ProjectFlockKandangId)
if err != nil {
return err
}
for i := range mappedDepletions {
mappedDepletions[i].SourceProductWarehouseId = &sourceWarehouseID
}
}
if err := s.Repository.CreateDepletions(tx, mappedDepletions); err != nil { if err := s.Repository.CreateDepletions(tx, mappedDepletions); err != nil {
s.Log.Errorf("Failed to persist depletions: %+v", err) s.Log.Errorf("Failed to persist depletions: %+v", err)
return err return err
} }
if s.FifoSvc != nil {
note := fmt.Sprintf("Recording-Create#%d", createdRecording.Id)
if err := s.consumeRecordingDepletions(ctx, tx, mappedDepletions, note, actorID); err != nil {
return err
}
}
mappedEggs := recordingutil.MapEggs(createdRecording.Id, createdRecording.CreatedBy, req.Eggs) mappedEggs := recordingutil.MapEggs(createdRecording.Id, createdRecording.CreatedBy, req.Eggs)
if err := s.Repository.CreateEggs(tx, mappedEggs); err != nil { if err := s.Repository.CreateEggs(tx, mappedEggs); err != nil {
s.Log.Errorf("Failed to persist eggs: %+v", err) s.Log.Errorf("Failed to persist eggs: %+v", err)
return err return err
} }
if s.FifoSvc != nil {
note := fmt.Sprintf("Recording-Create#%d", createdRecording.Id)
if err := s.replenishRecordingEggs(ctx, tx, mappedEggs, note, actorID); err != nil {
return err
}
}
if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, mappedDepletions, nil, mappedEggs)); err != nil { var warehouseDeltas map[uint]float64
warehouseDeltas = buildWarehouseDeltas(nil, mappedDepletions, nil, mappedEggs)
if err := s.adjustProductWarehouseQuantities(ctx, tx, warehouseDeltas); err != nil {
s.Log.Errorf("Failed to adjust product warehouses: %+v", err) s.Log.Errorf("Failed to adjust product warehouses: %+v", err)
return err return err
} }
@@ -353,6 +383,10 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
} }
ctx := c.Context() ctx := c.Context()
actorID, err := m.ActorIDFromContext(c)
if err != nil {
return nil, err
}
var recordingEntity *entity.Recording var recordingEntity *entity.Recording
var updatedRecording *entity.Recording var updatedRecording *entity.Recording
@@ -423,9 +457,6 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
if !isLaying && len(req.Eggs) > 0 { if !isLaying && len(req.Eggs) > 0 {
return fiber.NewError(fiber.StatusBadRequest, "Egg details permitted only for laying project flocks") return fiber.NewError(fiber.StatusBadRequest, "Egg details permitted only for laying project flocks")
} }
if isLaying && len(req.Eggs) == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Egg details are required for laying project flocks")
}
} }
if hasStockChanges { if hasStockChanges {
@@ -441,23 +472,47 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
} }
if hasStockChanges { if hasStockChanges {
if err := s.syncRecordingStocks(ctx, tx, recordingEntity.Id, existingStocks, req.Stocks); err != nil { note := fmt.Sprintf("Recording-Edit#%d", recordingEntity.Id)
if err := s.syncRecordingStocks(ctx, tx, recordingEntity.Id, existingStocks, req.Stocks, note, actorID); err != nil {
return err return err
} }
} }
if hasDepletionChanges { if hasDepletionChanges {
if s.FifoSvc != nil {
note := fmt.Sprintf("Recording-Edit#%d", recordingEntity.Id)
if err := s.releaseRecordingDepletions(ctx, tx, existingDepletions, note, actorID); err != nil {
return err
}
}
if err := s.Repository.DeleteDepletions(tx, recordingEntity.Id); err != nil { if err := s.Repository.DeleteDepletions(tx, recordingEntity.Id); err != nil {
s.Log.Errorf("Failed to clear depletions: %+v", err) s.Log.Errorf("Failed to clear depletions: %+v", err)
return err return err
} }
mappedDepletions := recordingutil.MapDepletions(recordingEntity.Id, req.Depletions) mappedDepletions := recordingutil.MapDepletions(recordingEntity.Id, req.Depletions)
if s.FifoSvc != nil && len(mappedDepletions) > 0 {
sourceWarehouseID, err := s.resolvePopulationWarehouseID(ctx, recordingEntity.ProjectFlockKandangId)
if err != nil {
return err
}
for i := range mappedDepletions {
mappedDepletions[i].SourceProductWarehouseId = &sourceWarehouseID
}
}
if err := s.Repository.CreateDepletions(tx, mappedDepletions); err != nil { if err := s.Repository.CreateDepletions(tx, mappedDepletions); err != nil {
s.Log.Errorf("Failed to update depletions: %+v", err) s.Log.Errorf("Failed to update depletions: %+v", err)
return err return err
} }
if s.FifoSvc != nil {
note := fmt.Sprintf("Recording-Edit#%d", recordingEntity.Id)
if err := s.consumeRecordingDepletions(ctx, tx, mappedDepletions, note, actorID); err != nil {
return err
}
}
if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(existingDepletions, mappedDepletions, nil, nil)); err != nil { if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(existingDepletions, mappedDepletions, nil, nil)); err != nil {
s.Log.Errorf("Failed to adjust product warehouses for depletions: %+v", err) s.Log.Errorf("Failed to adjust product warehouses for depletions: %+v", err)
return err return err
@@ -465,6 +520,38 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
} }
if hasEggChanges { if hasEggChanges {
if s.FifoSvc != nil {
if err := ensureRecordingEggsUnused(existingEggs); err != nil {
return err
}
if s.StockLogRepo != nil {
note := fmt.Sprintf("Recording-Edit#%d", recordingEntity.Id)
logs := make([]*entity.StockLog, 0, len(existingEggs))
for _, egg := range existingEggs {
if egg.ProductWarehouseId == 0 || egg.Qty <= 0 {
continue
}
logs = append(logs, &entity.StockLog{
ProductWarehouseId: egg.ProductWarehouseId,
CreatedBy: actorID,
Decrease: float64(egg.Qty),
LoggableType: string(utils.StockLogTypeRecording),
LoggableId: recordingEntity.Id,
Notes: note,
})
}
if len(logs) > 0 {
if err := s.StockLogRepo.WithTx(tx).CreateMany(ctx, logs, nil); err != nil {
return err
}
}
}
if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, nil, existingEggs, nil)); err != nil {
s.Log.Errorf("Failed to adjust product warehouses for eggs: %+v", err)
return err
}
}
if err := s.Repository.DeleteEggs(tx, recordingEntity.Id); err != nil { if err := s.Repository.DeleteEggs(tx, recordingEntity.Id); err != nil {
s.Log.Errorf("Failed to clear eggs: %+v", err) s.Log.Errorf("Failed to clear eggs: %+v", err)
return err return err
@@ -476,9 +563,16 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
return err return err
} }
if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, nil, existingEggs, mappedEggs)); err != nil { if s.FifoSvc != nil {
s.Log.Errorf("Failed to adjust product warehouses for eggs: %+v", err) note := fmt.Sprintf("Recording-Edit#%d", recordingEntity.Id)
return err if err := s.replenishRecordingEggs(ctx, tx, mappedEggs, note, actorID); err != nil {
return err
}
} else {
if err := s.adjustProductWarehouseQuantities(ctx, tx, buildWarehouseDeltas(nil, nil, existingEggs, mappedEggs)); err != nil {
s.Log.Errorf("Failed to adjust product warehouses for eggs: %+v", err)
return err
}
} }
} }
@@ -655,12 +749,22 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error {
s.Log.Errorf("Failed to list depletions before delete: %+v", err) s.Log.Errorf("Failed to list depletions before delete: %+v", err)
return err return err
} }
if s.FifoSvc != nil {
if err := s.releaseRecordingDepletions(ctx, tx, oldDepletions, "", 0); err != nil {
return err
}
}
oldEggs, err := s.Repository.ListEggs(tx, id) oldEggs, err := s.Repository.ListEggs(tx, id)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
s.Log.Errorf("Failed to list eggs before delete: %+v", err) s.Log.Errorf("Failed to list eggs before delete: %+v", err)
return err return err
} }
if s.FifoSvc != nil {
if err := ensureRecordingEggsUnused(oldEggs); err != nil {
return err
}
}
oldStocks, err := s.Repository.ListStocks(tx, id) oldStocks, err := s.Repository.ListStocks(tx, id)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
@@ -668,7 +772,7 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error {
return err return err
} }
if err := s.releaseRecordingStocks(ctx, tx, oldStocks); err != nil { if err := s.releaseRecordingStocks(ctx, tx, oldStocks, "", 0); err != nil {
return err return err
} }
@@ -727,10 +831,19 @@ func (s *recordingService) ensureProductWarehousesExist(c *fiber.Ctx, stocks []v
return nil return nil
} }
func (s *recordingService) consumeRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock) error { func (s *recordingService) consumeRecordingStocks(
ctx context.Context,
tx *gorm.DB,
stocks []entity.RecordingStock,
note string,
actorID uint,
) error {
if len(stocks) == 0 || s.FifoSvc == nil { if len(stocks) == 0 || s.FifoSvc == nil {
return nil return nil
} }
if strings.TrimSpace(note) != "" && s.StockLogRepo == nil {
return errors.New("stock log repository is not available")
}
for _, stock := range stocks { for _, stock := range stocks {
if stock.Id == 0 { if stock.Id == 0 {
@@ -763,19 +876,134 @@ func (s *recordingService) consumeRecordingStocks(ctx context.Context, tx *gorm.
if err := s.Repository.UpdateStockUsage(tx, stock.Id, result.UsageQuantity, result.PendingQuantity); err != nil { if err := s.Repository.UpdateStockUsage(tx, stock.Id, result.UsageQuantity, result.PendingQuantity); err != nil {
return err return err
} }
logDecrease := result.UsageQuantity
if result.PendingQuantity > 0 {
logDecrease += result.PendingQuantity
}
if logDecrease > 0 && strings.TrimSpace(note) != "" && actorID != 0 {
log := &entity.StockLog{
ProductWarehouseId: stock.ProductWarehouseId,
CreatedBy: actorID,
Decrease: logDecrease,
LoggableType: string(utils.StockLogTypeRecording),
LoggableId: stock.RecordingId,
Notes: note,
}
if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil {
return err
}
}
} }
return nil return nil
} }
func (s *recordingService) ConsumeRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock) error { func (s *recordingService) consumeRecordingDepletions(
return s.consumeRecordingStocks(ctx, tx, stocks) ctx context.Context,
tx *gorm.DB,
depletions []entity.RecordingDepletion,
note string,
actorID uint,
) error {
if len(depletions) == 0 || s.FifoSvc == nil {
return nil
}
if strings.TrimSpace(note) != "" && s.StockLogRepo == nil {
return errors.New("stock log repository is not available")
}
for _, depletion := range depletions {
if depletion.Id == 0 {
continue
}
sourceWarehouseID := uint(0)
if depletion.SourceProductWarehouseId != nil {
sourceWarehouseID = *depletion.SourceProductWarehouseId
}
if sourceWarehouseID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Source product warehouse tidak ditemukan untuk depletion")
}
desired := depletion.Qty + depletion.PendingQty
result, err := s.FifoSvc.Consume(ctx, commonSvc.StockConsumeRequest{
UsableKey: recordingDepletionUsableKey,
UsableID: depletion.Id,
ProductWarehouseID: sourceWarehouseID,
Quantity: desired,
AllowPending: false,
Tx: tx,
})
if err != nil {
s.Log.Errorf("Failed to consume FIFO stock for recording depletion %d: %+v", depletion.Id, err)
return err
}
if err := s.Repository.UpdateDepletionPending(tx, depletion.Id, result.PendingQuantity); err != nil {
return err
}
logDecrease := result.UsageQuantity
if result.PendingQuantity > 0 {
logDecrease += result.PendingQuantity
}
if logDecrease > 0 && strings.TrimSpace(note) != "" && actorID != 0 {
log := &entity.StockLog{
ProductWarehouseId: sourceWarehouseID,
CreatedBy: actorID,
Decrease: logDecrease,
LoggableType: string(utils.StockLogTypeRecording),
LoggableId: depletion.RecordingId,
Notes: note,
}
if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil {
return err
}
}
destDelta := depletion.Qty + depletion.PendingQty
if depletion.ProductWarehouseId != 0 && destDelta > 0 && strings.TrimSpace(note) != "" && actorID != 0 {
log := &entity.StockLog{
ProductWarehouseId: depletion.ProductWarehouseId,
CreatedBy: actorID,
Increase: destDelta,
LoggableType: string(utils.StockLogTypeRecording),
LoggableId: depletion.RecordingId,
Notes: note,
}
if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil {
return err
}
}
}
return nil
} }
func (s *recordingService) releaseRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock) error { func (s *recordingService) ConsumeRecordingStocks(
ctx context.Context,
tx *gorm.DB,
stocks []entity.RecordingStock,
note string,
actorID uint,
) error {
return s.consumeRecordingStocks(ctx, tx, stocks, note, actorID)
}
func (s *recordingService) releaseRecordingStocks(
ctx context.Context,
tx *gorm.DB,
stocks []entity.RecordingStock,
note string,
actorID uint,
) error {
if len(stocks) == 0 || s.FifoSvc == nil { if len(stocks) == 0 || s.FifoSvc == nil {
return nil return nil
} }
if strings.TrimSpace(note) != "" && s.StockLogRepo == nil {
return errors.New("stock log repository is not available")
}
for _, stock := range stocks { for _, stock := range stocks {
if stock.Id == 0 { if stock.Id == 0 {
@@ -794,13 +1022,166 @@ func (s *recordingService) releaseRecordingStocks(ctx context.Context, tx *gorm.
if err := s.Repository.UpdateStockUsage(tx, stock.Id, 0, 0); err != nil { if err := s.Repository.UpdateStockUsage(tx, stock.Id, 0, 0); err != nil {
return err return err
} }
if stock.UsageQty != nil && *stock.UsageQty > 0 && strings.TrimSpace(note) != "" && actorID != 0 {
log := &entity.StockLog{
ProductWarehouseId: stock.ProductWarehouseId,
CreatedBy: actorID,
Increase: *stock.UsageQty,
LoggableType: string(utils.StockLogTypeRecording),
LoggableId: stock.RecordingId,
Notes: note,
}
if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil {
return err
}
}
} }
return nil return nil
} }
func (s *recordingService) ReleaseRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock) error { func (s *recordingService) releaseRecordingDepletions(
return s.releaseRecordingStocks(ctx, tx, stocks) ctx context.Context,
tx *gorm.DB,
depletions []entity.RecordingDepletion,
note string,
actorID uint,
) error {
if len(depletions) == 0 || s.FifoSvc == nil {
return nil
}
if strings.TrimSpace(note) != "" && s.StockLogRepo == nil {
return errors.New("stock log repository is not available")
}
for _, depletion := range depletions {
if depletion.Id == 0 {
continue
}
sourceWarehouseID := uint(0)
if depletion.SourceProductWarehouseId != nil {
sourceWarehouseID = *depletion.SourceProductWarehouseId
}
if sourceWarehouseID == 0 {
return fiber.NewError(fiber.StatusBadRequest, "Source product warehouse tidak ditemukan untuk depletion")
}
if err := s.FifoSvc.ReleaseUsage(ctx, commonSvc.StockReleaseRequest{
UsableKey: recordingDepletionUsableKey,
UsableID: depletion.Id,
Tx: tx,
}); err != nil {
s.Log.Errorf("Failed to release FIFO stock for recording depletion %d: %+v", depletion.Id, err)
return err
}
if err := s.Repository.UpdateDepletionPending(tx, depletion.Id, 0); err != nil {
return err
}
logIncrease := depletion.Qty
if depletion.PendingQty > 0 {
logIncrease += depletion.PendingQty
}
if logIncrease > 0 && strings.TrimSpace(note) != "" && actorID != 0 {
log := &entity.StockLog{
ProductWarehouseId: sourceWarehouseID,
CreatedBy: actorID,
Increase: logIncrease,
LoggableType: string(utils.StockLogTypeRecording),
LoggableId: depletion.RecordingId,
Notes: note,
}
if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil {
return err
}
}
destDelta := depletion.Qty + depletion.PendingQty
if depletion.ProductWarehouseId != 0 && destDelta > 0 && strings.TrimSpace(note) != "" && actorID != 0 {
log := &entity.StockLog{
ProductWarehouseId: depletion.ProductWarehouseId,
CreatedBy: actorID,
Decrease: destDelta,
LoggableType: string(utils.StockLogTypeRecording),
LoggableId: depletion.RecordingId,
Notes: note,
}
if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil {
return err
}
}
}
return nil
}
func (s *recordingService) ReleaseRecordingStocks(
ctx context.Context,
tx *gorm.DB,
stocks []entity.RecordingStock,
note string,
actorID uint,
) error {
return s.releaseRecordingStocks(ctx, tx, stocks, note, actorID)
}
func (s *recordingService) resolvePopulationWarehouseID(ctx context.Context, projectFlockKandangID uint) (uint, error) {
if projectFlockKandangID == 0 {
return 0, fiber.NewError(fiber.StatusBadRequest, "Project flock kandang tidak valid")
}
populations, err := s.ProjectFlockPopulationRepo.GetByProjectFlockKandangID(ctx, projectFlockKandangID)
if err != nil {
s.Log.Errorf("Failed to fetch populations for project_flock_kandang_id=%d: %+v", projectFlockKandangID, err)
return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data populasi")
}
for _, pop := range populations {
if pop.ProductWarehouseId > 0 && pop.TotalQty > 0 {
return pop.ProductWarehouseId, nil
}
}
for _, pop := range populations {
if pop.ProductWarehouseId > 0 {
return pop.ProductWarehouseId, nil
}
}
return 0, fiber.NewError(fiber.StatusBadRequest, "Source product warehouse populasi tidak ditemukan")
}
func (s *recordingService) computeRecordingDay(ctx context.Context, projectFlockKandangID uint, recordTime time.Time) (int, error) {
if projectFlockKandangID == 0 {
return 0, fiber.NewError(fiber.StatusBadRequest, "Project flock kandang tidak valid")
}
populations, err := s.ProjectFlockPopulationRepo.GetByProjectFlockKandangID(ctx, projectFlockKandangID)
if err != nil {
s.Log.Errorf("Failed to fetch populations for project_flock_kandang_id=%d: %+v", projectFlockKandangID, err)
return 0, fiber.NewError(fiber.StatusInternalServerError, "Gagal mengambil data populasi")
}
var chickinDate time.Time
for _, pop := range populations {
if pop.ProjectChickin == nil || pop.ProjectChickin.ChickInDate.IsZero() {
continue
}
if chickinDate.IsZero() || pop.ProjectChickin.ChickInDate.Before(chickinDate) {
chickinDate = pop.ProjectChickin.ChickInDate
}
}
if chickinDate.IsZero() {
return 0, fiber.NewError(fiber.StatusBadRequest, "Tanggal chick in tidak ditemukan")
}
chickinDay := time.Date(chickinDate.Year(), chickinDate.Month(), chickinDate.Day(), 0, 0, 0, 0, time.UTC)
recordDay := time.Date(recordTime.Year(), recordTime.Month(), recordTime.Day(), 0, 0, 0, 0, time.UTC)
diff := int(recordDay.Sub(chickinDay).Hours() / 24)
if diff < 0 {
return 0, fiber.NewError(fiber.StatusBadRequest, "Record date tidak boleh sebelum tanggal chick in")
}
return diff + 1, nil
} }
func buildWarehouseDeltas( func buildWarehouseDeltas(
@@ -837,6 +1218,53 @@ func (s *recordingService) adjustProductWarehouseQuantities(ctx context.Context,
return s.ProductWarehouseRepo.AdjustQuantities(ctx, deltas, func(*gorm.DB) *gorm.DB { return tx }) return s.ProductWarehouseRepo.AdjustQuantities(ctx, deltas, func(*gorm.DB) *gorm.DB { return tx })
} }
func (s *recordingService) replenishRecordingEggs(
ctx context.Context,
tx *gorm.DB,
eggs []entity.RecordingEgg,
note string,
actorID uint,
) error {
if len(eggs) == 0 || s.FifoSvc == nil {
return nil
}
if strings.TrimSpace(note) != "" && s.StockLogRepo == nil {
return errors.New("stock log repository is not available")
}
for _, egg := range eggs {
if egg.Id == 0 || egg.ProductWarehouseId == 0 || egg.Qty <= 0 {
continue
}
if _, err := s.FifoSvc.Replenish(ctx, commonSvc.StockReplenishRequest{
StockableKey: fifo.StockableKeyRecordingEgg,
StockableID: egg.Id,
ProductWarehouseID: egg.ProductWarehouseId,
Quantity: float64(egg.Qty),
Tx: tx,
}); err != nil {
s.Log.Errorf("Failed to replenish FIFO stock for recording egg %d: %+v", egg.Id, err)
return err
}
if strings.TrimSpace(note) != "" && actorID != 0 {
log := &entity.StockLog{
ProductWarehouseId: egg.ProductWarehouseId,
CreatedBy: actorID,
Increase: float64(egg.Qty),
LoggableType: string(utils.StockLogTypeRecording),
LoggableId: egg.RecordingId,
Notes: note,
}
if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil {
return err
}
}
}
return nil
}
type desiredStock struct { type desiredStock struct {
Usage float64 Usage float64
Pending float64 Pending float64
@@ -882,6 +1310,8 @@ func (s *recordingService) syncRecordingStocks(
recordingID uint, recordingID uint,
existing []entity.RecordingStock, existing []entity.RecordingStock,
incoming []validation.Stock, incoming []validation.Stock,
note string,
actorID uint,
) error { ) error {
if s.FifoSvc == nil { if s.FifoSvc == nil {
if err := s.Repository.DeleteStocks(tx, recordingID); err != nil { if err := s.Repository.DeleteStocks(tx, recordingID); err != nil {
@@ -918,10 +1348,8 @@ func (s *recordingService) syncRecordingStocks(
desired := item.Qty desired := item.Qty
stock.UsageQty = &desired stock.UsageQty = &desired
if item.PendingQty != nil { zero := 0.0
pending := *item.PendingQty stock.PendingQty = &zero
stock.PendingQty = &pending
}
stocksToConsume = append(stocksToConsume, stock) stocksToConsume = append(stocksToConsume, stock)
} }
@@ -930,7 +1358,7 @@ func (s *recordingService) syncRecordingStocks(
leftovers = append(leftovers, list...) leftovers = append(leftovers, list...)
} }
if len(leftovers) > 0 { if len(leftovers) > 0 {
if err := s.releaseRecordingStocks(ctx, tx, leftovers); err != nil { if err := s.releaseRecordingStocks(ctx, tx, leftovers, note, actorID); err != nil {
return err return err
} }
ids := make([]uint, 0, len(leftovers)) ids := make([]uint, 0, len(leftovers))
@@ -949,7 +1377,7 @@ func (s *recordingService) syncRecordingStocks(
if len(stocksToConsume) == 0 { if len(stocksToConsume) == 0 {
return nil return nil
} }
return s.consumeRecordingStocks(ctx, tx, stocksToConsume) return s.consumeRecordingStocks(ctx, tx, stocksToConsume, note, actorID)
} }
type eggTotals struct { type eggTotals struct {
@@ -957,44 +1385,31 @@ type eggTotals struct {
Weight float64 Weight float64
} }
func stocksMatch(existing []entity.RecordingStock, incoming []validation.Stock) bool { func ensureRecordingEggsUnused(eggs []entity.RecordingEgg) error {
hasPending := false for _, egg := range eggs {
for _, item := range incoming { if egg.TotalUsed > 0 {
if item.PendingQty != nil { return fiber.NewError(fiber.StatusBadRequest, "Recording egg sudah digunakan sehingga tidak dapat diubah")
hasPending = true
break
} }
} }
return nil
}
func stocksMatch(existing []entity.RecordingStock, incoming []validation.Stock) bool {
existingUsage := make(map[uint]float64) existingUsage := make(map[uint]float64)
existingTotal := make(map[uint]float64)
for _, stock := range existing { for _, stock := range existing {
var usage float64 var usage float64
var pending float64
if stock.UsageQty != nil { if stock.UsageQty != nil {
usage = *stock.UsageQty usage = *stock.UsageQty
} }
if stock.PendingQty != nil {
pending = *stock.PendingQty
}
existingUsage[stock.ProductWarehouseId] += usage existingUsage[stock.ProductWarehouseId] += usage
existingTotal[stock.ProductWarehouseId] += usage + pending
} }
incomingUsage := make(map[uint]float64) incomingUsage := make(map[uint]float64)
incomingTotal := make(map[uint]float64)
for _, item := range incoming { for _, item := range incoming {
var pending float64
if item.PendingQty != nil {
pending = *item.PendingQty
}
incomingUsage[item.ProductWarehouseId] += item.Qty incomingUsage[item.ProductWarehouseId] += item.Qty
incomingTotal[item.ProductWarehouseId] += item.Qty + pending
} }
if hasPending {
return floatMapsMatch(existingTotal, incomingTotal)
}
return floatMapsMatch(existingUsage, incomingUsage) return floatMapsMatch(existingUsage, incomingUsage)
} }
@@ -1021,7 +1436,7 @@ func eggsMatch(existing []entity.RecordingEgg, incoming []validation.Egg) bool {
} }
current := existingTotals[egg.ProductWarehouseId] current := existingTotals[egg.ProductWarehouseId]
current.Qty += egg.Qty current.Qty += egg.Qty
current.Weight += float64(egg.Qty) * weight current.Weight += weight
existingTotals[egg.ProductWarehouseId] = current existingTotals[egg.ProductWarehouseId] = current
} }
@@ -1033,7 +1448,7 @@ func eggsMatch(existing []entity.RecordingEgg, incoming []validation.Egg) bool {
} }
current := incomingTotals[egg.ProductWarehouseId] current := incomingTotals[egg.ProductWarehouseId]
current.Qty += egg.Qty current.Qty += egg.Qty
current.Weight += float64(egg.Qty) * weight current.Weight += weight
incomingTotals[egg.ProductWarehouseId] = current incomingTotals[egg.ProductWarehouseId] = current
} }
@@ -1192,7 +1607,7 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm
var eggMass float64 var eggMass float64
if remainingChick > 0 && totalEggWeightGrams > 0 { if remainingChick > 0 && totalEggWeightGrams > 0 {
eggMass = (totalEggWeightGrams / remainingChick) * 1000 eggMass = (totalEggWeightGrams / remainingChick) / 1000
updates["egg_mass"] = eggMass updates["egg_mass"] = eggMass
recording.EggMass = &eggMass recording.EggMass = &eggMass
} else { } else {
@@ -1202,7 +1617,7 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm
var eggWeight float64 var eggWeight float64
if totalEggQty > 0 && totalEggWeightGrams > 0 { if totalEggQty > 0 && totalEggWeightGrams > 0 {
eggWeight = (totalEggWeightGrams / totalEggQty) * 1000 eggWeight = totalEggWeightGrams / totalEggQty
updates["egg_weight"] = eggWeight updates["egg_weight"] = eggWeight
recording.EggWeight = &eggWeight recording.EggWeight = &eggWeight
} else { } else {
@@ -2,9 +2,8 @@ package validation
type ( type (
Stock struct { Stock struct {
ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,number,min=1"` ProductWarehouseId uint `json:"product_warehouse_id" validate:"required,number,min=1"`
Qty float64 `json:"qty" validate:"required,gte=0"` Qty float64 `json:"qty" validate:"required,gte=0"`
PendingQty *float64 `json:"pending_qty,omitempty" validate:"omitempty,gte=0"`
} }
Depletion struct { Depletion struct {
@@ -20,23 +19,24 @@ type (
) )
type Create struct { type Create struct {
ProjectFlockKandangId uint `json:"project_flock_kandang_id" validate:"required,number,min=1"` ProjectFlockKandangId uint `json:"project_flock_kandang_id" validate:"required,number,min=1"`
RecordDate *string `json:"record_date,omitempty" validate:"omitempty,datetime=2006-01-02"` RecordDate *string `json:"record_date,omitempty" validate:"omitempty,datetime=2006-01-02"`
Stocks []Stock `json:"stocks" validate:"dive"` Stocks []Stock `json:"stocks" validate:"dive"`
Depletions []Depletion `json:"depletions" validate:"dive"` Depletions []Depletion `json:"depletions,omitempty" validate:"omitempty,dive"`
Eggs []Egg `json:"eggs" validate:"omitempty,dive"` Eggs []Egg `json:"eggs" validate:"omitempty,dive"`
} }
type Update struct { type Update struct {
Stocks []Stock `json:"stocks,omitempty" validate:"omitempty,dive"` Stocks []Stock `json:"stocks,omitempty" validate:"omitempty,dive"`
Depletions []Depletion `json:"depletions,omitempty" validate:"omitempty,dive"` Depletions []Depletion `json:"depletions,omitempty" validate:"omitempty,dive"`
Eggs []Egg `json:"eggs,omitempty" validate:"omitempty,dive"` Eggs []Egg `json:"eggs,omitempty" validate:"omitempty,dive"`
} }
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"`
ProjectFlockKandangId uint `query:"project_flock_kandang_id" validate:"omitempty,number,min=1"` ProjectFlockKandangId uint `query:"project_flock_kandang_id" validate:"omitempty,number,min=1"`
Search string `query:"search" validate:"omitempty,max=50"`
} }
type Approve struct { type Approve struct {
@@ -25,8 +25,12 @@ func NewTransferLayingController(transferLayingService service.TransferLayingSer
func (u *TransferLayingController) GetAll(c *fiber.Ctx) error { func (u *TransferLayingController) 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),
Search: c.Query("search", ""),
TransferDate: c.Query("transfer_date", ""),
FlockSource: uint(c.QueryInt("flock_source", 0)),
FlockDestination: uint(c.QueryInt("flock_destination", 0)),
} }
if query.Page < 1 || query.Limit < 1 { if query.Page < 1 || query.Limit < 1 {
@@ -66,7 +70,7 @@ func (u *TransferLayingController) GetOne(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadRequest, "Invalid Id") return fiber.NewError(fiber.StatusBadRequest, "Invalid Id")
} }
result, approval, err := u.TransferLayingService.GetOneWithApproval(c, uint(id)) result, approval, err := u.TransferLayingService.GetOne(c, uint(id))
if err != nil { if err != nil {
return err return err
} }
@@ -179,7 +183,6 @@ func (u *TransferLayingController) Approval(c *fiber.Ctx) error {
}) })
} }
func (u *TransferLayingController) GetAvailableQtyPerKandang(c *fiber.Ctx) error { func (u *TransferLayingController) GetAvailableQtyPerKandang(c *fiber.Ctx) error {
projectFlockID, err := strconv.ParseUint(c.Params("project_flock_id"), 10, 32) projectFlockID, err := strconv.ParseUint(c.Params("project_flock_id"), 10, 32)
if err != nil { if err != nil {
@@ -162,9 +162,19 @@ func ToProductWarehouseSummaryDTO(pw *entity.ProductWarehouse) *ProductWarehouse
} }
func ToLayingTransferSourceDTO(source entity.LayingTransferSource) LayingTransferSourceDTO { func ToLayingTransferSourceDTO(source entity.LayingTransferSource) LayingTransferSourceDTO {
// Tampilkan requested qty sebelum approve, consumed qty setelah approve
var displayQty float64
if source.UsageQty > 0 {
// Sudah di-approve dan di-consume, tampilkan actual consumed quantity
displayQty = source.UsageQty
} else {
// Belum di-approve, tampilkan requested quantity
displayQty = source.RequestedQty
}
return LayingTransferSourceDTO{ return LayingTransferSourceDTO{
SourceProjectFlockKandang: ToProjectFlockKandangSummaryDTO(source.SourceProjectFlockKandang), SourceProjectFlockKandang: ToProjectFlockKandangSummaryDTO(source.SourceProjectFlockKandang),
Qty: source.UsageQty, // Ambil dari UsageQty (FIFO consumed quantity) Qty: displayQty,
ProductWarehouse: ToProductWarehouseSummaryDTO(source.ProductWarehouse), ProductWarehouse: ToProductWarehouseSummaryDTO(source.ProductWarehouse),
Note: source.Note, Note: source.Note,
} }
@@ -28,8 +28,7 @@ import (
type TransferLayingService interface { type TransferLayingService interface {
GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.LayingTransfer, int64, error) GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.LayingTransfer, int64, error)
GetOne(ctx *fiber.Ctx, id uint) (*entity.LayingTransfer, error) GetOne(ctx *fiber.Ctx, id uint) (*entity.LayingTransfer, *entity.Approval, error)
GetOneWithApproval(ctx *fiber.Ctx, id uint) (*entity.LayingTransfer, *entity.Approval, error)
CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.LayingTransfer, error) CreateOne(ctx *fiber.Ctx, req *validation.Create) (*entity.LayingTransfer, error)
UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.LayingTransfer, error) UpdateOne(ctx *fiber.Ctx, req *validation.Update, id uint) (*entity.LayingTransfer, error)
DeleteOne(ctx *fiber.Ctx, id uint) error DeleteOne(ctx *fiber.Ctx, id uint) error
@@ -107,26 +106,34 @@ func (s transferLayingService) GetAll(c *fiber.Ctx, params *validation.Query) ([
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
transferLayings, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB { transferLayings, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
db = s.withRelations(db) // Apply search and filters
if scope.Restrict { if params.Search != "" {
if len(scope.IDs) == 0 { searchPattern := "%" + params.Search + "%"
return db.Where("1 = 0") db = db.Joins("LEFT JOIN project_flocks AS pf_from ON laying_transfers.from_project_flock_id = pf_from.id").
} Joins("LEFT JOIN project_flocks AS pf_to ON laying_transfers.to_project_flock_id = pf_to.id").
db = db. Where("laying_transfers.transfer_number ILIKE ? OR laying_transfers.notes ILIKE ? OR pf_from.flock_name ILIKE ? OR pf_to.flock_name ILIKE ?",
Joins("JOIN project_flocks pf_from ON pf_from.id = laying_transfers.from_project_flock_id"). searchPattern, searchPattern, searchPattern, searchPattern)
Joins("JOIN project_flocks pf_to ON pf_to.id = laying_transfers.to_project_flock_id").
Where("(pf_from.location_id IN ? OR pf_to.location_id IN ?)", scope.IDs, scope.IDs).
Distinct("laying_transfers.*")
} }
if params.TransferDate != "" {
db = db.Where("transfer_date::date = ?::date", params.TransferDate)
}
if params.FlockSource > 0 {
db = db.Where("from_project_flock_id = ?", params.FlockSource)
}
if params.FlockDestination > 0 {
db = db.Where("to_project_flock_id = ?", params.FlockDestination)
}
db = db.Order("created_at DESC") db = db.Order("created_at DESC")
db = s.withRelations(db)
return db return db
}) })
@@ -148,18 +155,15 @@ func (s transferLayingService) GetAll(c *fiber.Ctx, params *validation.Query) ([
return transferLayings, total, nil return transferLayings, total, nil
} }
func (s transferLayingService) GetOne(c *fiber.Ctx, id uint) (*entity.LayingTransfer, error) { func (s transferLayingService) GetOne(c *fiber.Ctx, id uint) (*entity.LayingTransfer, *entity.Approval, error) {
if err := m.EnsureLayingTransferAccess(c, s.Repository.DB(), id); err != nil {
return nil, err
}
transferLaying, err := s.Repository.GetByID(c.Context(), id, s.withRelations) transferLaying, 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, "TransferLaying not found") return nil, nil, fiber.NewError(fiber.StatusNotFound, "TransferLaying not found")
} }
if err != nil { if err != nil {
s.Log.Errorf("Failed get transferLaying by id: %+v", err) s.Log.Errorf("Failed get transferLaying by id: %+v", err)
return nil, err return nil, nil, err
} }
approvalRepo := commonRepo.NewApprovalRepository(s.Repository.DB()) approvalRepo := commonRepo.NewApprovalRepository(s.Repository.DB())
@@ -170,15 +174,6 @@ func (s transferLayingService) GetOne(c *fiber.Ctx, id uint) (*entity.LayingTran
transferLaying.LatestApproval = latestApproval transferLaying.LatestApproval = latestApproval
} }
return transferLaying, nil
}
func (s transferLayingService) GetOneWithApproval(c *fiber.Ctx, id uint) (*entity.LayingTransfer, *entity.Approval, error) {
transferLaying, err := s.GetOne(c, id)
if err != nil {
return nil, nil, err
}
return transferLaying, transferLaying.LatestApproval, nil return transferLaying, transferLaying.LatestApproval, nil
} }
@@ -241,7 +236,7 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create)
for _, sourceDetail := range req.SourceKandangs { for _, sourceDetail := range req.SourceKandangs {
if sourceDetail.Quantity <= 0 { if sourceDetail.Quantity <= 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "Jumlah kandang sumber harus lebih dari 0") continue
} }
totalSourceQty += sourceDetail.Quantity totalSourceQty += sourceDetail.Quantity
@@ -272,11 +267,18 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create)
for _, targetDetail := range req.TargetKandangs { for _, targetDetail := range req.TargetKandangs {
if targetDetail.Quantity <= 0 { if targetDetail.Quantity <= 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "Jumlah kandang tujuan harus lebih dari 0") continue
} }
totalTargetQty += targetDetail.Quantity totalTargetQty += targetDetail.Quantity
} }
if totalSourceQty == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "Minimal harus ada 1 kandang sumber dengan jumlah lebih dari 0")
}
if totalTargetQty == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "Minimal harus ada 1 kandang tujuan dengan jumlah lebih dari 0")
}
if totalSourceQty != totalTargetQty { if totalSourceQty != totalTargetQty {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Jumlah total sumber (%.0f) harus sama dengan jumlah total tujuan (%.0f)", totalSourceQty, totalTargetQty)) return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Jumlah total sumber (%.0f) harus sama dengan jumlah total tujuan (%.0f)", totalSourceQty, totalTargetQty))
} }
@@ -304,11 +306,16 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create)
} }
for _, sourceDetail := range req.SourceKandangs { for _, sourceDetail := range req.SourceKandangs {
if sourceDetail.Quantity == 0 {
continue
}
productWarehouseId := sourceWarehouseMap[sourceDetail.ProjectFlockKandangId] productWarehouseId := sourceWarehouseMap[sourceDetail.ProjectFlockKandangId]
source := entity.LayingTransferSource{ source := entity.LayingTransferSource{
LayingTransferId: createBody.Id, LayingTransferId: createBody.Id,
SourceProjectFlockKandangId: sourceDetail.ProjectFlockKandangId, SourceProjectFlockKandangId: sourceDetail.ProjectFlockKandangId,
RequestedQty: sourceDetail.Quantity, // Quantity yang diminta user
UsageQty: 0, UsageQty: 0,
PendingUsageQty: 0, // Di-set 0, biarkan FIFO Consume yang handle saat Approval PendingUsageQty: 0, // Di-set 0, biarkan FIFO Consume yang handle saat Approval
ProductWarehouseId: &productWarehouseId, ProductWarehouseId: &productWarehouseId,
@@ -320,6 +327,9 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create)
} }
for _, targetDetail := range req.TargetKandangs { for _, targetDetail := range req.TargetKandangs {
if targetDetail.Quantity == 0 {
continue
}
targetprojectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), targetDetail.ProjectFlockKandangId) targetprojectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), targetDetail.ProjectFlockKandangId)
if err != nil { if err != nil {
@@ -393,7 +403,12 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat transfer laying") return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat transfer laying")
} }
return s.GetOne(c, createBody.Id) laying_transfer, _, err := s.GetOne(c, createBody.Id)
if err != nil {
return nil, err
}
return laying_transfer, nil
} }
func (s *transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.LayingTransfer, error) { func (s *transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.LayingTransfer, error) {
@@ -497,8 +512,9 @@ func (s *transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update,
source := entity.LayingTransferSource{ source := entity.LayingTransferSource{
LayingTransferId: id, LayingTransferId: id,
SourceProjectFlockKandangId: sourceDetail.ProjectFlockKandangId, SourceProjectFlockKandangId: sourceDetail.ProjectFlockKandangId,
RequestedQty: sourceDetail.Quantity, // Quantity yang diminta user
UsageQty: 0, UsageQty: 0,
PendingUsageQty: sourceDetail.Quantity, PendingUsageQty: 0, // Di-set 0, biarkan FIFO Consume yang handle saat Approval
ProductWarehouseId: &productWarehouseId, ProductWarehouseId: &productWarehouseId,
} }
if err := sourceRepo.CreateOne(c.Context(), &source, nil); err != nil { if err := sourceRepo.CreateOne(c.Context(), &source, nil); err != nil {
@@ -577,7 +593,9 @@ func (s *transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update,
return nil, err return nil, err
} }
return s.GetOne(c, id) layingTransfer, _, err := s.GetOne(c, id)
return layingTransfer, err
} }
func (s transferLayingService) DeleteOne(c *fiber.Ctx, id uint) error { func (s transferLayingService) DeleteOne(c *fiber.Ctx, id uint) error {
@@ -734,7 +752,7 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) (
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Target product warehouse tidak ditemukan untuk transfer %d", approvableID)) return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Target product warehouse tidak ditemukan untuk transfer %d", approvableID))
} }
note := fmt.Sprintf("Transfer to Laying #%s - Target Kandang", transfer.TransferNumber) note := fmt.Sprintf("Transfer to Laying #%s", transfer.TransferNumber)
replenishResult, err := s.FifoSvc.Replenish(c.Context(), commonSvc.StockReplenishRequest{ replenishResult, err := s.FifoSvc.Replenish(c.Context(), commonSvc.StockReplenishRequest{
StockableKey: fifo.StockableKeyTransferToLayingIn, StockableKey: fifo.StockableKeyTransferToLayingIn,
StockableID: target.Id, StockableID: target.Id,
@@ -768,7 +786,7 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) (
updated := make([]entity.LayingTransfer, 0, len(approvableIDs)) updated := make([]entity.LayingTransfer, 0, len(approvableIDs))
for _, approvableID := range approvableIDs { for _, approvableID := range approvableIDs {
transfer, err := s.GetOne(c, approvableID) transfer, _, err := s.GetOne(c, approvableID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -848,15 +866,15 @@ func (s transferLayingService) GetAvailableQtyPerKandang(ctx *fiber.Ctx, project
kandangAvailableQty := make(map[uint]float64) kandangAvailableQty := make(map[uint]float64)
for _, kandang := range kandangs { for _, kandang := range kandangs {
// Gunakan fungsi repository yang sama dengan recording service
totalQty, err := s.ProjectFlockPopulationRepo.GetTotalQtyByProjectFlockKandangID(ctx.Context(), kandang.Id) totalAvailable, err := s.ProjectFlockPopulationRepo.GetAvailableQtyByProjectFlockKandangID(ctx.Context(), kandang.Id)
if err != nil { if err != nil {
s.Log.Warnf("Failed to get total qty for kandang %d: %+v", kandang.Id, err) s.Log.Warnf("Failed to get available qty for kandang %d: %+v", kandang.Id, err)
kandangAvailableQty[kandang.Id] = 0 kandangAvailableQty[kandang.Id] = 0
continue continue
} }
kandangAvailableQty[kandang.Id] = totalQty kandangAvailableQty[kandang.Id] = totalAvailable
} }
return pf, kandangAvailableQty, nil return pf, kandangAvailableQty, nil
@@ -2,12 +2,12 @@ package validation
type SourceKandangDetail struct { type SourceKandangDetail struct {
ProjectFlockKandangId uint `json:"project_flock_kandang_id" validate:"required"` ProjectFlockKandangId uint `json:"project_flock_kandang_id" validate:"required"`
Quantity float64 `json:"quantity" validate:"required,gt=0"` Quantity float64 `json:"quantity"`
} }
type TargetKandangDetail struct { type TargetKandangDetail struct {
ProjectFlockKandangId uint `json:"project_flock_kandang_id" validate:"required"` ProjectFlockKandangId uint `json:"project_flock_kandang_id" validate:"required"`
Quantity float64 `json:"quantity" validate:"required,gt=0"` Quantity float64 `json:"quantity"`
} }
type Create struct { type Create struct {
@@ -29,8 +29,12 @@ 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"`
TransferDate string `query:"transfer_date" validate:"omitempty"`
FlockSource uint `query:"flock_source" validate:"omitempty,number"`
FlockDestination uint `query:"flock_destination" validate:"omitempty,number"`
} }
type Approve struct { type Approve struct {
@@ -363,7 +363,52 @@ func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file
); err != nil { ); err != nil {
return nil, err return nil, err
} }
if err := s.ensureUniqueUniformity(c.Context(), 0, req.ProjectFlockKandangId, req.Week, &uniformDate); err != nil {
pfk, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), req.ProjectFlockKandangId)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Project Flock Kandang not found")
}
return nil, err
}
category := strings.TrimSpace(pfk.ProjectFlock.Category)
if s.ProductionStandardRepo != nil && pfk.ProjectFlock.ProductionStandardId != 0 {
if standard, err := s.ProductionStandardRepo.GetByID(c.Context(), pfk.ProjectFlock.ProductionStandardId, nil); err == nil {
if strings.TrimSpace(standard.ProjectCategory) != "" {
category = standard.ProjectCategory
}
}
}
weekBase := 1
if strings.EqualFold(category, string(utils.ProjectFlockCategoryLaying)) {
weekBase = 18
}
if req.Week < weekBase {
if weekBase == 18 {
return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 18 for laying projects")
}
return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 1 for growing projects")
}
var latestWeek int
if err := s.Repository.DB().WithContext(c.Context()).
Model(&entity.ProjectFlockKandangUniformity{}).
Where("project_flock_kandang_id = ?", req.ProjectFlockKandangId).
Select("COALESCE(MAX(week), 0)").
Scan(&latestWeek).Error; err != nil {
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate uniformity week sequence")
}
if latestWeek == 0 && req.Week != weekBase {
if weekBase == 18 {
return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 18 for laying projects")
}
return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 1 for growing projects")
}
if latestWeek > 0 && req.Week > latestWeek+1 {
return nil, fiber.NewError(fiber.StatusBadRequest, "week must be sequential without skipping")
}
if err := s.ensureUniqueUniformity(c.Context(), 0, req.ProjectFlockKandangId, req.Week); err != nil {
return nil, err return nil, err
} }
@@ -508,8 +553,35 @@ func (s uniformityService) UpdateOne(c *fiber.Ctx, req *validation.Update, id ui
if req.ProjectFlockKandangId != nil { if req.ProjectFlockKandangId != nil {
targetPFKID = *req.ProjectFlockKandangId targetPFKID = *req.ProjectFlockKandangId
} }
if targetPFKID != 0 && targetWeek > 0 {
pfk, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), targetPFKID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fiber.NewError(fiber.StatusNotFound, "Project Flock Kandang not found")
}
return nil, err
}
category := strings.TrimSpace(pfk.ProjectFlock.Category)
if s.ProductionStandardRepo != nil && pfk.ProjectFlock.ProductionStandardId != 0 {
if standard, err := s.ProductionStandardRepo.GetByID(c.Context(), pfk.ProjectFlock.ProductionStandardId, nil); err == nil {
if strings.TrimSpace(standard.ProjectCategory) != "" {
category = standard.ProjectCategory
}
}
}
weekBase := 1
if strings.EqualFold(category, string(utils.ProjectFlockCategoryLaying)) {
weekBase = 18
}
if targetWeek < weekBase {
if weekBase == 18 {
return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 18 for laying projects")
}
return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 1 for growing projects")
}
}
if targetDate != nil { if targetDate != nil {
if err := s.ensureUniqueUniformity(c.Context(), id, targetPFKID, targetWeek, targetDate); err != nil { if err := s.ensureUniqueUniformity(c.Context(), id, targetPFKID, targetWeek); err != nil {
return nil, err return nil, err
} }
} }
@@ -625,7 +697,7 @@ func (s uniformityService) UpdateOne(c *fiber.Ctx, req *validation.Update, id ui
return s.GetOne(c, id) return s.GetOne(c, id)
} }
func (s *uniformityService) ensureUniqueUniformity(ctx context.Context, id uint, projectFlockKandangID uint, week int, uniformDate *time.Time) error { func (s *uniformityService) ensureUniqueUniformity(ctx context.Context, id uint, projectFlockKandangID uint, week int) error {
if projectFlockKandangID == 0 || week == 0 { if projectFlockKandangID == 0 || week == 0 {
return nil return nil
} }
@@ -36,6 +36,7 @@ type ExpenseReceivingPayload struct {
TransportPerItem *float64 TransportPerItem *float64
ReceivedQty float64 ReceivedQty float64
ReceivedDate *time.Time ReceivedDate *time.Time
VehicleNumber *string
} }
type groupedItem struct { type groupedItem struct {
@@ -166,12 +167,21 @@ func (b *expenseBridge) markExpensesUpdated(ctx context.Context, expenseIDs map[
if actorID == 0 { if actorID == 0 {
actorID = 1 actorID = 1
} }
svc := commonSvc.NewApprovalService(commonRepo.NewApprovalRepository(b.db)) approvalRepo := commonRepo.NewApprovalRepository(b.db)
action := entity.ApprovalActionUpdated svc := commonSvc.NewApprovalService(approvalRepo)
action := entity.ApprovalActionCreated
for id := range expenseIDs { for id := range expenseIDs {
if _, err := svc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(id), utils.ExpenseStepFinance, &action, actorID, nil); err != nil { latestApproval, err := approvalRepo.LatestByTarget(ctx, string(utils.ApprovalWorkflowExpense), uint(id), nil)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err return err
} }
if latestApproval == nil {
if _, err := svc.CreateApproval(ctx, utils.ApprovalWorkflowExpense, uint(id), utils.ExpenseStepFinance, &action, actorID, nil); err != nil {
return err
}
}
} }
return nil return nil
} }
@@ -182,6 +192,22 @@ func (b *expenseBridge) OnItemsReceived(c *fiber.Ctx, purchaseID uint, updates [
} }
ctx := c.Context() ctx := c.Context()
filtered := make([]ExpenseReceivingPayload, 0, len(updates))
for _, upd := range updates {
if upd.SupplierID == 0 {
continue
}
if upd.TransportPerItem == nil || *upd.TransportPerItem <= 0 {
continue
}
if upd.VehicleNumber == nil || strings.TrimSpace(*upd.VehicleNumber) == "" {
continue
}
filtered = append(filtered, upd)
}
if len(filtered) == 0 {
return nil
}
// Load current links to decide whether to update in place or recreate. // Load current links to decide whether to update in place or recreate.
type itemLink struct { type itemLink struct {
@@ -205,9 +231,9 @@ func (b *expenseBridge) OnItemsReceived(c *fiber.Ctx, purchaseID uint, updates [
itemLinks := make(map[uint]itemLink) itemLinks := make(map[uint]itemLink)
updatedExpenses := make(map[uint64]struct{}) updatedExpenses := make(map[uint64]struct{})
if len(updates) > 0 { if len(filtered) > 0 {
ids := make([]uint, 0, len(updates)) ids := make([]uint, 0, len(filtered))
for _, upd := range updates { for _, upd := range filtered {
if upd.PurchaseItemID != 0 { if upd.PurchaseItemID != 0 {
ids = append(ids, upd.PurchaseItemID) ids = append(ids, upd.PurchaseItemID)
} }
@@ -252,7 +278,7 @@ func (b *expenseBridge) OnItemsReceived(c *fiber.Ctx, purchaseID uint, updates [
groups := make(map[string][]groupedItem) groups := make(map[string][]groupedItem)
for _, payload := range updates { for _, payload := range filtered {
if payload.ReceivedDate == nil { if payload.ReceivedDate == nil {
return fiber.NewError(fiber.StatusBadRequest, "received_date is required") return fiber.NewError(fiber.StatusBadRequest, "received_date is required")
} }
@@ -18,6 +18,7 @@ import (
rSupplier "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/repositories" rSupplier "gitlab.com/mbugroup/lti-api.git/internal/modules/master/suppliers/repositories"
rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories" rWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories"
projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories" projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
rStockLogs "gitlab.com/mbugroup/lti-api.git/internal/modules/shared/repositories"
rPurchase "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories" rPurchase "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/validations" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/validations"
"gitlab.com/mbugroup/lti-api.git/internal/utils" "gitlab.com/mbugroup/lti-api.git/internal/utils"
@@ -757,6 +758,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
warehouseID uint warehouseID uint
supplierID uint supplierID uint
transportPerItem *float64 transportPerItem *float64
vehicleNumber *string
overrideWarehouse bool overrideWarehouse bool
receivedQty float64 receivedQty float64
} }
@@ -805,13 +807,16 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
if receivedQty > item.SubQty { if receivedQty > item.SubQty {
return nil, utils.BadRequest(fmt.Sprintf("Received quantity for item %d cannot exceed ordered quantity (%.3f)", payload.PurchaseItemID, item.SubQty)) return nil, utils.BadRequest(fmt.Sprintf("Received quantity for item %d cannot exceed ordered quantity (%.3f)", payload.PurchaseItemID, item.SubQty))
} }
if receivedQty < item.TotalUsed {
return nil, utils.BadRequest(fmt.Sprintf("Received quantity for item %d cannot be lower than used amount (%.3f)", payload.PurchaseItemID, item.TotalUsed))
}
if _, dup := visitedItems[payload.PurchaseItemID]; dup { if _, dup := visitedItems[payload.PurchaseItemID]; dup {
return nil, utils.BadRequest(fmt.Sprintf("Duplicate receiving data for item %d", payload.PurchaseItemID)) return nil, utils.BadRequest(fmt.Sprintf("Duplicate receiving data for item %d", payload.PurchaseItemID))
} }
visitedItems[payload.PurchaseItemID] = struct{}{} visitedItems[payload.PurchaseItemID] = struct{}{}
supplierID := purchase.SupplierId var supplierID uint
if payload.ExpeditionVendorID != nil && *payload.ExpeditionVendorID != 0 { if payload.ExpeditionVendorID != nil && *payload.ExpeditionVendorID != 0 {
supplierID = *payload.ExpeditionVendorID supplierID = *payload.ExpeditionVendorID
} }
@@ -825,6 +830,15 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
transportPerItem = &val transportPerItem = &val
} }
var vehicleNumber *string
if payload.VehicleNumber != nil && strings.TrimSpace(*payload.VehicleNumber) != "" {
val := strings.TrimSpace(*payload.VehicleNumber)
vehicleNumber = &val
} else if item.VehicleNumber != nil && strings.TrimSpace(*item.VehicleNumber) != "" {
val := strings.TrimSpace(*item.VehicleNumber)
vehicleNumber = &val
}
prepared = append(prepared, preparedReceiving{ prepared = append(prepared, preparedReceiving{
item: item, item: item,
payload: payload, payload: payload,
@@ -832,6 +846,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
warehouseID: warehouseID, warehouseID: warehouseID,
supplierID: supplierID, supplierID: supplierID,
transportPerItem: transportPerItem, transportPerItem: transportPerItem,
vehicleNumber: vehicleNumber,
overrideWarehouse: overrideWarehouse, overrideWarehouse: overrideWarehouse,
receivedQty: receivedQty, receivedQty: receivedQty,
}) })
@@ -871,19 +886,37 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
receivingAction = entity.ApprovalActionUpdated receivingAction = entity.ApprovalActionUpdated
} }
} }
noteSuffix := "receive"
if receivingAction == entity.ApprovalActionUpdated {
noteSuffix = "edit-receive"
}
receiveNote := fmt.Sprintf("%s#%s", strings.TrimSpace(*purchase.PoNumber), noteSuffix)
transactionErr := s.PurchaseRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error { transactionErr := s.PurchaseRepo.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
repoTx := rPurchase.NewPurchaseRepository(tx) repoTx := rPurchase.NewPurchaseRepository(tx)
pwRepoTx := rProductWarehouse.NewProductWarehouseRepository(tx) pwRepoTx := rProductWarehouse.NewProductWarehouseRepository(tx)
stockLogRepoTx := rStockLogs.NewStockLogRepository(tx)
deltas := make(map[uint]float64) deltas := make(map[uint]float64)
affected := make(map[uint]struct{}) affected := make(map[uint]struct{})
updates := make([]rPurchase.PurchaseReceivingUpdate, 0, len(prepared)) updates := make([]rPurchase.PurchaseReceivingUpdate, 0, len(prepared))
priceUpdates := make([]rPurchase.PurchasePricingUpdate, 0, len(prepared)) priceUpdates := make([]rPurchase.PurchasePricingUpdate, 0, len(prepared))
totalQtyDeltas := make(map[uint]float64)
fifoAdds := make([]struct { fifoAdds := make([]struct {
itemID uint itemID uint
pwID uint pwID uint
qty float64 qty float64
}, 0, len(prepared)) }, 0, len(prepared))
fifoSubs := make([]struct {
itemID uint
pwID uint
qty float64
}, 0, len(prepared))
logEntries := make([]struct {
itemID uint
pwID uint
delta float64
}, 0, len(prepared))
for _, prep := range prepared { for _, prep := range prepared {
item := prep.item item := prep.item
@@ -904,16 +937,38 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
newPWID = &pwID newPWID = &pwID
deltaQty := prep.receivedQty - item.TotalQty deltaQty := prep.receivedQty - item.TotalQty
switch { if newPWID != nil && deltaQty != 0 {
case deltaQty > 0 && newPWID != nil: logEntries = append(logEntries, struct {
fifoAdds = append(fifoAdds, struct {
itemID uint itemID uint
pwID uint pwID uint
qty float64 delta float64
}{itemID: item.Id, pwID: *newPWID, qty: deltaQty}) }{itemID: item.Id, pwID: *newPWID, delta: deltaQty})
}
switch {
case deltaQty > 0 && newPWID != nil:
if s.FifoSvc != nil {
fifoAdds = append(fifoAdds, struct {
itemID uint
pwID uint
qty float64
}{itemID: item.Id, pwID: *newPWID, qty: deltaQty})
} else {
deltas[*newPWID] += deltaQty
totalQtyDeltas[item.Id] += deltaQty
}
case deltaQty < 0 && newPWID != nil: case deltaQty < 0 && newPWID != nil:
deltas[*newPWID] += deltaQty // negative if s.FifoSvc != nil {
affected[*newPWID] = struct{}{} fifoSubs = append(fifoSubs, struct {
itemID uint
pwID uint
qty float64
}{itemID: item.Id, pwID: *newPWID, qty: deltaQty})
affected[*newPWID] = struct{}{}
} else {
deltas[*newPWID] += deltaQty // negative
affected[*newPWID] = struct{}{}
totalQtyDeltas[item.Id] += deltaQty
}
} }
dateCopy := prep.receivedDate dateCopy := prep.receivedDate
@@ -936,7 +991,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
updates = append(updates, update) updates = append(updates, update)
if item.Price > 0 && prep.receivedQty >= 0 { if prep.receivedQty >= 0 {
priceUpdates = append(priceUpdates, rPurchase.PurchasePricingUpdate{ priceUpdates = append(priceUpdates, rPurchase.PurchasePricingUpdate{
ItemID: item.Id, ItemID: item.Id,
Price: item.Price, Price: item.Price,
@@ -953,16 +1008,25 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
return err return err
} }
if err := pwRepoTx.CleanupEmpty(c.Context(), affected); err != nil {
return err
}
if len(priceUpdates) > 0 { if len(priceUpdates) > 0 {
if err := repoTx.UpdatePricing(c.Context(), purchase.Id, priceUpdates); err != nil { if err := repoTx.UpdatePricing(c.Context(), purchase.Id, priceUpdates); err != nil {
return err return err
} }
} }
if len(totalQtyDeltas) > 0 {
for itemID, delta := range totalQtyDeltas {
if delta == 0 {
continue
}
if err := tx.Model(&entity.PurchaseItem{}).
Where("purchase_id = ? AND id = ?", purchase.Id, itemID).
Update("total_qty", gorm.Expr("COALESCE(total_qty,0) + ?", delta)).Error; err != nil {
return err
}
}
}
// Update due_date based on earliest received date when receiving approved. // Update due_date based on earliest received date when receiving approved.
if earliestReceived != nil { if earliestReceived != nil {
due := earliestReceived.AddDate(0, 0, purchase.CreditTerm) due := earliestReceived.AddDate(0, 0, purchase.CreditTerm)
@@ -988,6 +1052,53 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
return err return err
} }
} }
for _, adj := range fifoSubs {
if adj.pwID == 0 || adj.qty >= 0 {
continue
}
if err := s.FifoSvc.AdjustStockableQuantity(c.Context(), commonSvc.StockAdjustRequest{
StockableKey: fifo.StockableKeyPurchaseItems,
StockableID: adj.itemID,
ProductWarehouseID: adj.pwID,
Quantity: adj.qty,
Tx: tx,
}); err != nil {
return err
}
}
}
if len(logEntries) > 0 {
logs := make([]*entity.StockLog, 0, len(logEntries))
for _, entry := range logEntries {
if entry.pwID == 0 || entry.delta == 0 {
continue
}
log := &entity.StockLog{
ProductWarehouseId: entry.pwID,
CreatedBy: actorID,
LoggableType: string(utils.StockLogTypePurchase),
LoggableId: purchase.Id,
Notes: receiveNote,
}
if entry.delta > 0 {
log.Increase = entry.delta
} else {
log.Decrease = -entry.delta
}
logs = append(logs, log)
}
if len(logs) > 0 {
if err := stockLogRepoTx.CreateMany(c.Context(), logs, nil); err != nil {
return err
}
}
}
if len(affected) > 0 {
if err := pwRepoTx.CleanupEmpty(c.Context(), affected); err != nil {
return err
}
} }
return nil return nil
@@ -1019,6 +1130,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
TransportPerItem: prep.transportPerItem, TransportPerItem: prep.transportPerItem,
ReceivedQty: prep.receivedQty, ReceivedQty: prep.receivedQty,
ReceivedDate: &date, ReceivedDate: &date,
VehicleNumber: prep.vehicleNumber,
} }
receivingPayloads = append(receivingPayloads, payload) receivingPayloads = append(receivingPayloads, payload)
} }
@@ -1414,10 +1526,6 @@ func (s *purchaseService) buildStaffAdjustmentPayload(
qtyCopy := effectiveQty qtyCopy := effectiveQty
update.Quantity = &qtyCopy update.Quantity = &qtyCopy
} }
if syncReceiving {
qtyCopy := effectiveQty
update.TotalQty = &qtyCopy
}
updates = append(updates, update) updates = append(updates, update)
delete(requestItems, item.Id) delete(requestItems, item.Id)
@@ -98,6 +98,8 @@ func (c *RepportController) GetMarketing(ctx *fiber.Ctx) error {
ProductId: int64(ctx.QueryInt("product_id", 0)), ProductId: int64(ctx.QueryInt("product_id", 0)),
WarehouseId: int64(ctx.QueryInt("warehouse_id", 0)), WarehouseId: int64(ctx.QueryInt("warehouse_id", 0)),
SalesPersonId: int64(ctx.QueryInt("sales_person_id", 0)), SalesPersonId: int64(ctx.QueryInt("sales_person_id", 0)),
AreaId: int64(ctx.QueryInt("area_id", 0)),
LocationId: int64(ctx.QueryInt("location_id", 0)),
MarketingType: ctx.Query("marketing_type", ""), MarketingType: ctx.Query("marketing_type", ""),
FilterBy: ctx.Query("filter_by", ""), FilterBy: ctx.Query("filter_by", ""),
StartDate: ctx.Query("start_date", ""), StartDate: ctx.Query("start_date", ""),
@@ -278,6 +280,65 @@ func (c *RepportController) GetHppPerKandang(ctx *fiber.Ctx) error {
return ctx.Status(fiber.StatusOK).JSON(resp) return ctx.Status(fiber.StatusOK).JSON(resp)
} }
func (c *RepportController) GetCustomerPayment(ctx *fiber.Ctx) error {
var customerIDs []uint
if customerIDsStr := ctx.Query("customer_ids"); customerIDsStr != "" {
ids := strings.Split(customerIDsStr, ",")
for _, idStr := range ids {
idStr = strings.TrimSpace(idStr)
if idStr != "" {
if id, err := strconv.ParseUint(idStr, 10, 32); err == nil {
customerIDs = append(customerIDs, uint(id))
}
}
}
}
query := &validation.CustomerPaymentQuery{
Page: ctx.QueryInt("page", 1),
Limit: ctx.QueryInt("limit", 10),
CustomerIDs: customerIDs,
StartDate: ctx.Query("start_date", ""),
EndDate: ctx.Query("end_date", ""),
}
// Validate pagination
if len(customerIDs) == 0 && (query.Page < 1 || query.Limit < 1) {
return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0 when customer_ids is not provided")
}
result, totalResults, err := c.RepportService.GetCustomerPayment(ctx, query)
if err != nil {
return err
}
// If single customer mode (only 1 customer ID), return without pagination
if len(customerIDs) == 1 {
return ctx.Status(fiber.StatusOK).
JSON(response.Success{
Code: fiber.StatusOK,
Status: "success",
Message: "Get customer payment report successfully",
Data: result,
})
}
// Multiple customers mode with pagination
return ctx.Status(fiber.StatusOK).
JSON(response.SuccessWithPaginate[dto.CustomerPaymentReportItem]{
Code: fiber.StatusOK,
Status: "success",
Message: "Get customer payment report successfully",
Meta: response.Meta{
Page: query.Page,
Limit: query.Limit,
TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))),
TotalResults: totalResults,
},
Data: result,
})
}
func (c *RepportController) GetProductionResult(ctx *fiber.Ctx) error { func (c *RepportController) GetProductionResult(ctx *fiber.Ctx) error {
idParam := ctx.Params("idProjectFlockKandang") idParam := ctx.Params("idProjectFlockKandang")
if idParam == "" { if idParam == "" {
@@ -0,0 +1,120 @@
package dto
import (
"strings"
"time"
"gitlab.com/mbugroup/lti-api.git/internal/entities"
customerDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/dto"
repportRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/repositories"
)
type CustomerPaymentReportRow struct {
TransactionType string `json:"transaction_type"`
TransactionID int64 `json:"transaction_id"`
TransDate time.Time `json:"trans_date"`
DeliveryDate *time.Time `json:"delivery_date"`
Reference string `json:"reference"`
Qty float64 `json:"qty"`
Weight float64 `json:"weight"`
AverageWeight float64 `json:"average_weight"`
UnitPrice float64 `json:"unit_price"`
FinalPrice float64 `json:"final_price"`
TotalPrice float64 `json:"total_price"`
PaymentAmount float64 `json:"payment_amount"`
AccountsReceivable float64 `json:"accounts_receivable"`
AgingDay *int `json:"aging_day"`
Status string `json:"status"`
VehicleNumbers []string `json:"vehicle_numbers"`
PickupInfo []string `json:"pickup_info"`
SalesPerson string `json:"sales_person"`
}
type CustomerPaymentReportSummary struct {
TotalQty float64 `json:"total_qty"`
TotalWeight float64 `json:"total_weight"`
TotalFinalAmount float64 `json:"total_final_amount"`
TotalGrandAmount float64 `json:"total_grand_amount"`
TotalPayment float64 `json:"total_payment"`
TotalAccountsReceivable float64 `json:"total_accounts_receivable"`
}
type CustomerPaymentReportItem struct {
Customer customerDTO.CustomerRelationDTO `json:"customer"`
InitialBalance float64 `json:"initial_balance"`
Rows []CustomerPaymentReportRow `json:"rows"`
Summary CustomerPaymentReportSummary `json:"summary"`
}
type CustomerPaymentReportResponse struct {
Data []CustomerPaymentReportItem `json:"data"`
}
func ToCustomerPaymentReportRow(tx repportRepo.CustomerPaymentTransaction) CustomerPaymentReportRow {
return CustomerPaymentReportRow{
TransactionType: tx.TransactionType,
TransactionID: tx.TransactionID,
TransDate: tx.TransDate,
DeliveryDate: tx.DeliveryDate,
Reference: tx.Reference,
Qty: tx.Qty,
Weight: tx.Weight,
AverageWeight: tx.AverageWeight,
UnitPrice: tx.Price,
FinalPrice: tx.FinalPrice,
TotalPrice: tx.TotalPrice,
PaymentAmount: tx.PaymentAmount,
VehicleNumbers: parseStringSlice(tx.VehicleNumbers),
PickupInfo: parseStringSlice(tx.PickupInfo),
SalesPerson: tx.SalesPerson,
}
}
func ToCustomerPaymentReportItem(customer entities.Customer, initialBalance float64, rows []CustomerPaymentReportRow, summary CustomerPaymentReportSummary) CustomerPaymentReportItem {
return CustomerPaymentReportItem{
Customer: customerDTO.ToCustomerRelationDTO(customer),
InitialBalance: initialBalance,
Rows: rows,
Summary: summary,
}
}
func ToCustomerPaymentReportSummary(rows []CustomerPaymentReportRow, initialBalance float64) CustomerPaymentReportSummary {
summary := CustomerPaymentReportSummary{}
for _, row := range rows {
summary.TotalQty += row.Qty
summary.TotalWeight += row.Weight
if row.TransactionType == "SALES" {
summary.TotalFinalAmount += row.FinalPrice
summary.TotalGrandAmount += row.TotalPrice
} else if row.TransactionType == "PAYMENT" {
summary.TotalPayment += row.PaymentAmount
}
}
// Total AR = Initial Balance - Total Sales + Total Payment
summary.TotalAccountsReceivable = initialBalance - summary.TotalGrandAmount + summary.TotalPayment
return summary
}
func parseStringSlice(str string) []string {
str = strings.TrimSpace(str)
if str == "" || str == "-" {
return []string{}
}
parts := strings.Split(str, ",")
result := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
result = append(result, part)
}
}
return result
}
+30 -35
View File
@@ -25,14 +25,15 @@ type HppPerKandangResponseData struct {
} }
type HppPerKandangRowDTO struct { type HppPerKandangRowDTO struct {
ID int `json:"id"` ID int `json:"id"`
Kandang HppPerKandangRowKandangDTO `json:"kandang"` Kandang HppPerKandangRowKandangDTO `json:"kandang"`
WeightRange HppPerKandangWeightRangeDTO `json:"weight_range"` NameWithPeriode string `json:"name_with_periode"`
RemainingChickenBirds int64 `json:"remaining_chicken_birds"` WeightRange HppPerKandangWeightRangeDTO `json:"weight_range"`
RemainingChickenWeightKg float64 `json:"remaining_chicken_weight_kg"` AvgWeightKg float64 `json:"avg_weight_kg"`
AvgWeightKg float64 `json:"avg_weight_kg"` EggProductionPieces int64 `json:"egg_production_pieces"`
EggProductionPieces int64 `json:"egg_production_pieces"` EggProductionKg float64 `json:"egg_production_kg"`
EggProductionKg float64 `json:"egg_production_kg"` // EggProductionTotalWeightKg float64 `json:"egg_production_total_weight_kg"`
// EggProductionTotalPieces int64 `json:"egg_production_total_pieces"`
// FeedCostRp float64 `json:"feed_cost_rp"` // FeedCostRp float64 `json:"feed_cost_rp"`
// OvkCostRp float64 `json:"ovk_cost_rp"` // OvkCostRp float64 `json:"ovk_cost_rp"`
EggHppRpPerKg float64 `json:"egg_hpp_rp_per_kg"` EggHppRpPerKg float64 `json:"egg_hpp_rp_per_kg"`
@@ -40,8 +41,8 @@ type HppPerKandangRowDTO struct {
FeedSuppliers []HppPerKandangSupplierDTO `json:"feed_suppliers"` FeedSuppliers []HppPerKandangSupplierDTO `json:"feed_suppliers"`
DocSuppliers []HppPerKandangSupplierDTO `json:"doc_suppliers"` DocSuppliers []HppPerKandangSupplierDTO `json:"doc_suppliers"`
AverageDocPriceRp int64 `json:"average_doc_price_rp"` AverageDocPriceRp int64 `json:"average_doc_price_rp"`
HppRp float64 `json:"hpp_rp"` // HppRp float64 `json:"hpp_rp"`
RemainingValueRp int64 `json:"remaining_value_rp"` // RemainingValueRp int64 `json:"remaining_value_rp"`
} }
type HppPerKandangRowKandangDTO struct { type HppPerKandangRowKandangDTO struct {
@@ -80,34 +81,28 @@ type HppPerKandangSummaryDTO struct {
} }
type HppPerKandangSummaryWeightRangeDTO struct { type HppPerKandangSummaryWeightRangeDTO struct {
ID int `json:"id"` ID int `json:"id"`
WeightRange HppPerKandangWeightRangeDTO `json:"weight_range"` WeightRange HppPerKandangWeightRangeDTO `json:"weight_range"`
Label string `json:"label"` Label string `json:"label"`
RemainingChickenBirds int64 `json:"remaining_chicken_birds"` AvgWeightKg float64 `json:"avg_weight_kg"`
RemainingChickenWeightKg float64 `json:"remaining_chicken_weight_kg"` EggProductionPieces int64 `json:"egg_production_pieces"`
AvgWeightKg float64 `json:"avg_weight_kg"` EggProductionKg float64 `json:"egg_production_kg"`
EggProductionPieces int64 `json:"egg_production_pieces"` EggHppRpPerKg float64 `json:"egg_hpp_rp_per_kg"`
EggProductionKg float64 `json:"egg_production_kg"` EggValueRp int64 `json:"egg_value_rp"`
EggHppRpPerKg float64 `json:"egg_hpp_rp_per_kg"` FeedSuppliers []HppPerKandangSupplierDTO `json:"feed_suppliers"`
EggValueRp int64 `json:"egg_value_rp"` DocSuppliers []HppPerKandangSupplierDTO `json:"doc_suppliers"`
FeedSuppliers []HppPerKandangSupplierDTO `json:"feed_suppliers"` AverageDocPriceRp float64 `json:"average_doc_price_rp"`
DocSuppliers []HppPerKandangSupplierDTO `json:"doc_suppliers"` HppRp float64 `json:"hpp_rp"`
AverageDocPriceRp float64 `json:"average_doc_price_rp"` RemainingValueRp int64 `json:"remaining_value_rp"`
HppRp float64 `json:"hpp_rp"`
RemainingValueRp int64 `json:"remaining_value_rp"`
} }
type HppPerKandangSummaryTotalDTO struct { type HppPerKandangSummaryTotalDTO struct {
TotalRemainingChickenBirds int64 `json:"total_remaining_chicken_birds"` AverageWeightKg float64 `json:"average_weight_kg"`
TotalRemainingChickenWeightKg float64 `json:"total_remaining_chicken_weight_kg"` TotalEggProductionPieces int64 `json:"total_egg_production_pieces"`
AverageWeightKg float64 `json:"average_weight_kg"` TotalEggProductionKg float64 `json:"total_egg_production_kg"`
TotalRemainingValueRp int64 `json:"total_remaining_value_rp"` AverageEggHppRpPerKg float64 `json:"average_egg_hpp_rp_per_kg"`
TotalEggProductionPieces int64 `json:"total_egg_production_pieces"` TotalEggValueRp int64 `json:"total_egg_value_rp"`
TotalEggProductionKg float64 `json:"total_egg_production_kg"` TotalAverageDocPriceRp float64 `json:"total_average_doc_price_rp"`
AverageEggHppRpPerKg float64 `json:"average_egg_hpp_rp_per_kg"`
TotalEggValueRp int64 `json:"total_egg_value_rp"`
TotalHppRp float64 `json:"total_hpp_rp"`
TotalAverageDocPriceRp float64 `json:"total_average_doc_price_rp"`
} }
func NewHppPerKandangFiltersDTO(area, location, kandang, weightMin, weightMax, period, showUnrecorded string) HppPerKandangFiltersDTO { func NewHppPerKandangFiltersDTO(area, location, kandang, weightMin, weightMax, period, showUnrecorded string) HppPerKandangFiltersDTO {
@@ -40,98 +40,22 @@ type RepportMarketingItemDTO struct {
type Summary struct { type Summary struct {
TotalQty int `json:"total_qty"` TotalQty int `json:"total_qty"`
TotalWeightKg float64 `json:"total_weight_kg"` TotalWeightKg float64 `json:"total_weight_kg"`
AverageWeightKg float64 `json:"average_weight_kg"`
AverageSalesPrice float64 `json:"average_sales_price"`
TotalSalesAmount int64 `json:"total_sales_amount"` TotalSalesAmount int64 `json:"total_sales_amount"`
TotalHppAmount int64 `json:"total_hpp_amount"` TotalHppAmount int64 `json:"total_hpp_amount"`
TotalHppPricePerKg float64 `json:"total_hpp_price_per_kg"` TotalHppPricePerKg float64 `json:"total_hpp_price_per_kg"`
} }
type RepportMarketingResponseDTO struct {
Items []RepportMarketingItemDTO `json:"items"`
Total *Summary `json:"total,omitempty"`
}
type ProductRelationDTOFixed struct { type ProductRelationDTOFixed struct {
productDTO.ProductRelationDTO productDTO.ProductRelationDTO
ProductPrice float64 `json:"product_price"` ProductPrice float64 `json:"product_price"`
SellingPrice *float64 `json:"selling_price,omitempty"` SellingPrice *float64 `json:"selling_price,omitempty"`
} }
func ToRepportMarketingItemDTO(mdp entity.MarketingDeliveryProduct, hppPricePerKg float64, category string) RepportMarketingItemDTO { func ToMarketingReportItems(mdps []entity.MarketingDeliveryProduct, hppMap map[uint]float64, agingMap map[int]int) []RepportMarketingItemDTO {
soDate := time.Time{}
agingDays := 0
if mdp.MarketingProduct.Marketing.SoDate.Year() > 1 {
soDate = mdp.MarketingProduct.Marketing.SoDate
agingDays = int(time.Since(soDate).Hours() / 24)
}
realizationDate := time.Time{}
if mdp.DeliveryDate != nil {
realizationDate = *mdp.DeliveryDate
}
doNumber := marketingDTO.GenerateDeliveryOrderNumber(mdp.MarketingProduct.Marketing.SoNumber, mdp.DeliveryDate, mdp.MarketingProduct.ProductWarehouse.WarehouseId)
totalWeightKg := mdp.UsageQty * mdp.AvgWeight
salesAmount := totalWeightKg * mdp.UnitPrice
var hpp float64
var hppAmount float64
if isProductEligibleForHpp(mdp, category) {
hpp = hppPricePerKg
hppAmount = totalWeightKg * hppPricePerKg
}
item := RepportMarketingItemDTO{
ID: int(mdp.Id),
SoDate: soDate,
RealizationDate: realizationDate,
AgingDays: agingDays,
DoNumber: doNumber,
MarketingType: getMarketingType(mdp),
Qty: mdp.UsageQty,
AverageWeightKg: mdp.AvgWeight,
TotalWeightKg: totalWeightKg,
SalesPricePerKg: mdp.UnitPrice,
HppPricePerKg: hpp,
SalesAmount: salesAmount,
HppAmount: hppAmount,
}
if mdp.MarketingProduct.ProductWarehouse.WarehouseId != 0 {
mapped := warehouseDTO.ToWarehouseRelationDTO(mdp.MarketingProduct.ProductWarehouse.Warehouse)
item.Warehouse = &mapped
}
if mdp.MarketingProduct.Marketing.CustomerId != 0 {
mapped := customerDTO.ToCustomerRelationDTO(mdp.MarketingProduct.Marketing.Customer)
item.Customer = &mapped
}
if mdp.MarketingProduct.Marketing.SalesPersonId != 0 {
mapped := userDTO.ToUserRelationDTO(mdp.MarketingProduct.Marketing.SalesPerson)
item.Sales = &mapped
}
item.VehicleNumber = mdp.VehicleNumber
if mdp.MarketingProduct.ProductWarehouse.ProductId != 0 {
mapped := productDTO.ToProductRelationDTO(mdp.MarketingProduct.ProductWarehouse.Product)
item.Product = newProductRelationDTOFixedPtr(&mapped)
}
return item
}
func ToRepportMarketingItemDTOs(mdps []entity.MarketingDeliveryProduct, hppPricePerKg float64, category string) []RepportMarketingItemDTO {
items := make([]RepportMarketingItemDTO, 0, len(mdps)) items := make([]RepportMarketingItemDTO, 0, len(mdps))
for _, mdp := range mdps {
items = append(items, ToRepportMarketingItemDTO(mdp, hppPricePerKg, category))
}
return items
}
func ToRepportMarketingItemDTOsWithHppMap(mdps []entity.MarketingDeliveryProduct, hppMap map[uint]float64) []RepportMarketingItemDTO {
items := make([]RepportMarketingItemDTO, 0, len(mdps))
for _, mdp := range mdps { for _, mdp := range mdps {
hppPerKg := float64(0) hppPerKg := float64(0)
category := "" category := ""
@@ -142,101 +66,113 @@ func ToRepportMarketingItemDTOsWithHppMap(mdps []entity.MarketingDeliveryProduct
category = projectFlockKandang.ProjectFlock.Category category = projectFlockKandang.ProjectFlock.Category
} }
item := ToRepportMarketingItemDTO(mdp, hppPerKg, category) soDate := time.Time{}
agingDays := 0
if mdp.MarketingProduct.Marketing.SoDate.Year() > 1 {
soDate = mdp.MarketingProduct.Marketing.SoDate
if ag, exists := agingMap[int(mdp.Id)]; exists {
agingDays = ag
} else {
agingDays = int(time.Since(soDate).Hours() / 24)
}
}
realizationDate := time.Time{}
if mdp.DeliveryDate != nil {
realizationDate = *mdp.DeliveryDate
}
totalWeightKg := mdp.UsageQty * mdp.AvgWeight
salesAmount := totalWeightKg * mdp.UnitPrice
var hpp float64
var hppAmount float64
var hasAyam, hasTelur, hasTrading bool
for _, flag := range mdp.MarketingProduct.ProductWarehouse.Product.Flags {
ft := utils.FlagType(flag.Name)
if ft == utils.FlagAyamAfkir || ft == utils.FlagAyamCulling || ft == utils.FlagAyamMati ||
ft == utils.FlagDOC || ft == utils.FlagPullet || ft == utils.FlagLayer {
hasAyam = true
}
if ft == utils.FlagTelur || ft == utils.FlagTelurUtuh || ft == utils.FlagTelurPecah ||
ft == utils.FlagTelurPutih || ft == utils.FlagTelurRetak {
hasTelur = true
}
if ft == utils.FlagOVK || ft == utils.FlagObat || ft == utils.FlagVitamin || ft == utils.FlagKimia ||
ft == utils.FlagPakan || ft == utils.FlagPreStarter || ft == utils.FlagStarter || ft == utils.FlagFinisher {
hasTrading = true
}
}
marketingType := "trading"
if hasTrading {
marketingType = "trading"
} else if hasTelur {
marketingType = "telur"
} else if hasAyam {
marketingType = "ayam"
}
eligibleForHpp := false
if utils.ProjectFlockCategory(category) == utils.ProjectFlockCategoryGrowing {
eligibleForHpp = hasAyam
} else {
eligibleForHpp = hasAyam || hasTelur
}
if eligibleForHpp {
hpp = hppPerKg
hppAmount = totalWeightKg * hppPerKg
}
item := RepportMarketingItemDTO{
ID: int(mdp.Id),
SoDate: soDate,
RealizationDate: realizationDate,
AgingDays: agingDays,
DoNumber: marketingDTO.GenerateDeliveryOrderNumber(mdp.MarketingProduct.Marketing.SoNumber, mdp.DeliveryDate, mdp.MarketingProduct.ProductWarehouse.WarehouseId),
MarketingType: marketingType,
Qty: mdp.UsageQty,
AverageWeightKg: mdp.AvgWeight,
TotalWeightKg: totalWeightKg,
SalesPricePerKg: mdp.UnitPrice,
HppPricePerKg: hpp,
SalesAmount: salesAmount,
HppAmount: hppAmount,
VehicleNumber: mdp.VehicleNumber,
}
if mdp.MarketingProduct.ProductWarehouse.WarehouseId != 0 {
mapped := warehouseDTO.ToWarehouseRelationDTO(mdp.MarketingProduct.ProductWarehouse.Warehouse)
item.Warehouse = &mapped
}
if mdp.MarketingProduct.Marketing.CustomerId != 0 {
mapped := customerDTO.ToCustomerRelationDTO(mdp.MarketingProduct.Marketing.Customer)
item.Customer = &mapped
}
if mdp.MarketingProduct.Marketing.SalesPersonId != 0 {
mapped := userDTO.ToUserRelationDTO(mdp.MarketingProduct.Marketing.SalesPerson)
item.Sales = &mapped
}
if mdp.MarketingProduct.ProductWarehouse.ProductId != 0 {
mapped := productDTO.ToProductRelationDTO(mdp.MarketingProduct.ProductWarehouse.Product)
item.Product = newProductRelationDTOFixedPtr(&mapped)
}
items = append(items, item) items = append(items, item)
} }
return items return items
} }
func getMarketingType(mdp entity.MarketingDeliveryProduct) string {
hasAyam, hasTelur, hasTrading := checkProductFlags(mdp.MarketingProduct.ProductWarehouse.Product.Flags)
if hasAyam {
return "ayam"
}
if hasTelur {
return "telur"
}
if hasTrading {
return "trading"
}
return "trading" // default to trading if no flags found
}
func checkProductFlags(flags []entity.Flag) (hasAyam, hasTelur, hasTrading bool) {
if len(flags) == 0 {
return false, false, false
}
for _, flag := range flags {
ft := utils.FlagType(flag.Name)
if ft == utils.FlagAyamAfkir || ft == utils.FlagAyamCulling || ft == utils.FlagAyamMati ||
ft == utils.FlagDOC || ft == utils.FlagPullet || ft == utils.FlagLayer {
hasAyam = true
}
if ft == utils.FlagTelur || ft == utils.FlagTelurUtuh || ft == utils.FlagTelurPecah ||
ft == utils.FlagTelurPutih || ft == utils.FlagTelurRetak {
hasTelur = true
}
if ft == utils.FlagOVK || ft == utils.FlagObat || ft == utils.FlagVitamin || ft == utils.FlagKimia ||
ft == utils.FlagPakan || ft == utils.FlagPreStarter || ft == utils.FlagStarter || ft == utils.FlagFinisher {
hasTrading = true
}
}
return hasAyam, hasTelur, hasTrading
}
func isProductEligibleForHpp(mdp entity.MarketingDeliveryProduct, category string) bool {
hasAyam, hasTelur, _ := checkProductFlags(mdp.MarketingProduct.ProductWarehouse.Product.Flags)
if utils.ProjectFlockCategory(category) == utils.ProjectFlockCategoryGrowing {
return hasAyam
}
return hasAyam || hasTelur
}
func ToSummary(mdps []entity.MarketingDeliveryProduct, hppPricePerKg float64, category string) *Summary {
if len(mdps) == 0 {
return nil
}
totalQty := 0
totalWeightKg := 0.0
totalEligibleWeightKg := 0.0
totalSalesAmount := int64(0)
totalHppAmount := int64(0)
for _, mdp := range mdps {
calculatedTotalWeight := mdp.UsageQty * mdp.AvgWeight
totalQty += int(mdp.UsageQty)
totalWeightKg += calculatedTotalWeight
totalSalesAmount += int64(calculatedTotalWeight * mdp.UnitPrice)
if isProductEligibleForHpp(mdp, category) {
totalEligibleWeightKg += calculatedTotalWeight
totalHppAmount += int64(calculatedTotalWeight * hppPricePerKg)
}
}
totalHppPricePerKg := float64(0)
if totalEligibleWeightKg > 0 {
totalHppPricePerKg = float64(totalHppAmount) / totalEligibleWeightKg
}
return &Summary{
TotalQty: totalQty,
TotalWeightKg: totalWeightKg,
TotalSalesAmount: totalSalesAmount,
TotalHppAmount: totalHppAmount,
TotalHppPricePerKg: totalHppPricePerKg,
}
}
func ToSummaryFromDTOItems(items []RepportMarketingItemDTO) *Summary { func ToSummaryFromDTOItems(items []RepportMarketingItemDTO) *Summary {
if len(items) == 0 { if len(items) == 0 {
return nil return nil
@@ -244,6 +180,8 @@ func ToSummaryFromDTOItems(items []RepportMarketingItemDTO) *Summary {
totalQty := 0 totalQty := 0
totalWeightKg := 0.0 totalWeightKg := 0.0
avgSalesPrice := 0.0
avgWeightKg := 0.0
totalSalesAmount := int64(0) totalSalesAmount := int64(0)
totalHppAmount := int64(0) totalHppAmount := int64(0)
@@ -255,29 +193,27 @@ func ToSummaryFromDTOItems(items []RepportMarketingItemDTO) *Summary {
} }
totalHppPricePerKg := float64(0) totalHppPricePerKg := float64(0)
if totalWeightKg > 0 { if totalWeightKg > 0 {
totalHppPricePerKg = float64(totalHppAmount) / totalWeightKg totalHppPricePerKg = float64(totalHppAmount) / totalWeightKg
avgSalesPrice = float64(totalSalesAmount) / totalWeightKg
}
if totalQty > 0 {
avgWeightKg = totalWeightKg / float64(totalQty)
} }
return &Summary{ return &Summary{
TotalQty: totalQty, TotalQty: totalQty,
TotalWeightKg: totalWeightKg, TotalWeightKg: totalWeightKg,
AverageWeightKg: avgWeightKg,
AverageSalesPrice: avgSalesPrice,
TotalSalesAmount: totalSalesAmount, TotalSalesAmount: totalSalesAmount,
TotalHppAmount: totalHppAmount, TotalHppAmount: totalHppAmount,
TotalHppPricePerKg: totalHppPricePerKg, TotalHppPricePerKg: totalHppPricePerKg,
} }
} }
func ToRepportMarketingResponseDTO(mdps []entity.MarketingDeliveryProduct, hppPricePerKg float64, category string) RepportMarketingResponseDTO {
items := ToRepportMarketingItemDTOs(mdps, hppPricePerKg, category)
total := ToSummary(mdps, hppPricePerKg, category)
return RepportMarketingResponseDTO{
Items: items,
Total: total,
}
}
func newProductRelationDTOFixedPtr(original *productDTO.ProductRelationDTO) *ProductRelationDTOFixed { func newProductRelationDTOFixedPtr(original *productDTO.ProductRelationDTO) *ProductRelationDTOFixed {
if original == nil { if original == nil {
return nil return nil
@@ -27,12 +27,12 @@ type PurchaseSupplierRowDTO struct {
} }
type PurchaseSupplierSummaryDTO struct { type PurchaseSupplierSummaryDTO struct {
TotalQty float64 `json:"total_qty"` TotalQty float64 `json:"total_qty"`
TotalPurchaseValue float64 `json:"total_purchase_value"` TotalPurchaseValue float64 `json:"total_purchase_value"`
TotalTransportValue float64 `json:"total_transport_value"` TotalTransportValue float64 `json:"total_transport_value"`
TotalAmount float64 `json:"total_amount"` TotalAmount float64 `json:"total_amount"`
TotalUnitPrice float64 `json:"total_unit_price"` TotalUnitPrice float64 `json:"total_unit_price"`
TotalTransportUnitPrice float64 `json:"total_transport_unit_price"` TotalTransportUnitPrice float64 `json:"total_transport_unit_price"`
} }
type PurchaseSupplierDTO struct { type PurchaseSupplierDTO struct {
@@ -122,11 +122,6 @@ func ToPurchaseSupplierDTO(supplier entity.Supplier, items []entity.PurchaseItem
rows := make([]PurchaseSupplierRowDTO, 0, len(items)) rows := make([]PurchaseSupplierRowDTO, 0, len(items))
summary := PurchaseSupplierSummaryDTO{} summary := PurchaseSupplierSummaryDTO{}
var unitPriceSum float64
var unitPriceCount int
var transportUnitPriceSum float64
var transportUnitPriceCount int
for i := range items { for i := range items {
row := ToPurchaseSupplierRowDTO(&items[i]) row := ToPurchaseSupplierRowDTO(&items[i])
rows = append(rows, row) rows = append(rows, row)
@@ -136,19 +131,16 @@ func ToPurchaseSupplierDTO(supplier entity.Supplier, items []entity.PurchaseItem
summary.TotalTransportValue += row.TransportValue summary.TotalTransportValue += row.TransportValue
summary.TotalAmount += row.TotalAmount summary.TotalAmount += row.TotalAmount
unitPriceSum += row.UnitPrice
unitPriceCount++
transportUnitPriceSum += row.TransportUnitPrice
transportUnitPriceCount++
} }
if unitPriceCount > 0 { if summary.TotalQty > 0 {
summary.TotalUnitPrice = math.Round(unitPriceSum / float64(unitPriceCount)) avg := summary.TotalPurchaseValue / summary.TotalQty
summary.TotalUnitPrice = math.Round(avg)
} }
if transportUnitPriceCount > 0 { if summary.TotalQty > 0 {
summary.TotalTransportUnitPrice = math.Round(transportUnitPriceSum / float64(transportUnitPriceCount)) avg := summary.TotalTransportValue / summary.TotalQty
summary.TotalTransportUnitPrice = math.Round(avg)
} }
return PurchaseSupplierDTO{ return PurchaseSupplierDTO{
+9 -1
View File
@@ -12,6 +12,8 @@ import (
expenseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories" expenseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/expenses/repositories"
marketingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories" marketingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/marketing/repositories"
customerRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/repositories"
productionStandardRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/production-standards/repositories"
chickinRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories" chickinRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/chickins/repositories"
recordingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories" recordingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories"
purchaseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories" purchaseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/purchases/repositories"
@@ -30,14 +32,20 @@ func (RepportModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *
chickinRepository := chickinRepo.NewChickinRepository(db) chickinRepository := chickinRepo.NewChickinRepository(db)
recordingRepository := recordingRepo.NewRecordingRepository(db) recordingRepository := recordingRepo.NewRecordingRepository(db)
approvalRepository := commonRepo.NewApprovalRepository(db) approvalRepository := commonRepo.NewApprovalRepository(db)
hppCostRepository := commonRepo.NewHppCostRepository(db)
purchaseSupplierRepository := repportRepo.NewPurchaseSupplierRepository(db) purchaseSupplierRepository := repportRepo.NewPurchaseSupplierRepository(db)
debtSupplierRepository := repportRepo.NewDebtSupplierRepository(db) debtSupplierRepository := repportRepo.NewDebtSupplierRepository(db)
hppPerKandangRepository := repportRepo.NewHppPerKandangRepository(db) hppPerKandangRepository := repportRepo.NewHppPerKandangRepository(db)
productionResultRepository := repportRepo.NewProductionResultRepository(db) productionResultRepository := repportRepo.NewProductionResultRepository(db)
customerPaymentRepository := repportRepo.NewCustomerPaymentRepository(db)
customerRepository := customerRepo.NewCustomerRepository(db)
standardGrowthDetailRepository := productionStandardRepo.NewStandardGrowthDetailRepository(db)
productionStandardDetailRepository := productionStandardRepo.NewProductionStandardDetailRepository(db)
userRepository := rUser.NewUserRepository(db) userRepository := rUser.NewUserRepository(db)
approvalSvc := approvalService.NewApprovalService(approvalRepository) approvalSvc := approvalService.NewApprovalService(approvalRepository)
repportService := sRepport.NewRepportService(validate, expenseRealizationRepository, marketingDeliveryProductRepository, purchaseRepository, chickinRepository, recordingRepository, approvalSvc, purchaseSupplierRepository, debtSupplierRepository, hppPerKandangRepository, productionResultRepository) hppSvc := approvalService.NewHppService(hppCostRepository)
repportService := sRepport.NewRepportService(db, validate, expenseRealizationRepository, marketingDeliveryProductRepository, purchaseRepository, chickinRepository, recordingRepository, approvalSvc, hppSvc, purchaseSupplierRepository, debtSupplierRepository, hppPerKandangRepository, productionResultRepository, customerPaymentRepository, customerRepository, standardGrowthDetailRepository, productionStandardDetailRepository)
userService := sUser.NewUserService(userRepository, validate) userService := sUser.NewUserService(userRepository, validate)
RepportRoutes(router, userService, repportService) RepportRoutes(router, userService, repportService)
@@ -0,0 +1,195 @@
package repositories
import (
"context"
"time"
"gorm.io/gorm"
)
type CustomerPaymentTransaction struct {
TransactionType string `gorm:"column:transaction_type"`
TransactionID int64 `gorm:"column:transaction_id"`
CustomerID int64 `gorm:"column:customer_id"`
TransDate time.Time `gorm:"column:trans_date"`
DeliveryDate *time.Time `gorm:"column:delivery_date"`
Reference string `gorm:"column:reference"`
VehicleNumbers string `gorm:"column:vehicle_numbers"`
Qty float64 `gorm:"column:qty"`
Weight float64 `gorm:"column:weight"`
AverageWeight float64 `gorm:"column:average_weight"`
Price float64 `gorm:"column:price"`
FinalPrice float64 `gorm:"column:final_price"`
TotalPrice float64 `gorm:"column:total_price"`
PaymentAmount float64 `gorm:"column:payment_amount"`
PickupInfo string `gorm:"column:pickup_info"`
SalesPerson string `gorm:"column:sales_person"`
}
type CustomerPaymentRepository interface {
GetCustomerPaymentTransactions(ctx context.Context, customerID *uint) ([]CustomerPaymentTransaction, error)
GetInitialBalanceByCustomer(ctx context.Context, customerID uint) (float64, error)
GetCustomerIDsWithTransactions(ctx context.Context, limit, offset int) ([]uint, int64, error)
}
type customerPaymentRepositoryImpl struct {
db *gorm.DB
}
func NewCustomerPaymentRepository(db *gorm.DB) CustomerPaymentRepository {
return &customerPaymentRepositoryImpl{db: db}
}
func (r *customerPaymentRepositoryImpl) GetCustomerPaymentTransactions(ctx context.Context, customerID *uint) ([]CustomerPaymentTransaction, error) {
salesQuery := r.db.WithContext(ctx).
Table("marketing_delivery_products mdp").
Select(`
'SALES' AS transaction_type,
mdp.id::BIGINT AS transaction_id,
c.id::BIGINT AS customer_id,
m.so_date::DATE AS trans_date,
mdp.delivery_date::DATE AS delivery_date,
m.so_number || '-' || TO_CHAR(mdp.delivery_date, 'YYYYMMDD') || '-' || CAST(pw.warehouse_id AS VARCHAR) AS reference,
COALESCE(mdp.vehicle_number, '') AS vehicle_numbers,
COALESCE(mdp.usage_qty, 0)::NUMERIC(15,3) AS qty,
COALESCE(mdp.total_weight, 0)::NUMERIC(15,3) AS weight,
COALESCE(mdp.avg_weight, 0)::NUMERIC(15,3) AS average_weight,
COALESCE(mdp.unit_price, 0)::NUMERIC(15,3) AS price,
COALESCE(mdp.total_price, 0)::NUMERIC(15,3) AS final_price,
COALESCE(mdp.total_price, 0)::NUMERIC(15,3) AS total_price,
0::NUMERIC(15,3) AS payment_amount,
w.name AS pickup_info,
u.name AS sales_person
`).
Joins("INNER JOIN marketing_products mp ON mp.id = mdp.marketing_product_id").
Joins("INNER JOIN marketings m ON m.id = mp.marketing_id").
Joins("INNER JOIN customers c ON c.id = m.customer_id").
Joins("INNER JOIN product_warehouses pw ON pw.id = mdp.product_warehouse_id").
Joins("INNER JOIN warehouses w ON w.id = pw.warehouse_id").
Joins("INNER JOIN users u ON u.id = m.sales_person_id").
Where("mdp.delivery_date IS NOT NULL").
Where("m.deleted_at IS NULL").
Where("c.deleted_at IS NULL")
if customerID != nil {
salesQuery = salesQuery.Where("c.id = ?", *customerID)
}
paymentQuery := r.db.WithContext(ctx).
Table("payments p").
Select(`
'PAYMENT' AS transaction_type,
p.id::BIGINT AS transaction_id,
c.id::BIGINT AS customer_id,
p.payment_date::DATE AS trans_date,
NULL AS delivery_date,
COALESCE(p.reference_number, p.payment_code) AS reference,
'-' AS vehicle_numbers,
0::NUMERIC(15,3) AS qty,
0::NUMERIC(15,3) AS weight,
0::NUMERIC(15,3) AS average_weight,
0::NUMERIC(15,3) AS price,
0::NUMERIC(15,3) AS final_price,
0::NUMERIC(15,3) AS total_price,
p.nominal::NUMERIC(15,3) AS payment_amount,
'-' AS pickup_info,
'-' AS sales_person
`).
Joins("INNER JOIN customers c ON c.id = p.party_id").
Where("p.party_type = ?", "CUSTOMER").
Where("p.direction = ?", "IN").
Where("p.transaction_type = ?", "PENJUALAN").
Where("p.deleted_at IS NULL").
Where("c.deleted_at IS NULL")
if customerID != nil {
paymentQuery = paymentQuery.Where("c.id = ?", *customerID)
}
var results []CustomerPaymentTransaction
err := r.db.WithContext(ctx).
Raw("? UNION ALL ? ORDER BY customer_id, trans_date, transaction_type DESC, transaction_id",
salesQuery,
paymentQuery,
).
Scan(&results).
Error
if err != nil {
return nil, err
}
return results, nil
}
func (r *customerPaymentRepositoryImpl) GetInitialBalanceByCustomer(ctx context.Context, customerID uint) (float64, error) {
var result struct {
Nominal float64
}
err := r.db.WithContext(ctx).
Table("payments").
Select("COALESCE(SUM(nominal), 0) as nominal").
Where("party_type = ?", "CUSTOMER").
Where("party_id = ?", customerID).
Where("transaction_type = ?", "SALDO_AWAL").
Where("deleted_at IS NULL").
Scan(&result).
Error
if err != nil {
return 0, err
}
return result.Nominal, nil
}
func (r *customerPaymentRepositoryImpl) GetCustomerIDsWithTransactions(ctx context.Context, limit, offset int) ([]uint, int64, error) {
subQuery := r.db.WithContext(ctx).
Table("(" +
"SELECT DISTINCT c.id as customer_id FROM marketing_delivery_products mdp " +
"INNER JOIN marketing_products mp ON mp.id = mdp.marketing_product_id " +
"INNER JOIN marketings m ON m.id = mp.marketing_id " +
"INNER JOIN customers c ON c.id = m.customer_id " +
"WHERE mdp.delivery_date IS NOT NULL AND m.deleted_at IS NULL AND c.deleted_at IS NULL " +
"UNION " +
"SELECT DISTINCT c.id as customer_id FROM payments p " +
"INNER JOIN customers c ON c.id = p.party_id " +
"WHERE p.party_type = 'CUSTOMER' AND p.direction = 'IN' " +
"AND p.transaction_type = 'PENJUALAN' AND p.deleted_at IS NULL AND c.deleted_at IS NULL" +
") as customer_ids")
var total int64
if err := subQuery.Count(&total).Error; err != nil {
return nil, 0, err
}
var customerIDs []uint
err := r.db.WithContext(ctx).
Table("("+
"SELECT DISTINCT c.id as customer_id FROM marketing_delivery_products mdp "+
"INNER JOIN marketing_products mp ON mp.id = mdp.marketing_product_id "+
"INNER JOIN marketings m ON m.id = mp.marketing_id "+
"INNER JOIN customers c ON c.id = m.customer_id "+
"WHERE mdp.delivery_date IS NOT NULL AND m.deleted_at IS NULL AND c.deleted_at IS NULL "+
"UNION "+
"SELECT DISTINCT c.id as customer_id FROM payments p "+
"INNER JOIN customers c ON c.id = p.party_id "+
"WHERE p.party_type = 'CUSTOMER' AND p.direction = 'IN' "+
"AND p.transaction_type = 'PENJUALAN' AND p.deleted_at IS NULL AND c.deleted_at IS NULL"+
") as customer_ids").
Select("customer_id").
Order("customer_id ASC").
Limit(limit).
Offset(offset).
Pluck("customer_id", &customerIDs).
Error
if err != nil {
return nil, 0, err
}
return customerIDs, total, nil
}
@@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"strings" "strings"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/validations" validation "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/validations"
@@ -17,6 +18,8 @@ type DebtSupplierRepository interface {
GetPurchasesBySuppliers(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) ([]entity.Purchase, error) GetPurchasesBySuppliers(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) ([]entity.Purchase, error)
GetPaymentsBySuppliers(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) ([]entity.Payment, error) GetPaymentsBySuppliers(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) ([]entity.Payment, error)
GetPaymentTotalsByReferences(ctx context.Context, supplierIDs []uint, references []string) (map[string]float64, error) GetPaymentTotalsByReferences(ctx context.Context, supplierIDs []uint, references []string) (map[string]float64, error)
GetPaymentSummariesByReferences(ctx context.Context, supplierIDs []uint, references []string) (map[string]PaymentReferenceSummary, error)
GetInitialBalanceTotals(ctx context.Context, supplierIDs []uint) (map[uint]float64, error)
GetPurchaseTotalsBeforeDate(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) (map[uint]float64, error) GetPurchaseTotalsBeforeDate(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) (map[uint]float64, error)
GetPaymentTotalsBeforeDate(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) (map[uint]float64, error) GetPaymentTotalsBeforeDate(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) (map[uint]float64, error)
} }
@@ -25,10 +28,30 @@ type debtSupplierRepositoryImpl struct {
db *gorm.DB db *gorm.DB
} }
type PaymentReferenceSummary struct {
Total float64
LatestPaymentDate time.Time
}
func NewDebtSupplierRepository(db *gorm.DB) DebtSupplierRepository { func NewDebtSupplierRepository(db *gorm.DB) DebtSupplierRepository {
return &debtSupplierRepositoryImpl{db: db} return &debtSupplierRepositoryImpl{db: db}
} }
func (r *debtSupplierRepositoryImpl) latestPurchaseApproval(ctx context.Context) *gorm.DB {
return r.db.WithContext(ctx).
Table("approvals AS a").
Select("a.approvable_id, a.step_number, a.action").
Joins(`
JOIN (
SELECT approvable_id, MAX(action_at) AS latest_action_at
FROM approvals
WHERE approvable_type = ?
GROUP BY approvable_id
) AS la ON la.approvable_id = a.approvable_id AND la.latest_action_at = a.action_at`,
string(utils.ApprovalWorkflowPurchase),
)
}
func resolveDebtSupplierDateColumn(filterBy string) string { func resolveDebtSupplierDateColumn(filterBy string) string {
switch strings.ToLower(strings.TrimSpace(filterBy)) { switch strings.ToLower(strings.TrimSpace(filterBy)) {
case "po_date": case "po_date":
@@ -46,7 +69,11 @@ func (r *debtSupplierRepositoryImpl) baseSupplierQuery(ctx context.Context, filt
db := r.db.WithContext(ctx). db := r.db.WithContext(ctx).
Model(&entity.Supplier{}). Model(&entity.Supplier{}).
Joins("JOIN purchases ON purchases.supplier_id = suppliers.id"). Joins("JOIN purchases ON purchases.supplier_id = suppliers.id").
Joins("JOIN purchase_items ON purchase_items.purchase_id = purchases.id") Joins("JOIN purchase_items ON purchase_items.purchase_id = purchases.id").
Joins("JOIN (?) AS la ON la.approvable_id = purchases.id", r.latestPurchaseApproval(ctx)).
Where("la.step_number >= ?", uint16(utils.PurchaseStepReceiving)).
Where("(la.action IS NULL OR la.action != ?)", string(entity.ApprovalActionRejected)).
Where("purchase_items.received_date IS NOT NULL")
if len(filters.SupplierIDs) > 0 { if len(filters.SupplierIDs) > 0 {
db = db.Where("suppliers.id IN ?", filters.SupplierIDs) db = db.Where("suppliers.id IN ?", filters.SupplierIDs)
@@ -167,7 +194,8 @@ func (r *debtSupplierRepositoryImpl) GetPaymentsBySuppliers(ctx context.Context,
Model(&entity.Payment{}). Model(&entity.Payment{}).
Where("party_type = ?", string(utils.PaymentPartySupplier)). Where("party_type = ?", string(utils.PaymentPartySupplier)).
Where("direction = ?", "OUT"). Where("direction = ?", "OUT").
Where("party_id IN ?", supplierIDs) Where("party_id IN ?", supplierIDs).
Where("transaction_type <> ?", string(utils.TransactionTypeSaldoAwal))
if strings.TrimSpace(filters.StartDate) != "" { if strings.TrimSpace(filters.StartDate) != "" {
if dateFrom, err := utils.ParseDateString(filters.StartDate); err == nil { if dateFrom, err := utils.ParseDateString(filters.StartDate); err == nil {
@@ -198,7 +226,11 @@ func (r *debtSupplierRepositoryImpl) getPurchaseIDs(ctx context.Context, supplie
Table("purchases"). Table("purchases").
Select("DISTINCT purchases.id"). Select("DISTINCT purchases.id").
Joins("JOIN purchase_items ON purchase_items.purchase_id = purchases.id"). Joins("JOIN purchase_items ON purchase_items.purchase_id = purchases.id").
Where("purchases.supplier_id IN ?", supplierIDs) Joins("JOIN (?) AS la ON la.approvable_id = purchases.id", r.latestPurchaseApproval(ctx)).
Where("purchases.supplier_id IN ?", supplierIDs).
Where("la.step_number >= ?", uint16(utils.PurchaseStepReceiving)).
Where("(la.action IS NULL OR la.action != ?)", string(entity.ApprovalActionRejected)).
Where("purchase_items.received_date IS NOT NULL")
if filters.StartDate != "" { if filters.StartDate != "" {
if dateFrom, err := utils.ParseDateString(filters.StartDate); err == nil { if dateFrom, err := utils.ParseDateString(filters.StartDate); err == nil {
@@ -238,6 +270,7 @@ func (r *debtSupplierRepositoryImpl) GetPaymentTotalsByReferences(ctx context.Co
Where("direction = ?", "OUT"). Where("direction = ?", "OUT").
Where("party_id IN ?", supplierIDs). Where("party_id IN ?", supplierIDs).
Where("reference_number IN ?", references). Where("reference_number IN ?", references).
Where("transaction_type <> ?", string(utils.TransactionTypeSaldoAwal)).
Group("reference_number"). Group("reference_number").
Scan(&rows).Error; err != nil { Scan(&rows).Error; err != nil {
return nil, err return nil, err
@@ -254,6 +287,75 @@ func (r *debtSupplierRepositoryImpl) GetPaymentTotalsByReferences(ctx context.Co
return result, nil return result, nil
} }
func (r *debtSupplierRepositoryImpl) GetPaymentSummariesByReferences(ctx context.Context, supplierIDs []uint, references []string) (map[string]PaymentReferenceSummary, error) {
if len(supplierIDs) == 0 || len(references) == 0 {
return map[string]PaymentReferenceSummary{}, nil
}
type paymentRow struct {
ReferenceNumber *string `gorm:"column:reference_number"`
Total float64 `gorm:"column:total"`
LatestPaymentDate time.Time `gorm:"column:latest_payment_date"`
}
rows := make([]paymentRow, 0)
if err := r.db.WithContext(ctx).
Model(&entity.Payment{}).
Select("reference_number, SUM(nominal) AS total, MAX(payment_date) AS latest_payment_date").
Where("party_type = ?", string(utils.PaymentPartySupplier)).
Where("direction = ?", "OUT").
Where("party_id IN ?", supplierIDs).
Where("reference_number IN ?", references).
Where("transaction_type <> ?", string(utils.TransactionTypeSaldoAwal)).
Group("reference_number").
Scan(&rows).Error; err != nil {
return nil, err
}
result := make(map[string]PaymentReferenceSummary, len(rows))
for _, row := range rows {
if row.ReferenceNumber == nil || strings.TrimSpace(*row.ReferenceNumber) == "" {
continue
}
result[*row.ReferenceNumber] = PaymentReferenceSummary{
Total: row.Total,
LatestPaymentDate: row.LatestPaymentDate,
}
}
return result, nil
}
func (r *debtSupplierRepositoryImpl) GetInitialBalanceTotals(ctx context.Context, supplierIDs []uint) (map[uint]float64, error) {
if len(supplierIDs) == 0 {
return map[uint]float64{}, nil
}
type balanceRow struct {
SupplierID uint `gorm:"column:supplier_id"`
Total float64 `gorm:"column:total"`
}
rows := make([]balanceRow, 0)
if err := r.db.WithContext(ctx).
Model(&entity.Payment{}).
Select("party_id AS supplier_id, SUM(nominal) AS total").
Where("party_type = ?", string(utils.PaymentPartySupplier)).
Where("party_id IN ?", supplierIDs).
Where("transaction_type = ?", string(utils.TransactionTypeSaldoAwal)).
Group("party_id").
Scan(&rows).Error; err != nil {
return nil, err
}
result := make(map[uint]float64, len(rows))
for _, row := range rows {
result[row.SupplierID] = row.Total
}
return result, nil
}
func (r *debtSupplierRepositoryImpl) GetPurchaseTotalsBeforeDate(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) (map[uint]float64, error) { func (r *debtSupplierRepositoryImpl) GetPurchaseTotalsBeforeDate(ctx context.Context, supplierIDs []uint, filters *validation.DebtSupplierQuery) (map[uint]float64, error) {
if len(supplierIDs) == 0 || strings.TrimSpace(filters.StartDate) == "" { if len(supplierIDs) == 0 || strings.TrimSpace(filters.StartDate) == "" {
return map[uint]float64{}, nil return map[uint]float64{}, nil
@@ -276,7 +378,11 @@ func (r *debtSupplierRepositoryImpl) GetPurchaseTotalsBeforeDate(ctx context.Con
Table("purchases"). Table("purchases").
Select("purchases.supplier_id AS supplier_id, SUM(purchase_items.total_price) AS total"). Select("purchases.supplier_id AS supplier_id, SUM(purchase_items.total_price) AS total").
Joins("JOIN purchase_items ON purchase_items.purchase_id = purchases.id"). Joins("JOIN purchase_items ON purchase_items.purchase_id = purchases.id").
Joins("JOIN (?) AS la ON la.approvable_id = purchases.id", r.latestPurchaseApproval(ctx)).
Where("purchases.supplier_id IN ?", supplierIDs). Where("purchases.supplier_id IN ?", supplierIDs).
Where("la.step_number >= ?", uint16(utils.PurchaseStepReceiving)).
Where("(la.action IS NULL OR la.action != ?)", string(entity.ApprovalActionRejected)).
Where("purchase_items.received_date IS NOT NULL").
Where(fmt.Sprintf("DATE(%s) < ?", dateColumn), dateFrom). Where(fmt.Sprintf("DATE(%s) < ?", dateColumn), dateFrom).
Group("purchases.supplier_id"). Group("purchases.supplier_id").
Scan(&rows).Error; err != nil { Scan(&rows).Error; err != nil {
@@ -313,6 +419,7 @@ func (r *debtSupplierRepositoryImpl) GetPaymentTotalsBeforeDate(ctx context.Cont
Where("party_type = ?", string(utils.PaymentPartySupplier)). Where("party_type = ?", string(utils.PaymentPartySupplier)).
Where("direction = ?", "OUT"). Where("direction = ?", "OUT").
Where("party_id IN ?", supplierIDs). Where("party_id IN ?", supplierIDs).
Where("transaction_type <> ?", string(utils.TransactionTypeSaldoAwal)).
Where("DATE(payment_date) < ?", dateFrom). Where("DATE(payment_date) < ?", dateFrom).
Group("party_id"). Group("party_id").
Scan(&rows).Error; err != nil { Scan(&rows).Error; err != nil {

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