mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-05-21 13:55:43 +00:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 32a8557a3b | |||
| e32b231c6c | |||
| ca6d0b160b | |||
| adabb4e035 | |||
| 67ecdbc1dd | |||
| e6244dea8a | |||
| d1e4bf060e | |||
| fc06b3e4db | |||
| 8ff97cb647 | |||
| 1b7ce3c62c | |||
| 21de17b18a | |||
| 2aaaab91f7 | |||
| 4227152979 | |||
| ec020ac17c | |||
| adc30ad5cd | |||
| 9fb5395469 | |||
| bc771660be | |||
| f2ae2cc731 | |||
| 409652c15e | |||
| 9d611b7492 | |||
| f13e4f907c | |||
| a5af469865 | |||
| 3f4d6c630a | |||
| cad15bcd78 | |||
| e0ff6e6d79 | |||
| 9f1c153841 | |||
| dbf72c7248 | |||
| 894fa0b22a | |||
| d680196919 |
+17
-87
@@ -1,90 +1,20 @@
|
|||||||
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_COMMIT_BRANCH == "development"'
|
||||||
DEPLOY_APP: "LTI-MBUGROUP"
|
|
||||||
# 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
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
+133
@@ -0,0 +1,133 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
@@ -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;
|
||||||
@@ -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"`
|
||||||
|
|||||||
@@ -236,9 +236,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)
|
||||||
@@ -277,6 +278,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", "")
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -33,6 +34,7 @@ type ClosingRepository interface {
|
|||||||
FetchSapronakChickinUsageDetails(ctx context.Context, pfkID uint) (map[uint][]SapronakDetailRow, error)
|
FetchSapronakChickinUsageDetails(ctx context.Context, pfkID uint) (map[uint][]SapronakDetailRow, error)
|
||||||
FetchSapronakAdjustments(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error)
|
FetchSapronakAdjustments(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error)
|
||||||
FetchSapronakTransfers(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error)
|
FetchSapronakTransfers(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, map[uint][]SapronakDetailRow, error)
|
||||||
|
FetchSapronakSales(ctx context.Context, kandangID uint) (map[uint][]SapronakDetailRow, error)
|
||||||
GetProductsWithFlagsByIDs(ctx context.Context, productIDs []uint) ([]entity.Product, error)
|
GetProductsWithFlagsByIDs(ctx context.Context, productIDs []uint) ([]entity.Product, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,10 +61,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"`
|
||||||
@@ -74,6 +84,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) {
|
||||||
@@ -109,14 +120,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 {
|
||||||
@@ -126,6 +159,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
|
||||||
@@ -379,6 +485,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 +534,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
|
||||||
@@ -476,6 +584,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,
|
||||||
'Transfer to other unit' AS notes
|
'Transfer to other unit' AS notes
|
||||||
FROM stock_transfer_details std
|
FROM stock_transfer_details std
|
||||||
@@ -522,13 +631,15 @@ 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,
|
||||||
'RETAIL CUSTOMER' AS destination_warehouse,
|
COALESCE(c.name, '') AS destination_warehouse,
|
||||||
'' AS destination,
|
'' AS destination,
|
||||||
mp.qty AS quantity,
|
mp.qty AS quantity,
|
||||||
|
u.id AS unit_id,
|
||||||
u.name AS unit,
|
u.name AS unit,
|
||||||
m.notes AS notes
|
m.notes AS notes
|
||||||
FROM marketing_products mp
|
FROM marketing_products mp
|
||||||
JOIN marketings m ON m.id = mp.marketing_id
|
JOIN marketings m ON m.id = mp.marketing_id
|
||||||
|
LEFT JOIN customers c ON c.id = m.customer_id
|
||||||
JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id
|
JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id
|
||||||
JOIN products prod ON prod.id = pw.product_id
|
JOIN products prod ON prod.id = pw.product_id
|
||||||
JOIN uoms u ON u.id = prod.uom_id
|
JOIN uoms u ON u.id = prod.uom_id
|
||||||
@@ -909,17 +1020,50 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand
|
|||||||
COALESCE(p.product_price, 0) AS price
|
COALESCE(p.product_price, 0) AS price
|
||||||
`).
|
`).
|
||||||
Joins("JOIN stock_transfers st ON st.id = std.stock_transfer_id").
|
Joins("JOIN stock_transfers st ON st.id = std.stock_transfer_id").
|
||||||
|
Joins("LEFT JOIN warehouses fw ON fw.id = st.from_warehouse_id").
|
||||||
Joins("JOIN product_warehouses pw ON pw.id = std.dest_product_warehouse_id").
|
Joins("JOIN product_warehouses pw ON pw.id = std.dest_product_warehouse_id").
|
||||||
Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
|
Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
|
||||||
Joins("JOIN products p ON p.id = std.product_id").
|
Joins("JOIN products p ON p.id = std.product_id").
|
||||||
Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct).
|
Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct).
|
||||||
Where("w.kandang_id = ?", kandangID).
|
Where("w.kandang_id = ?", kandangID).
|
||||||
|
Where("(fw.kandang_id IS NULL OR fw.kandang_id <> w.kandang_id)").
|
||||||
Where("f.name IN ?", sapronakFlagsAll)
|
Where("f.name IN ?", sapronakFlagsAll)
|
||||||
incoming, err := scanAndGroupDetails(incomingQuery)
|
incoming, err := scanAndGroupDetails(incomingQuery)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
incomingLayingQuery := r.withCtx(ctx).
|
||||||
|
Table("laying_transfer_targets AS ltt").
|
||||||
|
Select(`
|
||||||
|
pw.product_id AS product_id,
|
||||||
|
p.name AS product_name,
|
||||||
|
f.name AS flag,
|
||||||
|
lt.transfer_date::timestamp AS date,
|
||||||
|
COALESCE(lt.transfer_number, '') AS reference,
|
||||||
|
COALESCE(ltt.total_qty, 0) AS qty_in,
|
||||||
|
0 AS qty_out,
|
||||||
|
COALESCE(p.product_price, 0) AS price
|
||||||
|
`).
|
||||||
|
Joins("JOIN laying_transfers lt ON lt.id = ltt.laying_transfer_id").
|
||||||
|
Joins("LEFT JOIN laying_transfer_sources lts ON lts.laying_transfer_id = lt.id").
|
||||||
|
Joins("LEFT JOIN product_warehouses pw_source ON pw_source.id = lts.product_warehouse_id").
|
||||||
|
Joins("LEFT JOIN warehouses w_source ON w_source.id = pw_source.warehouse_id").
|
||||||
|
Joins("JOIN product_warehouses pw ON pw.id = ltt.product_warehouse_id").
|
||||||
|
Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
|
||||||
|
Joins("JOIN products p ON p.id = pw.product_id").
|
||||||
|
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...)
|
||||||
|
}
|
||||||
|
|
||||||
outgoingQuery := r.withCtx(ctx).
|
outgoingQuery := r.withCtx(ctx).
|
||||||
Table("stock_allocations AS sa").
|
Table("stock_allocations AS sa").
|
||||||
Select(`
|
Select(`
|
||||||
@@ -936,10 +1080,13 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand
|
|||||||
Joins("JOIN stock_transfers st ON st.id = std.stock_transfer_id").
|
Joins("JOIN stock_transfers st ON st.id = std.stock_transfer_id").
|
||||||
Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id").
|
Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id").
|
||||||
Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
|
Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
|
||||||
|
Joins("LEFT JOIN product_warehouses pw_dest ON pw_dest.id = std.dest_product_warehouse_id").
|
||||||
|
Joins("LEFT JOIN warehouses w_dest ON w_dest.id = pw_dest.warehouse_id").
|
||||||
Joins("JOIN products p ON p.id = std.product_id").
|
Joins("JOIN products p ON p.id = std.product_id").
|
||||||
Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct).
|
Joins("JOIN flags f ON f.flagable_id = p.id AND f.flagable_type = ?", entity.FlagableTypeProduct).
|
||||||
Where("sa.status = ?", entity.StockAllocationStatusActive).
|
Where("sa.status = ?", entity.StockAllocationStatusActive).
|
||||||
Where("w.kandang_id = ?", kandangID).
|
Where("w.kandang_id = ?", kandangID).
|
||||||
|
Where("(w_dest.kandang_id IS NULL OR w_dest.kandang_id <> w.kandang_id)").
|
||||||
Where("f.name IN ?", sapronakFlagsAll).
|
Where("f.name IN ?", sapronakFlagsAll).
|
||||||
Group("std.id, std.product_id, p.name, f.name, st.transfer_date, st.movement_number, p.product_price")
|
Group("std.id, std.product_id, p.name, f.name, st.transfer_date, st.movement_number, p.product_price")
|
||||||
outgoing, err := scanAndGroupDetails(outgoingQuery)
|
outgoing, err := scanAndGroupDetails(outgoingQuery)
|
||||||
@@ -947,9 +1094,71 @@ func (r *ClosingRepositoryImpl) FetchSapronakTransfers(ctx context.Context, kand
|
|||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
outgoingLayingQuery := r.withCtx(ctx).
|
||||||
|
Table("stock_allocations AS sa").
|
||||||
|
Select(`
|
||||||
|
pw.product_id AS product_id,
|
||||||
|
p.name AS product_name,
|
||||||
|
f.name AS flag,
|
||||||
|
lt.transfer_date::timestamp AS date,
|
||||||
|
COALESCE(lt.transfer_number, '') AS reference,
|
||||||
|
0 AS qty_in,
|
||||||
|
COALESCE(SUM(sa.qty), 0) AS qty_out,
|
||||||
|
COALESCE(p.product_price, 0) AS price
|
||||||
|
`).
|
||||||
|
Joins("JOIN laying_transfer_sources lts ON lts.id = sa.usable_id AND sa.usable_type = ?", fifo.UsableKeyTransferToLayingOut.String()).
|
||||||
|
Joins("JOIN laying_transfers lt ON lt.id = lts.laying_transfer_id").
|
||||||
|
Joins("LEFT JOIN laying_transfer_targets ltt ON ltt.laying_transfer_id = lt.id").
|
||||||
|
Joins("LEFT JOIN product_warehouses pw_dest ON pw_dest.id = ltt.product_warehouse_id").
|
||||||
|
Joins("LEFT JOIN warehouses w_dest ON w_dest.id = pw_dest.warehouse_id").
|
||||||
|
Joins("JOIN product_warehouses pw ON pw.id = sa.product_warehouse_id").
|
||||||
|
Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
|
||||||
|
Joins("JOIN products p ON p.id = pw.product_id").
|
||||||
|
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
|
return incoming, outgoing, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *ClosingRepositoryImpl) FetchSapronakSales(ctx context.Context, kandangID 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 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("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")
|
||||||
|
|
||||||
|
return scanAndGroupDetails(query)
|
||||||
|
}
|
||||||
|
|
||||||
func (r *ClosingRepositoryImpl) GetProductsWithFlagsByIDs(ctx context.Context, productIDs []uint) ([]entity.Product, error) {
|
func (r *ClosingRepositoryImpl) GetProductsWithFlagsByIDs(ctx context.Context, productIDs []uint) ([]entity.Product, error) {
|
||||||
if len(productIDs) == 0 {
|
if len(productIDs) == 0 {
|
||||||
return []entity.Product{}, nil
|
return []entity.Product{}, nil
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ 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)
|
||||||
|
|||||||
@@ -40,6 +40,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)
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,6 +354,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)
|
||||||
@@ -387,6 +389,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)
|
||||||
@@ -1030,4 +1100,3 @@ func closestFcrValues(standards []entity.FcrStandard, averageWeight float64) (fl
|
|||||||
|
|
||||||
return closest.Mortality, closest.FcrNumber
|
return closest.Mortality, closest.FcrNumber
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/go-playground/validator/v10"
|
"github.com/go-playground/validator/v10"
|
||||||
@@ -111,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, 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")
|
||||||
@@ -262,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(
|
||||||
@@ -271,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),
|
||||||
@@ -279,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) {
|
||||||
@@ -311,6 +315,7 @@ 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
|
||||||
}
|
}
|
||||||
@@ -350,6 +355,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.KandangId)
|
||||||
|
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 {
|
||||||
@@ -362,6 +371,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
|
||||||
@@ -400,13 +437,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 {
|
||||||
@@ -683,6 +724,25 @@ func (s sapronakService) buildSapronakItems(ctx context.Context, pfk entity.Proj
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for productID, details := range salesOutgoing {
|
||||||
|
flag, name := resolveFlagName(productID, details)
|
||||||
|
if !matchesFlag(flag) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
group := ensureGroup(flag)
|
||||||
|
for _, d := range details {
|
||||||
|
if d.Flag == "" {
|
||||||
|
d.Flag = flag
|
||||||
|
}
|
||||||
|
if d.ProductName == "" {
|
||||||
|
d.ProductName = name
|
||||||
|
}
|
||||||
|
group.Items = append(group.Items, d)
|
||||||
|
group.TotalKeluar += d.QtyKeluar
|
||||||
|
group.SaldoAkhir -= d.QtyKeluar
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
groups := make([]dto.SapronakGroupDTO, 0, len(groupMap))
|
groups := make([]dto.SapronakGroupDTO, 0, len(groupMap))
|
||||||
for _, g := range groupMap {
|
for _, g := range groupMap {
|
||||||
groups = append(groups, *g)
|
groups = append(groups, *g)
|
||||||
|
|||||||
@@ -24,4 +24,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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -26,8 +26,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)
|
||||||
|
|||||||
@@ -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"`
|
||||||
Warehouse *RecordingWarehouseDTO `json:"warehouse,omitempty"`
|
Kandang *RecordingKandangDTO `json:"kandang,omitempty"`
|
||||||
|
Location *RecordingLocationDTO `json:"location,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type RecordingDetailDTO struct {
|
type RecordingDetailDTO struct {
|
||||||
RecordingListDTO
|
RecordingListDTO
|
||||||
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,10 +142,11 @@ func ToRecordingDetailDTO(e entity.Recording) RecordingDetailDTO {
|
|||||||
|
|
||||||
return RecordingDetailDTO{
|
return RecordingDetailDTO{
|
||||||
RecordingListDTO: listDTO,
|
RecordingListDTO: listDTO,
|
||||||
ProductCategory: recordingProductCategory(e),
|
ProductCategory: recordingProductCategory(e),
|
||||||
Depletions: ToRecordingDepletionDTOs(e.Depletions),
|
Warehouse: recordingWarehouseDTO(e),
|
||||||
Stocks: ToRecordingStockDTOs(e.Stocks),
|
Depletions: ToRecordingDepletionDTOs(e.Depletions),
|
||||||
Eggs: ToRecordingEggDTOs(e.Eggs),
|
Stocks: ToRecordingStockDTOs(e.Stocks),
|
||||||
|
Eggs: ToRecordingEggDTOs(e.Eggs),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,7 +212,8 @@ func toRecordingListDTO(e entity.Recording) RecordingListDTO {
|
|||||||
CreatedAt: e.CreatedAt,
|
CreatedAt: e.CreatedAt,
|
||||||
UpdatedAt: e.UpdatedAt,
|
UpdatedAt: e.UpdatedAt,
|
||||||
CreatedUser: createdUser,
|
CreatedUser: createdUser,
|
||||||
Warehouse: recordingWarehouseDTO(e),
|
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
|
||||||
|
|||||||
@@ -74,6 +74,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)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -84,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").
|
||||||
@@ -107,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")
|
||||||
@@ -167,6 +206,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
|
||||||
@@ -322,38 +367,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) {
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ type RecordingFIFOIntegrationService interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var recordingStockUsableKey = fifo.UsableKeyRecordingStock
|
var recordingStockUsableKey = fifo.UsableKeyRecordingStock
|
||||||
|
var recordingDepletionUsableKey = fifo.UsableKeyRecordingDepletion
|
||||||
|
|
||||||
type recordingService struct {
|
type recordingService struct {
|
||||||
Log *logrus.Logger
|
Log *logrus.Logger
|
||||||
@@ -116,7 +117,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 err != nil {
|
if err != nil {
|
||||||
@@ -209,9 +211,6 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
|
|||||||
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
|
||||||
@@ -280,10 +279,24 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
||||||
|
if err := s.consumeRecordingDepletions(ctx, tx, mappedDepletions); 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 {
|
||||||
@@ -297,11 +310,7 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
|
|||||||
}
|
}
|
||||||
|
|
||||||
var warehouseDeltas map[uint]float64
|
var warehouseDeltas map[uint]float64
|
||||||
if s.FifoSvc != nil {
|
warehouseDeltas = buildWarehouseDeltas(nil, mappedDepletions, nil, mappedEggs)
|
||||||
warehouseDeltas = buildWarehouseDeltas(nil, mappedDepletions, nil, nil)
|
|
||||||
} else {
|
|
||||||
warehouseDeltas = buildWarehouseDeltas(nil, mappedDepletions, nil, mappedEggs)
|
|
||||||
}
|
|
||||||
if err := s.adjustProductWarehouseQuantities(ctx, tx, warehouseDeltas); err != nil {
|
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
|
||||||
@@ -407,9 +416,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 {
|
||||||
@@ -431,17 +437,38 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
|
|||||||
}
|
}
|
||||||
|
|
||||||
if hasDepletionChanges {
|
if hasDepletionChanges {
|
||||||
|
if s.FifoSvc != nil {
|
||||||
|
if err := s.releaseRecordingDepletions(ctx, tx, existingDepletions); 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 {
|
||||||
|
if err := s.consumeRecordingDepletions(ctx, tx, mappedDepletions); 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
|
||||||
@@ -647,6 +674,11 @@ 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); 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) {
|
||||||
@@ -765,6 +797,46 @@ func (s *recordingService) consumeRecordingStocks(ctx context.Context, tx *gorm.
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *recordingService) consumeRecordingDepletions(ctx context.Context, tx *gorm.DB, depletions []entity.RecordingDepletion) error {
|
||||||
|
if len(depletions) == 0 || s.FifoSvc == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) error {
|
||||||
return s.consumeRecordingStocks(ctx, tx, stocks)
|
return s.consumeRecordingStocks(ctx, tx, stocks)
|
||||||
}
|
}
|
||||||
@@ -796,10 +868,67 @@ func (s *recordingService) releaseRecordingStocks(ctx context.Context, tx *gorm.
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *recordingService) releaseRecordingDepletions(ctx context.Context, tx *gorm.DB, depletions []entity.RecordingDepletion) error {
|
||||||
|
if len(depletions) == 0 || s.FifoSvc == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *recordingService) ReleaseRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock) error {
|
func (s *recordingService) ReleaseRecordingStocks(ctx context.Context, tx *gorm.DB, stocks []entity.RecordingStock) error {
|
||||||
return s.releaseRecordingStocks(ctx, tx, stocks)
|
return s.releaseRecordingStocks(ctx, tx, stocks)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 buildWarehouseDeltas(
|
func buildWarehouseDeltas(
|
||||||
oldDepletions, newDepletions []entity.RecordingDepletion,
|
oldDepletions, newDepletions []entity.RecordingDepletion,
|
||||||
oldEggs, newEggs []entity.RecordingEgg,
|
oldEggs, newEggs []entity.RecordingEgg,
|
||||||
@@ -941,10 +1070,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -990,43 +1117,20 @@ func ensureRecordingEggsUnused(eggs []entity.RecordingEgg) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func stocksMatch(existing []entity.RecordingStock, incoming []validation.Stock) bool {
|
func stocksMatch(existing []entity.RecordingStock, incoming []validation.Stock) bool {
|
||||||
hasPending := false
|
|
||||||
for _, item := range incoming {
|
|
||||||
if item.PendingQty != nil {
|
|
||||||
hasPending = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
existingUsage := make(map[uint]float64)
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1224,7 +1328,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
|
||||||
updates["egg_mass"] = eggMass
|
updates["egg_mass"] = eggMass
|
||||||
recording.EggMass = &eggMass
|
recording.EggMass = &eggMass
|
||||||
} else {
|
} else {
|
||||||
@@ -1234,7 +1338,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 {
|
||||||
|
|||||||
@@ -345,7 +345,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 = ? AND deleted_at IS NULL", 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -487,8 +532,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -604,7 +676,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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -751,6 +751,9 @@ 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))
|
||||||
@@ -835,6 +838,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
|
|||||||
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
|
||||||
@@ -862,14 +866,20 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
|
|||||||
deltaQty := prep.receivedQty - item.TotalQty
|
deltaQty := prep.receivedQty - item.TotalQty
|
||||||
switch {
|
switch {
|
||||||
case deltaQty > 0 && newPWID != nil:
|
case deltaQty > 0 && newPWID != nil:
|
||||||
fifoAdds = append(fifoAdds, struct {
|
if s.FifoSvc != nil {
|
||||||
itemID uint
|
fifoAdds = append(fifoAdds, struct {
|
||||||
pwID uint
|
itemID uint
|
||||||
qty float64
|
pwID uint
|
||||||
}{itemID: item.Id, pwID: *newPWID, qty: deltaQty})
|
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
|
deltas[*newPWID] += deltaQty // negative
|
||||||
affected[*newPWID] = struct{}{}
|
affected[*newPWID] = struct{}{}
|
||||||
|
totalQtyDeltas[item.Id] += deltaQty
|
||||||
}
|
}
|
||||||
|
|
||||||
dateCopy := prep.receivedDate
|
dateCopy := prep.receivedDate
|
||||||
@@ -892,7 +902,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,
|
||||||
@@ -919,6 +929,19 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -1371,10 +1394,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)
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -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,6 +28,11 @@ 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}
|
||||||
}
|
}
|
||||||
@@ -167,7 +175,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 {
|
||||||
@@ -238,6 +247,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 +264,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
|
||||||
@@ -313,6 +392,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 {
|
||||||
|
|||||||
@@ -25,6 +25,21 @@ func NewPurchaseSupplierRepository(db *gorm.DB) PurchaseSupplierRepository {
|
|||||||
return &purchaseSupplierRepositoryImpl{db: db}
|
return &purchaseSupplierRepositoryImpl{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *purchaseSupplierRepositoryImpl) latestPurchaseApproval(ctx context.Context) *gorm.DB {
|
||||||
|
return r.db.WithContext(ctx).
|
||||||
|
Table("approvals AS a").
|
||||||
|
Select("a.approvable_id, a.step_number, a.action").
|
||||||
|
Joins(`
|
||||||
|
JOIN (
|
||||||
|
SELECT approvable_id, MAX(action_at) AS latest_action_at
|
||||||
|
FROM approvals
|
||||||
|
WHERE approvable_type = ?
|
||||||
|
GROUP BY approvable_id
|
||||||
|
) AS la ON la.approvable_id = a.approvable_id AND la.latest_action_at = a.action_at`,
|
||||||
|
string(utils.ApprovalWorkflowPurchase),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func (r *purchaseSupplierRepositoryImpl) baseSupplierQuery(ctx context.Context, filters *validation.PurchaseSupplierQuery) *gorm.DB {
|
func (r *purchaseSupplierRepositoryImpl) baseSupplierQuery(ctx context.Context, filters *validation.PurchaseSupplierQuery) *gorm.DB {
|
||||||
dateColumn := "purchase_items.received_date"
|
dateColumn := "purchase_items.received_date"
|
||||||
switch strings.ToLower(strings.TrimSpace(filters.FilterBy)) {
|
switch strings.ToLower(strings.TrimSpace(filters.FilterBy)) {
|
||||||
@@ -34,10 +49,16 @@ func (r *purchaseSupplierRepositoryImpl) baseSupplierQuery(ctx context.Context,
|
|||||||
dateColumn = "purchase_items.received_date"
|
dateColumn = "purchase_items.received_date"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
latestApproval := r.latestPurchaseApproval(ctx)
|
||||||
|
|
||||||
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", latestApproval).
|
||||||
|
Where("la.step_number >= ?", uint16(utils.PurchaseStepReceiving)).
|
||||||
|
Where("(la.action IS NULL OR la.action != ?)", string(entity.ApprovalActionRejected)).
|
||||||
|
Where("purchase_items.received_date IS NOT NULL")
|
||||||
|
|
||||||
if filters.SupplierId > 0 {
|
if filters.SupplierId > 0 {
|
||||||
db = db.Where("suppliers.id = ?", filters.SupplierId)
|
db = db.Where("suppliers.id = ?", filters.SupplierId)
|
||||||
@@ -152,7 +173,11 @@ func (r *purchaseSupplierRepositoryImpl) GetItemsBySuppliers(ctx context.Context
|
|||||||
Preload("ExpenseNonstock.Expense").
|
Preload("ExpenseNonstock.Expense").
|
||||||
Preload("ExpenseNonstock.Expense.Supplier").
|
Preload("ExpenseNonstock.Expense.Supplier").
|
||||||
Joins("JOIN purchases ON purchases.id = purchase_items.purchase_id").
|
Joins("JOIN purchases ON purchases.id = purchase_items.purchase_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.ProductId > 0 {
|
if filters.ProductId > 0 {
|
||||||
db = db.Where("purchase_items.product_id = ?", filters.ProductId)
|
db = db.Where("purchase_items.product_id = ?", filters.ProductId)
|
||||||
|
|||||||
@@ -1129,6 +1129,17 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu
|
|||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
initialBalanceTotals, err := s.DebtSupplierRepo.GetInitialBalanceTotals(c.Context(), supplierIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
references := collectDebtSupplierReferences(purchases)
|
||||||
|
paymentSummaries, err := s.DebtSupplierRepo.GetPaymentSummariesByReferences(c.Context(), supplierIDs, references)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
location, err := time.LoadLocation("Asia/Jakarta")
|
location, err := time.LoadLocation("Asia/Jakarta")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "failed to load timezone configuration")
|
return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "failed to load timezone configuration")
|
||||||
@@ -1150,7 +1161,7 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
initialBalance := initialPaymentTotals[supplierID] - initialPurchaseTotals[supplierID]
|
initialBalance := initialBalanceTotals[supplierID] + (initialPaymentTotals[supplierID] - initialPurchaseTotals[supplierID])
|
||||||
items := purchasesBySupplier[supplierID]
|
items := purchasesBySupplier[supplierID]
|
||||||
paymentItems := paymentsBySupplier[supplierID]
|
paymentItems := paymentsBySupplier[supplierID]
|
||||||
total := dto.DebtSupplierTotalDTO{}
|
total := dto.DebtSupplierTotalDTO{}
|
||||||
@@ -1158,6 +1169,16 @@ func (s *repportService) GetDebtSupplier(c *fiber.Ctx, params *validation.DebtSu
|
|||||||
combinedRows := make([]debtSupplierRowItem, 0, len(items)+len(paymentItems))
|
combinedRows := make([]debtSupplierRowItem, 0, len(items)+len(paymentItems))
|
||||||
for _, purchase := range items {
|
for _, purchase := range items {
|
||||||
row := buildDebtSupplierRow(purchase, now, location)
|
row := buildDebtSupplierRow(purchase, now, location)
|
||||||
|
if reference := resolveDebtSupplierReference(purchase); reference != "" {
|
||||||
|
if summary, ok := paymentSummaries[reference]; ok {
|
||||||
|
if isDebtSupplierPaid(row.TotalPrice, summary.Total) {
|
||||||
|
row.Status = "Lunas"
|
||||||
|
if !summary.LatestPaymentDate.IsZero() {
|
||||||
|
row.Aging = calculateDebtSupplierAging(purchase, summary.LatestPaymentDate, location)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
sortTime := resolveDebtSupplierSortTime(purchase, params.FilterBy, location)
|
sortTime := resolveDebtSupplierSortTime(purchase, params.FilterBy, location)
|
||||||
combinedRows = append(combinedRows, debtSupplierRowItem{
|
combinedRows = append(combinedRows, debtSupplierRowItem{
|
||||||
Row: row,
|
Row: row,
|
||||||
@@ -1374,6 +1395,55 @@ func resolveDebtSupplierSortTime(purchase entity.Purchase, filterBy string, loc
|
|||||||
return purchase.CreatedAt.In(loc)
|
return purchase.CreatedAt.In(loc)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func collectDebtSupplierReferences(purchases []entity.Purchase) []string {
|
||||||
|
if len(purchases) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
seen := make(map[string]struct{}, len(purchases))
|
||||||
|
result := make([]string, 0, len(purchases))
|
||||||
|
for _, purchase := range purchases {
|
||||||
|
ref := resolveDebtSupplierReference(purchase)
|
||||||
|
if ref == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[ref]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[ref] = struct{}{}
|
||||||
|
result = append(result, ref)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveDebtSupplierReference(purchase entity.Purchase) string {
|
||||||
|
if purchase.PoNumber != nil {
|
||||||
|
if ref := strings.TrimSpace(*purchase.PoNumber); ref != "" {
|
||||||
|
return ref
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ref := strings.TrimSpace(purchase.PrNumber); ref != "" {
|
||||||
|
return ref
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDebtSupplierPaid(totalPrice, paymentTotal float64) bool {
|
||||||
|
if totalPrice <= 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return paymentTotal >= totalPrice-0.000001
|
||||||
|
}
|
||||||
|
|
||||||
|
func calculateDebtSupplierAging(purchase entity.Purchase, endDate time.Time, loc *time.Location) int {
|
||||||
|
prDate := purchase.CreatedAt.In(loc)
|
||||||
|
startDate := time.Date(prDate.Year(), prDate.Month(), prDate.Day(), 0, 0, 0, 0, loc)
|
||||||
|
stopDate := time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 0, 0, 0, 0, loc)
|
||||||
|
if stopDate.Before(startDate) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return int(stopDate.Sub(startDate).Hours() / 24)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangResponseData, *dto.HppPerKandangMetaDTO, error) {
|
func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangResponseData, *dto.HppPerKandangMetaDTO, error) {
|
||||||
params, filters, err := s.parseHppPerKandangQuery(ctx)
|
params, filters, err := s.parseHppPerKandangQuery(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ type HppPerKandangQuery struct {
|
|||||||
|
|
||||||
type ProductionResultQuery struct {
|
type ProductionResultQuery struct {
|
||||||
Page int `query:"page" validate:"omitempty,min=1,gt=0"`
|
Page int `query:"page" validate:"omitempty,min=1,gt=0"`
|
||||||
Limit int `query:"limit" validate:"omitempty,min=1,gt=0"`
|
Limit int `query:"limit" validate:"omitempty,min=1,max=1000,gt=0"`
|
||||||
ProjectFlockKandangID uint `query:"-" validate:"required,gt=0"`
|
ProjectFlockKandangID uint `query:"-" validate:"required,gt=0"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,18 +2,19 @@ package fifo
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// Usable Keys
|
// Usable Keys
|
||||||
UsableKeyRecordingStock UsableKey = "RECORDING_STOCK"
|
UsableKeyRecordingStock UsableKey = "RECORDING_STOCK"
|
||||||
UsableKeyProjectChickin UsableKey = "PROJECT_CHICKIN"
|
UsableKeyRecordingDepletion UsableKey = "RECORDING_DEPLETION"
|
||||||
UsableKeyMarketingDelivery UsableKey = "MARKETING_DELIVERY"
|
UsableKeyProjectChickin UsableKey = "PROJECT_CHICKIN"
|
||||||
UsableKeyTransferToLayingOut UsableKey = "TRANSFERTOLAYING_OUT"
|
UsableKeyMarketingDelivery UsableKey = "MARKETING_DELIVERY"
|
||||||
UsableKeyStockTransferOut UsableKey = "STOCK_TRANSFER_OUT"
|
UsableKeyTransferToLayingOut UsableKey = "TRANSFERTOLAYING_OUT"
|
||||||
UsableKeyAdjustmentOut UsableKey = "ADJUSTMENT_OUT"
|
UsableKeyStockTransferOut UsableKey = "STOCK_TRANSFER_OUT"
|
||||||
|
UsableKeyAdjustmentOut UsableKey = "ADJUSTMENT_OUT"
|
||||||
|
|
||||||
// Stockable Keys
|
// Stockable Keys
|
||||||
StockableKeyTransferToLayingIn StockableKey = "TRANSFERTOLAYING_IN"
|
StockableKeyTransferToLayingIn StockableKey = "TRANSFERTOLAYING_IN"
|
||||||
StockableKeyStockTransferIn StockableKey = "STOCK_TRANSFER_IN"
|
StockableKeyStockTransferIn StockableKey = "STOCK_TRANSFER_IN"
|
||||||
StockableKeyAdjustmentIn StockableKey = "ADJUSTMENT_IN"
|
StockableKeyAdjustmentIn StockableKey = "ADJUSTMENT_IN"
|
||||||
StockableKeyPurchaseItems StockableKey = "PURCHASE_ITEMS"
|
StockableKeyPurchaseItems StockableKey = "PURCHASE_ITEMS"
|
||||||
StockableKeyProjectFlockPopulation StockableKey = "PROJECT_FLOCK_POPULATION"
|
StockableKeyProjectFlockPopulation StockableKey = "PROJECT_FLOCK_POPULATION"
|
||||||
StockableKeyRecordingEgg StockableKey = "RECORDING_EGG"
|
StockableKeyRecordingEgg StockableKey = "RECORDING_EGG"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,15 +14,10 @@ func MapStocks(recordingID uint, items []validation.Stock) []entity.RecordingSto
|
|||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
usagePtr := new(float64)
|
usagePtr := new(float64)
|
||||||
*usagePtr = item.Qty
|
*usagePtr = item.Qty
|
||||||
pending := item.PendingQty
|
|
||||||
if pending == nil {
|
|
||||||
pending = new(float64)
|
|
||||||
}
|
|
||||||
result = append(result, entity.RecordingStock{
|
result = append(result, entity.RecordingStock{
|
||||||
RecordingId: recordingID,
|
RecordingId: recordingID,
|
||||||
ProductWarehouseId: item.ProductWarehouseId,
|
ProductWarehouseId: item.ProductWarehouseId,
|
||||||
UsageQty: usagePtr,
|
UsageQty: usagePtr,
|
||||||
PendingQty: pending,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
|
|||||||
Reference in New Issue
Block a user