mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-05-20 13:31:56 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 698904dcc8 | |||
| 12bc22c469 |
+1
-1
@@ -9,7 +9,7 @@ main
|
||||
bin/
|
||||
*.exe
|
||||
*.out
|
||||
.air.toml
|
||||
|
||||
Makefile
|
||||
docker-compose.local.yml
|
||||
docker-compose.yaml
|
||||
|
||||
+84
-29
@@ -1,35 +1,90 @@
|
||||
workflow:
|
||||
rules:
|
||||
# MR pipeline
|
||||
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
||||
when: always
|
||||
stages:
|
||||
- deploy
|
||||
|
||||
# Push pipeline hanya untuk env branch
|
||||
- if: '$CI_COMMIT_BRANCH == "development"'
|
||||
when: always
|
||||
- if: '$CI_COMMIT_BRANCH == "staging"'
|
||||
when: always
|
||||
- if: '$CI_COMMIT_BRANCH == "production"'
|
||||
when: always
|
||||
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"
|
||||
|
||||
# Selain itu jangan buat pipeline
|
||||
- when: never
|
||||
before_script:
|
||||
- echo "🧰 Installing dependencies..."
|
||||
- apk update && apk add --no-cache openssh git curl bash
|
||||
|
||||
include:
|
||||
# khusus MR (notif)
|
||||
- local: "ci/merge_request.yml"
|
||||
rules:
|
||||
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
||||
# 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
|
||||
|
||||
# khusus push ke branch env
|
||||
- local: "ci/development.yml"
|
||||
rules:
|
||||
- if: '$CI_COMMIT_BRANCH == "development"'
|
||||
# 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
|
||||
|
||||
- local: "ci/staging.yml"
|
||||
rules:
|
||||
- if: '$CI_COMMIT_BRANCH == "staging"'
|
||||
script:
|
||||
- echo "🚀 Deploying latest code to $SERVER_USER@$SERVER_IP"
|
||||
|
||||
- local: "ci/production.yml"
|
||||
rules:
|
||||
- if: '$CI_COMMIT_BRANCH == "production"'
|
||||
- >
|
||||
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
|
||||
@@ -106,8 +106,8 @@ internal/
|
||||
|
||||
## ✨ Author
|
||||
|
||||
IT Development PT Mitra Berlian Unggas Group
|
||||
IT Development PT Mitra Berlian Unggas Groups
|
||||
|
||||
## 📃 License
|
||||
## 📃 Licensee
|
||||
|
||||
> This project is private. All rights reserved.
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
stages:
|
||||
- deploy
|
||||
|
||||
deploy-dev:
|
||||
stage: deploy
|
||||
image: alpine:3.20
|
||||
|
||||
rules:
|
||||
- if: '$CI_COMMIT_BRANCH == "development"'
|
||||
when: on_success
|
||||
- when: never
|
||||
|
||||
variables:
|
||||
DEPLOY_APP: "LTI-MBUGROUP"
|
||||
GIT_SUBMODULE_STRATEGY: recursive
|
||||
GIT_DEPTH: "1"
|
||||
|
||||
before_script:
|
||||
- echo "🧰 Installing dependencies..."
|
||||
- apk update && apk add --no-cache openssh git curl bash
|
||||
|
||||
# Setup SSH di runner
|
||||
- mkdir -p ~/.ssh
|
||||
- echo "$SSH_PRIVATE_KEY" | tr -d '\r' > ~/.ssh/id_rsa
|
||||
- chmod 600 ~/.ssh/id_rsa
|
||||
- eval "$(ssh-agent -s)"
|
||||
- ssh-add ~/.ssh/id_rsa
|
||||
|
||||
# Trust host keys (server + gitlab) biar SSH gak nanya interaktif
|
||||
- ssh-keyscan -H "$SERVER_IP" >> ~/.ssh/known_hosts
|
||||
- ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts
|
||||
|
||||
script:
|
||||
- echo "🚀 Deploying latest code to $SERVER_USER@$SERVER_IP"
|
||||
- >
|
||||
if ssh -o StrictHostKeyChecking=no "$SERVER_USER@$SERVER_IP" "
|
||||
set -e
|
||||
|
||||
cd /home/devops/docker/deployment/development/lti-api
|
||||
|
||||
# Pastikan remote origin SSH (antisipasi kalau pernah ke-set HTTPS)
|
||||
git remote set-url origin git@gitlab.com:mbugroup/lti-api.git
|
||||
|
||||
# Pastikan server percaya gitlab.com juga (untuk git fetch via SSH)
|
||||
mkdir -p ~/.ssh
|
||||
ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts
|
||||
|
||||
# Fetch/reset pakai SSH
|
||||
GIT_SSH_COMMAND='ssh -o StrictHostKeyChecking=no' git fetch origin development
|
||||
git reset --hard origin/development
|
||||
|
||||
docker compose restart dev-api-lti || docker compose up -d dev-api-lti
|
||||
"; then
|
||||
STATUS='success';
|
||||
else
|
||||
STATUS='failed';
|
||||
fi;
|
||||
|
||||
RUN_URL="${CI_PROJECT_URL}/-/pipelines/${CI_PIPELINE_ID}";
|
||||
|
||||
if [ "$STATUS" = "success" ]; then
|
||||
COLOR=3066993;
|
||||
TITLE="✅ Deployment API Succeeded";
|
||||
DESC="Deployment job on branch \`${CI_COMMIT_REF_NAME}\` completed successfully.";
|
||||
else
|
||||
COLOR=15158332;
|
||||
TITLE="❌ Deployment API Failed Gaes";
|
||||
DESC="Deployment job on branch \`${CI_COMMIT_REF_NAME}\` failed.";
|
||||
fi;
|
||||
|
||||
echo "{
|
||||
\"username\": \"CI Bot\",
|
||||
\"embeds\": [{
|
||||
\"title\": \"$TITLE\",
|
||||
\"description\": \"$DESC\",
|
||||
\"color\": $COLOR,
|
||||
\"fields\": [
|
||||
{\"name\": \"Repository\", \"value\": \"${CI_PROJECT_PATH}\", \"inline\": true},
|
||||
{\"name\": \"Actor\", \"value\": \"${GITLAB_USER_LOGIN}\", \"inline\": true},
|
||||
{\"name\": \"Commit\", \"value\": \"${CI_COMMIT_SHA}\", \"inline\": false},
|
||||
{\"name\": \"Pipeline\", \"value\": \"[Open run](${RUN_URL})\", \"inline\": false}
|
||||
]
|
||||
}]
|
||||
}" > payload.json;
|
||||
|
||||
echo "📡 Sending notification to Discord...";
|
||||
curl -sS -H "Content-Type: application/json" \
|
||||
-d @payload.json "$DISCORD_WEBHOOK_URL";
|
||||
|
||||
environment:
|
||||
name: development
|
||||
@@ -1,48 +0,0 @@
|
||||
stages:
|
||||
- notify
|
||||
|
||||
notify_discord_on_mr_request_main_dev:
|
||||
stage: notify
|
||||
image: alpine:3.20
|
||||
rules:
|
||||
# hanya MR yang target ke main atau development
|
||||
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" && ($CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main" || $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "development")'
|
||||
when: on_success
|
||||
- when: never
|
||||
|
||||
script:
|
||||
- apk add --no-cache curl jq coreutils
|
||||
- |
|
||||
TIME_HUMAN="$(date '+%d/%m/%y, %H.%M')"
|
||||
TIME_ISO="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
|
||||
TITLE="${CI_MERGE_REQUEST_TITLE}"
|
||||
IID="!${CI_MERGE_REQUEST_IID}"
|
||||
USER_LINE="${GITLAB_USER_NAME} (${GITLAB_USER_LOGIN})"
|
||||
PROJECT_PATH="${CI_PROJECT_PATH}"
|
||||
USERNAME="${GITLAB_USER_LOGIN}"
|
||||
MR_URL="${CI_PROJECT_URL}/-/merge_requests/${CI_MERGE_REQUEST_IID}"
|
||||
|
||||
DESC="$(printf "**%s**\n\n%s opened merge request %s %s\n%s" \
|
||||
"$USERNAME" "$USER_LINE" "$IID" "$TITLE" "$TIME_HUMAN")"
|
||||
|
||||
payload=$(jq -n \
|
||||
--arg desc "$DESC" \
|
||||
--arg project "$PROJECT_PATH" \
|
||||
--arg timeiso "$TIME_ISO" \
|
||||
--arg mrurl "$MR_URL" \
|
||||
'{
|
||||
"username": "Mock-api - Merge Requests",
|
||||
"embeds": [
|
||||
{
|
||||
"description": ($desc + "\n" + $mrurl),
|
||||
"color": 15105570,
|
||||
"footer": { "text": $project },
|
||||
"timestamp": $timeiso
|
||||
}
|
||||
]
|
||||
}')
|
||||
|
||||
curl -sS -H "Content-Type: application/json" \
|
||||
-d "$payload" \
|
||||
"$DISCORD_WEBHOOK_URL"
|
||||
@@ -1,155 +0,0 @@
|
||||
stages:
|
||||
- build
|
||||
- migrate
|
||||
- deploy
|
||||
- seed
|
||||
|
||||
default:
|
||||
tags:
|
||||
- self-hosted-prod
|
||||
|
||||
variables:
|
||||
DOCKER_BUILDKIT: "1"
|
||||
|
||||
IMAGE_TAG: "production_${CI_COMMIT_SHORT_SHA}"
|
||||
IMAGE_NAME: "${CI_REGISTRY_IMAGE}:${IMAGE_TAG}"
|
||||
IMAGE_LATEST: "${CI_REGISTRY_IMAGE}:production_latest"
|
||||
|
||||
DEPLOY_DIR: "/opt/deploy/lti"
|
||||
COMPOSE_FILE: "docker-compose.yaml"
|
||||
|
||||
# =========================
|
||||
# BUILD (AUTO)
|
||||
# =========================
|
||||
build_production:
|
||||
stage: build
|
||||
rules:
|
||||
- if: '$CI_COMMIT_BRANCH == "production"'
|
||||
when: on_success
|
||||
- when: never
|
||||
script: |
|
||||
set -e
|
||||
docker info
|
||||
|
||||
echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
|
||||
|
||||
echo "✅ Build image: $IMAGE_NAME"
|
||||
docker build -t "$IMAGE_NAME" -f Dockerfile .
|
||||
|
||||
echo "✅ Push image: $IMAGE_NAME"
|
||||
docker push "$IMAGE_NAME"
|
||||
|
||||
echo "✅ Tag latest: $IMAGE_LATEST"
|
||||
docker tag "$IMAGE_NAME" "$IMAGE_LATEST"
|
||||
docker push "$IMAGE_LATEST"
|
||||
|
||||
# =========================
|
||||
# MIGRATE (PRODUCTION)
|
||||
# =========================
|
||||
migrate_production:
|
||||
stage: migrate
|
||||
rules:
|
||||
- if: '$CI_COMMIT_BRANCH == "production"'
|
||||
when: on_success
|
||||
- when: never
|
||||
needs:
|
||||
- job: build_production
|
||||
artifacts: false
|
||||
script: |
|
||||
set -e
|
||||
echo "✅ Running migrations (production) ..."
|
||||
|
||||
cd "$DEPLOY_DIR"
|
||||
test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found in $DEPLOY_DIR" && exit 1)
|
||||
test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1)
|
||||
|
||||
set -a
|
||||
. ./.env
|
||||
set +a
|
||||
|
||||
test -n "$DB_HOST" || (echo "❌ DB_HOST empty" && exit 1)
|
||||
test -n "$DB_PORT" || (echo "❌ DB_PORT empty" && exit 1)
|
||||
test -n "$DB_USER" || (echo "❌ DB_USER empty" && exit 1)
|
||||
test -n "$DB_PASSWORD" || (echo "❌ DB_PASSWORD empty" && exit 1)
|
||||
test -n "$DB_NAME" || (echo "❌ DB_NAME empty" && exit 1)
|
||||
|
||||
export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE:-disable}"
|
||||
echo "✅ DATABASE_URL=$DATABASE_URL"
|
||||
|
||||
# NOTE: pastikan nama servicenya benar untuk production (ini sebelumnya masih stg-*)
|
||||
docker compose -f "$COMPOSE_FILE" up -d stg-postgres-lti stg-redis-lti || true
|
||||
|
||||
COMPOSE_NETWORK_KEY="$(docker compose -f "$COMPOSE_FILE" config | awk '/networks:/ {getline; print $1}' | tr -d ':')"
|
||||
NETWORK_NAME="$(docker network ls --format '{{.Name}}' | grep "_${COMPOSE_NETWORK_KEY}$" | head -n 1)"
|
||||
test -n "$NETWORK_NAME" || (echo "❌ Cannot find docker network for compose ($COMPOSE_NETWORK_KEY)" && exit 1)
|
||||
|
||||
echo "✅ Checking migrations from repo..."
|
||||
ls -lah "$CI_PROJECT_DIR/internal/database/migrations"
|
||||
|
||||
echo "✅ Running migrations via migrate/migrate container"
|
||||
set +e
|
||||
out=$(docker run --rm \
|
||||
--network "$NETWORK_NAME" \
|
||||
-v "$CI_PROJECT_DIR/internal/database/migrations:/migrations:ro" \
|
||||
migrate/migrate:v4.15.2 \
|
||||
-path=/migrations -database "$DATABASE_URL" up 2>&1)
|
||||
code=$?
|
||||
set -e
|
||||
|
||||
echo "$out"
|
||||
|
||||
if echo "$out" | grep -qi "no change"; then
|
||||
echo "✅ No change (already up to date)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ $code -ne 0 ]; then
|
||||
echo "❌ Migration failed with exit code $code"
|
||||
exit $code
|
||||
fi
|
||||
|
||||
echo "✅ Migration applied successfully"
|
||||
|
||||
# =========================
|
||||
# DEPLOY (AUTO)
|
||||
# =========================
|
||||
deploy_production:
|
||||
stage: deploy
|
||||
rules:
|
||||
- if: '$CI_COMMIT_BRANCH == "production"'
|
||||
when: on_success
|
||||
- when: never
|
||||
needs:
|
||||
- job: build_production
|
||||
artifacts: false
|
||||
script: |
|
||||
set -e
|
||||
docker info
|
||||
echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
|
||||
|
||||
cd "$DEPLOY_DIR"
|
||||
test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found in $DEPLOY_DIR" && exit 1)
|
||||
test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1)
|
||||
|
||||
docker compose -f "$COMPOSE_FILE" pull
|
||||
docker compose -f "$COMPOSE_FILE" up -d --force-recreate
|
||||
docker image prune -f
|
||||
|
||||
# =========================
|
||||
# SEED (MANUAL)
|
||||
# =========================
|
||||
seed_production:
|
||||
stage: seed
|
||||
rules:
|
||||
- if: '$CI_COMMIT_BRANCH == "production"'
|
||||
when: manual
|
||||
- when: never
|
||||
script: |
|
||||
set -e
|
||||
cd "$DEPLOY_DIR"
|
||||
test -f .env || (echo "❌ .env not found" && exit 1)
|
||||
|
||||
echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
|
||||
|
||||
docker compose --env-file .env pull seed
|
||||
docker compose --env-file .env run --rm seed
|
||||
-164
@@ -1,164 +0,0 @@
|
||||
stages:
|
||||
- build
|
||||
- migrate
|
||||
- deploy
|
||||
- seed
|
||||
|
||||
default:
|
||||
tags:
|
||||
- self-hosted-stg
|
||||
|
||||
variables:
|
||||
DOCKER_BUILDKIT: "1"
|
||||
|
||||
IMAGE_TAG: "staging_${CI_COMMIT_SHORT_SHA}"
|
||||
IMAGE_NAME: "${CI_REGISTRY_IMAGE}:${IMAGE_TAG}"
|
||||
IMAGE_LATEST: "${CI_REGISTRY_IMAGE}:staging_latest"
|
||||
|
||||
DEPLOY_DIR: "/opt/deploy/stg-lti-api"
|
||||
COMPOSE_FILE: "docker-compose.yaml"
|
||||
|
||||
# =========================
|
||||
# BUILD (AUTO)
|
||||
# =========================
|
||||
build_staging:
|
||||
stage: build
|
||||
rules:
|
||||
- if: '$CI_COMMIT_BRANCH == "staging"'
|
||||
when: on_success
|
||||
- when: never
|
||||
script: |
|
||||
set -e
|
||||
docker info
|
||||
|
||||
echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
|
||||
|
||||
echo "✅ Build image: $IMAGE_NAME"
|
||||
docker build -t "$IMAGE_NAME" -f Dockerfile .
|
||||
|
||||
echo "✅ Push image: $IMAGE_NAME"
|
||||
docker push "$IMAGE_NAME"
|
||||
|
||||
echo "✅ Tag latest: $IMAGE_LATEST"
|
||||
docker tag "$IMAGE_NAME" "$IMAGE_LATEST"
|
||||
docker push "$IMAGE_LATEST"
|
||||
|
||||
# =========================
|
||||
# MIGRATE (AUTO)
|
||||
# =========================
|
||||
migrate_staging:
|
||||
stage: migrate
|
||||
rules:
|
||||
- if: '$CI_COMMIT_BRANCH == "staging"'
|
||||
when: on_success
|
||||
- when: never
|
||||
needs:
|
||||
- job: build_staging
|
||||
artifacts: false
|
||||
script: |
|
||||
set -e
|
||||
echo "✅ Running migrations (staging) ..."
|
||||
|
||||
cd "$DEPLOY_DIR"
|
||||
test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found in $DEPLOY_DIR" && exit 1)
|
||||
test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1)
|
||||
|
||||
set -a
|
||||
. ./.env
|
||||
set +a
|
||||
|
||||
test -n "$DB_HOST" || (echo "❌ DB_HOST empty" && exit 1)
|
||||
test -n "$DB_PORT" || (echo "❌ DB_PORT empty" && exit 1)
|
||||
test -n "$DB_USER" || (echo "❌ DB_USER empty" && exit 1)
|
||||
test -n "$DB_PASSWORD" || (echo "❌ DB_PASSWORD empty" && exit 1)
|
||||
test -n "$DB_NAME" || (echo "❌ DB_NAME empty" && exit 1)
|
||||
|
||||
export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE:-disable}"
|
||||
echo "✅ DATABASE_URL=$DATABASE_URL"
|
||||
|
||||
echo "✅ Ensuring postgres & redis running ..."
|
||||
docker compose -f "$COMPOSE_FILE" up -d stg-postgres-lti stg-redis-lti || true
|
||||
|
||||
COMPOSE_NETWORK_KEY="$(docker compose -f "$COMPOSE_FILE" config | awk '/networks:/ {getline; print $1}' | tr -d ':')"
|
||||
echo "✅ Compose network key: $COMPOSE_NETWORK_KEY"
|
||||
|
||||
NETWORK_NAME="$(docker network ls --format '{{.Name}}' | grep "_${COMPOSE_NETWORK_KEY}$" | head -n 1)"
|
||||
test -n "$NETWORK_NAME" || (echo "❌ Cannot find docker network for compose ($COMPOSE_NETWORK_KEY)" && exit 1)
|
||||
|
||||
echo "✅ Docker network detected: $NETWORK_NAME"
|
||||
|
||||
echo "✅ Checking migrations from repo..."
|
||||
ls -lah "$CI_PROJECT_DIR/internal/database/migrations"
|
||||
|
||||
echo "✅ Running migrations via migrate/migrate container"
|
||||
set +e
|
||||
out=$(docker run --rm \
|
||||
--network "$NETWORK_NAME" \
|
||||
-v "$CI_PROJECT_DIR/internal/database/migrations:/migrations:ro" \
|
||||
migrate/migrate:v4.15.2 \
|
||||
-path=/migrations -database "$DATABASE_URL" up 2>&1)
|
||||
code=$?
|
||||
set -e
|
||||
|
||||
echo "$out"
|
||||
|
||||
if echo "$out" | grep -qi "no change"; then
|
||||
echo "✅ No change (already up to date)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ $code -ne 0 ]; then
|
||||
echo "❌ Migration failed with exit code $code"
|
||||
exit $code
|
||||
fi
|
||||
|
||||
echo "✅ Migration applied successfully"
|
||||
|
||||
# =========================
|
||||
# DEPLOY (AUTO)
|
||||
# =========================
|
||||
deploy_staging:
|
||||
stage: deploy
|
||||
rules:
|
||||
- if: '$CI_COMMIT_BRANCH == "staging"'
|
||||
when: on_success
|
||||
- when: never
|
||||
needs:
|
||||
- job: migrate_staging
|
||||
artifacts: false
|
||||
- job: build_staging
|
||||
artifacts: false
|
||||
script: |
|
||||
set -e
|
||||
docker info
|
||||
echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
|
||||
|
||||
cd "$DEPLOY_DIR"
|
||||
test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found in $DEPLOY_DIR" && exit 1)
|
||||
test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1)
|
||||
|
||||
docker compose -f "$COMPOSE_FILE" pull
|
||||
docker compose -f "$COMPOSE_FILE" up -d --force-recreate
|
||||
docker image prune -f
|
||||
|
||||
# =========================
|
||||
# SEED (MANUAL)
|
||||
# =========================
|
||||
seed_staging:
|
||||
stage: seed
|
||||
rules:
|
||||
- if: '$CI_COMMIT_BRANCH == "staging"'
|
||||
when: manual
|
||||
- when: never
|
||||
needs:
|
||||
- job: deploy_staging
|
||||
artifacts: false
|
||||
allow_failure: false
|
||||
script: |
|
||||
set -e
|
||||
cd "$DEPLOY_DIR"
|
||||
test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found" && exit 1)
|
||||
test -f .env || (echo "❌ .env not found" && exit 1)
|
||||
|
||||
docker compose -f "$COMPOSE_FILE" pull seed || true
|
||||
docker compose -f "$COMPOSE_FILE" run --rm seed
|
||||
+1
-1
@@ -14,8 +14,8 @@ import (
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/database"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/middleware"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session"
|
||||
sso "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/verifier"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/route"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/sso"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
@@ -1,407 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/config"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/database"
|
||||
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type adjustmentRow struct {
|
||||
ID uint `gorm:"column:id"`
|
||||
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
|
||||
ProductID uint `gorm:"column:product_id"`
|
||||
FunctionCode string `gorm:"column:function_code"`
|
||||
TotalQty float64 `gorm:"column:total_qty"`
|
||||
UsageQty float64 `gorm:"column:usage_qty"`
|
||||
PendingQty float64 `gorm:"column:pending_qty"`
|
||||
StockLogIncrease float64 `gorm:"column:stock_log_increase"`
|
||||
StockLogDecrease float64 `gorm:"column:stock_log_decrease"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
}
|
||||
|
||||
type routeResolution struct {
|
||||
FlagGroupCode string `gorm:"column:flag_group_code"`
|
||||
Lane string `gorm:"column:lane"`
|
||||
FunctionCode string `gorm:"column:function_code"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
var (
|
||||
idsRaw string
|
||||
apply bool
|
||||
)
|
||||
|
||||
flag.StringVar(&idsRaw, "ids", "", "Comma-separated adjustment IDs (required), example: 1,2")
|
||||
flag.BoolVar(&apply, "apply", false, "Apply delete. If false, run as dry-run")
|
||||
flag.Parse()
|
||||
|
||||
ids, err := parseIDs(idsRaw)
|
||||
if err != nil {
|
||||
log.Fatalf("invalid --ids: %v", err)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
log.Fatal("--ids is required")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
db := database.Connect(config.DBHost, config.DBName)
|
||||
|
||||
fifoStockV2Svc := commonSvc.NewFifoStockV2Service(db, nil)
|
||||
|
||||
adjustments, err := loadAdjustments(ctx, db, ids)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to load adjustments: %v", err)
|
||||
}
|
||||
if len(adjustments) == 0 {
|
||||
log.Fatal("no adjustments found for provided IDs")
|
||||
}
|
||||
|
||||
sort.Slice(adjustments, func(i, j int) bool {
|
||||
return adjustments[i].ID < adjustments[j].ID
|
||||
})
|
||||
|
||||
fmt.Printf("Mode: %s\n", modeLabel(apply))
|
||||
fmt.Printf("Adjustments loaded: %d\n\n", len(adjustments))
|
||||
|
||||
success := 0
|
||||
failed := 0
|
||||
skipped := 0
|
||||
|
||||
for _, adj := range adjustments {
|
||||
if strings.TrimSpace(adj.FunctionCode) == "" {
|
||||
fmt.Printf("SKIP adj=%d reason=function_code empty\n", adj.ID)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
route, err := resolveRouteByFunctionCode(ctx, db, adj.ProductID, strings.ToUpper(strings.TrimSpace(adj.FunctionCode)))
|
||||
if err != nil {
|
||||
fmt.Printf("FAIL adj=%d error=resolve route: %v\n", adj.ID, err)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
|
||||
switch route.Lane {
|
||||
case "USABLE":
|
||||
desiredQty := adj.UsageQty + adj.PendingQty
|
||||
if desiredQty <= 0 && adj.StockLogDecrease > 0 {
|
||||
desiredQty = adj.StockLogDecrease
|
||||
}
|
||||
|
||||
activeAlloc, err := countActiveUsableAllocations(ctx, db, fifo.UsableKeyAdjustmentOut.String(), adj.ID)
|
||||
if err != nil {
|
||||
fmt.Printf("FAIL adj=%d error=count usable allocations: %v\n", adj.ID, err)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf(
|
||||
"PLAN adj=%d lane=USABLE function=%s usage=%.3f pending=%.3f active_alloc=%d action=reflow_to_zero+delete\n",
|
||||
adj.ID,
|
||||
route.FunctionCode,
|
||||
adj.UsageQty,
|
||||
adj.PendingQty,
|
||||
activeAlloc,
|
||||
)
|
||||
|
||||
if !apply {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
reflowReq := commonSvc.FifoStockV2ReflowRequest{
|
||||
FlagGroupCode: route.FlagGroupCode,
|
||||
ProductWarehouseID: adj.ProductWarehouseID,
|
||||
AsOf: &adj.CreatedAt,
|
||||
IdempotencyKey: fmt.Sprintf("delete-adjustment-usable-%d-%d", adj.ID, time.Now().UnixNano()),
|
||||
Tx: tx,
|
||||
}
|
||||
if _, err := fifoStockV2Svc.Reflow(ctx, reflowReq); err != nil {
|
||||
return fmt.Errorf("reflow usable to zero: %w", err)
|
||||
}
|
||||
|
||||
if err := hardDeleteUsableAllocations(ctx, tx, fifo.UsableKeyAdjustmentOut.String(), adj.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := hardDeleteAdjustmentStockLogs(ctx, tx, adj.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := hardDeleteAdjustment(ctx, tx, adj.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Printf("FAIL adj=%d error=%v\n", adj.ID, err)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf("DONE adj=%d deleted\n", adj.ID)
|
||||
success++
|
||||
|
||||
case "STOCKABLE":
|
||||
removeQty := adj.TotalQty
|
||||
if removeQty <= 0 && adj.StockLogIncrease > 0 {
|
||||
removeQty = adj.StockLogIncrease
|
||||
}
|
||||
|
||||
activeAlloc, err := countActiveStockableAllocations(ctx, db, fifo.StockableKeyAdjustmentIn.String(), adj.ID)
|
||||
if err != nil {
|
||||
fmt.Printf("FAIL adj=%d error=count stockable allocations: %v\n", adj.ID, err)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
if activeAlloc > 0 {
|
||||
fmt.Printf(
|
||||
"FAIL adj=%d reason=stockable still allocated active_alloc=%d action=delete blocked\n",
|
||||
adj.ID,
|
||||
activeAlloc,
|
||||
)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf(
|
||||
"PLAN adj=%d lane=STOCKABLE function=%s total=%.3f remove_qty=%.3f action=reflow_to_zero+delete\n",
|
||||
adj.ID,
|
||||
route.FunctionCode,
|
||||
adj.TotalQty,
|
||||
removeQty,
|
||||
)
|
||||
|
||||
if !apply {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.WithContext(ctx).
|
||||
Table("adjustment_stocks").
|
||||
Where("id = ?", adj.ID).
|
||||
Updates(map[string]any{
|
||||
"total_qty": 0,
|
||||
"total_used": 0,
|
||||
}).Error; err != nil {
|
||||
return fmt.Errorf("set stockable qty to zero: %w", err)
|
||||
}
|
||||
|
||||
reflowReq := commonSvc.FifoStockV2ReflowRequest{
|
||||
FlagGroupCode: route.FlagGroupCode,
|
||||
ProductWarehouseID: adj.ProductWarehouseID,
|
||||
AsOf: &adj.CreatedAt,
|
||||
IdempotencyKey: fmt.Sprintf("delete-adjustment-stockable-%d-%d", adj.ID, time.Now().UnixNano()),
|
||||
Tx: tx,
|
||||
}
|
||||
if _, err := fifoStockV2Svc.Reflow(ctx, reflowReq); err != nil {
|
||||
return fmt.Errorf("reflow stockable to zero: %w", err)
|
||||
}
|
||||
|
||||
if err := hardDeleteStockableAllocations(ctx, tx, fifo.StockableKeyAdjustmentIn.String(), adj.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := hardDeleteAdjustmentStockLogs(ctx, tx, adj.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := hardDeleteAdjustment(ctx, tx, adj.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Printf("FAIL adj=%d error=%v\n", adj.ID, err)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf("DONE adj=%d deleted\n", adj.ID)
|
||||
success++
|
||||
|
||||
default:
|
||||
fmt.Printf("SKIP adj=%d reason=unsupported lane=%s\n", adj.ID, route.Lane)
|
||||
skipped++
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Printf("Summary: success=%d failed=%d skipped=%d\n", success, failed, skipped)
|
||||
if failed > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func modeLabel(apply bool) string {
|
||||
if apply {
|
||||
return "APPLY"
|
||||
}
|
||||
return "DRY-RUN"
|
||||
}
|
||||
|
||||
func parseIDs(raw string) ([]uint, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
parts := strings.Split(raw, ",")
|
||||
out := make([]uint, 0, len(parts))
|
||||
seen := map[uint]struct{}{}
|
||||
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
v, err := strconv.ParseUint(part, 10, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid id %q", part)
|
||||
}
|
||||
if v == 0 {
|
||||
return nil, fmt.Errorf("id must be > 0: %q", part)
|
||||
}
|
||||
id := uint(v)
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
out = append(out, id)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func loadAdjustments(ctx context.Context, db *gorm.DB, ids []uint) ([]adjustmentRow, error) {
|
||||
var rows []adjustmentRow
|
||||
err := db.WithContext(ctx).
|
||||
Table("adjustment_stocks a").
|
||||
Select(`
|
||||
a.id,
|
||||
a.product_warehouse_id,
|
||||
pw.product_id,
|
||||
a.function_code,
|
||||
COALESCE(a.total_qty, 0) AS total_qty,
|
||||
COALESCE(a.usage_qty, 0) AS usage_qty,
|
||||
COALESCE(a.pending_qty, 0) AS pending_qty,
|
||||
COALESCE((
|
||||
SELECT sl.increase
|
||||
FROM stock_logs sl
|
||||
WHERE sl.loggable_type = 'ADJUSTMENT'
|
||||
AND sl.loggable_id = a.id
|
||||
ORDER BY sl.id DESC
|
||||
LIMIT 1
|
||||
), 0) AS stock_log_increase,
|
||||
COALESCE((
|
||||
SELECT sl.decrease
|
||||
FROM stock_logs sl
|
||||
WHERE sl.loggable_type = 'ADJUSTMENT'
|
||||
AND sl.loggable_id = a.id
|
||||
ORDER BY sl.id DESC
|
||||
LIMIT 1
|
||||
), 0) AS stock_log_decrease,
|
||||
a.created_at
|
||||
`).
|
||||
Joins("JOIN product_warehouses pw ON pw.id = a.product_warehouse_id").
|
||||
Where("a.id IN ?", ids).
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func resolveRouteByFunctionCode(ctx context.Context, db *gorm.DB, productID uint, functionCode string) (*routeResolution, error) {
|
||||
var rows []routeResolution
|
||||
err := db.WithContext(ctx).
|
||||
Table("fifo_stock_v2_route_rules rr").
|
||||
Select("rr.flag_group_code, rr.lane, rr.function_code").
|
||||
Joins("JOIN fifo_stock_v2_flag_groups fg ON fg.code = rr.flag_group_code AND fg.is_active = TRUE").
|
||||
Where("rr.is_active = TRUE").
|
||||
Where("rr.function_code = ?", functionCode).
|
||||
Where(`
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM flags f
|
||||
JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.is_active = TRUE
|
||||
WHERE f.flagable_type = ?
|
||||
AND f.flagable_id = ?
|
||||
AND fm.flag_group_code = rr.flag_group_code
|
||||
)
|
||||
`, entity.FlagableTypeProduct, productID).
|
||||
Order("CASE WHEN rr.source_table = 'adjustment_stocks' THEN 0 ELSE 1 END ASC").
|
||||
Order("rr.id ASC").
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil, fmt.Errorf("no route found for product_id=%d function_code=%s", productID, functionCode)
|
||||
}
|
||||
|
||||
selected := rows[0]
|
||||
for _, row := range rows {
|
||||
if row.Lane != selected.Lane {
|
||||
return nil, fmt.Errorf("ambiguous lane for product_id=%d function_code=%s", productID, functionCode)
|
||||
}
|
||||
}
|
||||
selected.FunctionCode = functionCode
|
||||
return &selected, nil
|
||||
}
|
||||
|
||||
func countActiveUsableAllocations(ctx context.Context, db *gorm.DB, usableType string, usableID uint) (int64, error) {
|
||||
var count int64
|
||||
err := db.WithContext(ctx).
|
||||
Table("stock_allocations").
|
||||
Where("usable_type = ? AND usable_id = ?", usableType, usableID).
|
||||
Where("status = ?", entity.StockAllocationStatusActive).
|
||||
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
|
||||
Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
func countActiveStockableAllocations(ctx context.Context, db *gorm.DB, stockableType string, stockableID uint) (int64, error) {
|
||||
var count int64
|
||||
err := db.WithContext(ctx).
|
||||
Table("stock_allocations").
|
||||
Where("stockable_type = ? AND stockable_id = ?", stockableType, stockableID).
|
||||
Where("status = ?", entity.StockAllocationStatusActive).
|
||||
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
|
||||
Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
func hardDeleteUsableAllocations(ctx context.Context, tx *gorm.DB, usableType string, usableID uint) error {
|
||||
return tx.WithContext(ctx).
|
||||
Exec("DELETE FROM stock_allocations WHERE usable_type = ? AND usable_id = ? AND allocation_purpose = ?", usableType, usableID, entity.StockAllocationPurposeConsume).
|
||||
Error
|
||||
}
|
||||
|
||||
func hardDeleteStockableAllocations(ctx context.Context, tx *gorm.DB, stockableType string, stockableID uint) error {
|
||||
return tx.WithContext(ctx).
|
||||
Exec("DELETE FROM stock_allocations WHERE stockable_type = ? AND stockable_id = ? AND allocation_purpose = ?", stockableType, stockableID, entity.StockAllocationPurposeConsume).
|
||||
Error
|
||||
}
|
||||
|
||||
func hardDeleteAdjustmentStockLogs(ctx context.Context, tx *gorm.DB, adjustmentID uint) error {
|
||||
return tx.WithContext(ctx).
|
||||
Exec("DELETE FROM stock_logs WHERE loggable_type = ? AND loggable_id = ?", "ADJUSTMENT", adjustmentID).
|
||||
Error
|
||||
}
|
||||
|
||||
func hardDeleteAdjustment(ctx context.Context, tx *gorm.DB, adjustmentID uint) error {
|
||||
return tx.WithContext(ctx).
|
||||
Exec("DELETE FROM adjustment_stocks WHERE id = ?", adjustmentID).
|
||||
Error
|
||||
}
|
||||
@@ -1,333 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/config"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/database"
|
||||
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type adjustmentRow struct {
|
||||
ID uint `gorm:"column:id"`
|
||||
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
|
||||
ProductID uint `gorm:"column:product_id"`
|
||||
FunctionCode string `gorm:"column:function_code"`
|
||||
UsageQty float64 `gorm:"column:usage_qty"`
|
||||
PendingQty float64 `gorm:"column:pending_qty"`
|
||||
StockLogIncrease float64 `gorm:"column:stock_log_increase"`
|
||||
StockLogDecrease float64 `gorm:"column:stock_log_decrease"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
}
|
||||
|
||||
type routeResolution struct {
|
||||
FlagGroupCode string `gorm:"column:flag_group_code"`
|
||||
Lane string `gorm:"column:lane"`
|
||||
FunctionCode string `gorm:"column:function_code"`
|
||||
SourceTable string `gorm:"column:source_table"`
|
||||
LegacyTypeKey string `gorm:"column:legacy_type_key"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
var (
|
||||
idsRaw string
|
||||
apply bool
|
||||
asOfCreatedAt bool
|
||||
compensateMissingAlloc bool
|
||||
)
|
||||
|
||||
flag.StringVar(&idsRaw, "ids", "", "Comma-separated adjustment IDs (required), example: 1,2")
|
||||
flag.BoolVar(&apply, "apply", false, "Apply changes. If false, run as dry-run")
|
||||
flag.BoolVar(&asOfCreatedAt, "as-of-created-at", true, "Use adjustment created_at as reflow AsOf boundary")
|
||||
flag.BoolVar(&compensateMissingAlloc, "compensate-missing-alloc", true, "When active allocations are missing and usage_qty > 0, temporarily add back usage_qty before reflow")
|
||||
flag.Parse()
|
||||
|
||||
ids, err := parseIDs(idsRaw)
|
||||
if err != nil {
|
||||
log.Fatalf("invalid --ids: %v", err)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
log.Fatal("--ids is required")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
db := database.Connect(config.DBHost, config.DBName)
|
||||
fifoStockV2Svc := commonSvc.NewFifoStockV2Service(db, nil)
|
||||
|
||||
adjustments, err := loadAdjustments(ctx, db, ids)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to load adjustments: %v", err)
|
||||
}
|
||||
if len(adjustments) == 0 {
|
||||
log.Fatal("no adjustments found for provided IDs")
|
||||
}
|
||||
|
||||
sort.Slice(adjustments, func(i, j int) bool {
|
||||
return adjustments[i].ID < adjustments[j].ID
|
||||
})
|
||||
|
||||
fmt.Printf("Mode: %s\n", modeLabel(apply))
|
||||
fmt.Printf("Adjustments loaded: %d\n\n", len(adjustments))
|
||||
|
||||
success := 0
|
||||
failed := 0
|
||||
skipped := 0
|
||||
|
||||
for _, adj := range adjustments {
|
||||
if strings.TrimSpace(adj.FunctionCode) == "" {
|
||||
fmt.Printf("SKIP adj=%d reason=function_code empty\n", adj.ID)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
route, err := resolveRouteByFunctionCode(ctx, db, adj.ProductID, strings.ToUpper(strings.TrimSpace(adj.FunctionCode)))
|
||||
if err != nil {
|
||||
fmt.Printf("FAIL adj=%d error=resolve route: %v\n", adj.ID, err)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
if route.Lane != "USABLE" {
|
||||
fmt.Printf("SKIP adj=%d reason=lane=%s (not USABLE)\n", adj.ID, route.Lane)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
desiredQty := adj.UsageQty + adj.PendingQty
|
||||
desiredQtySource := "usage+pending"
|
||||
if desiredQty <= 0 && adj.StockLogDecrease > 0 {
|
||||
desiredQty = adj.StockLogDecrease
|
||||
desiredQtySource = "stock_log.decrease"
|
||||
}
|
||||
if desiredQty <= 0 {
|
||||
fmt.Printf(
|
||||
"SKIP adj=%d reason=no usable qty (usage=%.3f pending=%.3f stock_log.decrease=%.3f)\n",
|
||||
adj.ID,
|
||||
adj.UsageQty,
|
||||
adj.PendingQty,
|
||||
adj.StockLogDecrease,
|
||||
)
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
activeAllocationCount, err := countActiveAllocations(ctx, db, fifo.UsableKeyAdjustmentOut.String(), adj.ID)
|
||||
if err != nil {
|
||||
fmt.Printf("FAIL adj=%d error=count allocations: %v\n", adj.ID, err)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
|
||||
compensateQty := adj.UsageQty
|
||||
if compensateQty <= 0 && desiredQtySource == "stock_log.decrease" {
|
||||
compensateQty = adj.StockLogDecrease
|
||||
}
|
||||
shouldCompensate := compensateMissingAlloc && activeAllocationCount == 0 && compensateQty > 0
|
||||
|
||||
reflowReq := commonSvc.FifoStockV2ReflowRequest{
|
||||
FlagGroupCode: route.FlagGroupCode,
|
||||
ProductWarehouseID: adj.ProductWarehouseID,
|
||||
IdempotencyKey: fmt.Sprintf("manual-adjustment-reflow-%d-%d", adj.ID, time.Now().UnixNano()),
|
||||
}
|
||||
if asOfCreatedAt {
|
||||
asOf := adj.CreatedAt
|
||||
reflowReq.AsOf = &asOf
|
||||
}
|
||||
|
||||
fmt.Printf(
|
||||
"PLAN adj=%d pw=%d product=%d function=%s group=%s desired=%.3f source=%s active_alloc=%d compensate=%t\n",
|
||||
adj.ID,
|
||||
adj.ProductWarehouseID,
|
||||
adj.ProductID,
|
||||
route.FunctionCode,
|
||||
route.FlagGroupCode,
|
||||
desiredQty,
|
||||
desiredQtySource,
|
||||
activeAllocationCount,
|
||||
shouldCompensate,
|
||||
)
|
||||
|
||||
if !apply {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if shouldCompensate {
|
||||
if err := tx.Table("product_warehouses").
|
||||
Where("id = ?", adj.ProductWarehouseID).
|
||||
Update("qty", gorm.Expr("COALESCE(qty,0) + ?", compensateQty)).Error; err != nil {
|
||||
return fmt.Errorf("compensate product_warehouse qty: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
reflowReq.Tx = tx
|
||||
res, err := fifoStockV2Svc.Reflow(ctx, reflowReq)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf(
|
||||
"DONE adj=%d rollback=%.3f allocate=%.3f pending=%.3f\n",
|
||||
adj.ID,
|
||||
res.Rollback.ReleasedQty,
|
||||
res.Allocate.AllocatedQty,
|
||||
res.Allocate.PendingQty,
|
||||
)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Printf("FAIL adj=%d error=%v\n", adj.ID, err)
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
|
||||
success++
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Printf("Summary: success=%d failed=%d skipped=%d\n", success, failed, skipped)
|
||||
if failed > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func modeLabel(apply bool) string {
|
||||
if apply {
|
||||
return "APPLY"
|
||||
}
|
||||
return "DRY-RUN"
|
||||
}
|
||||
|
||||
func parseIDs(raw string) ([]uint, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
parts := strings.Split(raw, ",")
|
||||
ids := make([]uint, 0, len(parts))
|
||||
seen := map[uint]struct{}{}
|
||||
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
value, err := strconv.ParseUint(part, 10, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid id %q", part)
|
||||
}
|
||||
if value == 0 {
|
||||
return nil, fmt.Errorf("id must be > 0: %q", part)
|
||||
}
|
||||
id := uint(value)
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func loadAdjustments(ctx context.Context, db *gorm.DB, ids []uint) ([]adjustmentRow, error) {
|
||||
var rows []adjustmentRow
|
||||
err := db.WithContext(ctx).
|
||||
Table("adjustment_stocks a").
|
||||
Select(`
|
||||
a.id,
|
||||
a.product_warehouse_id,
|
||||
pw.product_id,
|
||||
a.function_code,
|
||||
COALESCE(a.usage_qty, 0) AS usage_qty,
|
||||
COALESCE(a.pending_qty, 0) AS pending_qty,
|
||||
COALESCE((
|
||||
SELECT sl.increase
|
||||
FROM stock_logs sl
|
||||
WHERE sl.loggable_type = 'ADJUSTMENT'
|
||||
AND sl.loggable_id = a.id
|
||||
ORDER BY sl.id DESC
|
||||
LIMIT 1
|
||||
), 0) AS stock_log_increase,
|
||||
COALESCE((
|
||||
SELECT sl.decrease
|
||||
FROM stock_logs sl
|
||||
WHERE sl.loggable_type = 'ADJUSTMENT'
|
||||
AND sl.loggable_id = a.id
|
||||
ORDER BY sl.id DESC
|
||||
LIMIT 1
|
||||
), 0) AS stock_log_decrease,
|
||||
a.created_at
|
||||
`).
|
||||
Joins("JOIN product_warehouses pw ON pw.id = a.product_warehouse_id").
|
||||
Where("a.id IN ?", ids).
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func resolveRouteByFunctionCode(ctx context.Context, db *gorm.DB, productID uint, functionCode string) (*routeResolution, error) {
|
||||
var rows []routeResolution
|
||||
err := db.WithContext(ctx).
|
||||
Table("fifo_stock_v2_route_rules rr").
|
||||
Select("rr.flag_group_code, rr.lane, rr.function_code, rr.source_table, rr.legacy_type_key").
|
||||
Joins("JOIN fifo_stock_v2_flag_groups fg ON fg.code = rr.flag_group_code AND fg.is_active = TRUE").
|
||||
Where("rr.is_active = TRUE").
|
||||
Where("rr.function_code = ?", functionCode).
|
||||
Where(`
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM flags f
|
||||
JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.is_active = TRUE
|
||||
WHERE f.flagable_type = ?
|
||||
AND f.flagable_id = ?
|
||||
AND fm.flag_group_code = rr.flag_group_code
|
||||
)
|
||||
`, entity.FlagableTypeProduct, productID).
|
||||
Order("CASE WHEN rr.source_table = 'adjustment_stocks' THEN 0 ELSE 1 END ASC").
|
||||
Order("rr.id ASC").
|
||||
Find(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil, fmt.Errorf("no route found for product_id=%d function_code=%s", productID, functionCode)
|
||||
}
|
||||
|
||||
selected := rows[0]
|
||||
for _, row := range rows {
|
||||
if row.Lane != selected.Lane {
|
||||
return nil, fmt.Errorf("ambiguous lane for product_id=%d function_code=%s", productID, functionCode)
|
||||
}
|
||||
}
|
||||
|
||||
selected.FunctionCode = functionCode
|
||||
return &selected, nil
|
||||
}
|
||||
|
||||
func countActiveAllocations(ctx context.Context, db *gorm.DB, usableType string, usableID uint) (int64, error) {
|
||||
var count int64
|
||||
err := db.WithContext(ctx).
|
||||
Table("stock_allocations").
|
||||
Where("usable_type = ? AND usable_id = ?", usableType, usableID).
|
||||
Where("status = ?", entity.StockAllocationStatusActive).
|
||||
Where("allocation_purpose = ?", entity.StockAllocationPurposeConsume).
|
||||
Count(&count).Error
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
@@ -1,648 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/config"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/database"
|
||||
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type productWarehouseScopeRow struct {
|
||||
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
|
||||
ProductID uint `gorm:"column:product_id"`
|
||||
WarehouseID uint `gorm:"column:warehouse_id"`
|
||||
ProjectFlockKandangID *uint `gorm:"column:project_flock_kandang_id"`
|
||||
}
|
||||
|
||||
type reflowTarget struct {
|
||||
ProductWarehouseID uint
|
||||
ProductID uint
|
||||
WarehouseID uint
|
||||
ProjectFlockKandangID *uint
|
||||
FlagGroupCode string
|
||||
}
|
||||
|
||||
func main() {
|
||||
var (
|
||||
projectFlockKandangID uint
|
||||
apply bool
|
||||
asOfRaw string
|
||||
includeShared bool
|
||||
)
|
||||
|
||||
flag.UintVar(&projectFlockKandangID, "project-flock-kandang-id", 0, "Project flock kandang ID (required)")
|
||||
flag.BoolVar(&apply, "apply", false, "Apply reflow. If false, run as dry-run")
|
||||
flag.StringVar(&asOfRaw, "as-of", "", "Optional AsOf boundary. Format: RFC3339 or YYYY-MM-DD")
|
||||
flag.BoolVar(&includeShared, "include-shared", true, "Include product warehouses referenced by transactions in this PFK scope (including shared/non-bound product warehouses)")
|
||||
flag.Parse()
|
||||
|
||||
if projectFlockKandangID == 0 {
|
||||
log.Fatal("--project-flock-kandang-id is required")
|
||||
}
|
||||
|
||||
asOf, err := parseAsOf(asOfRaw)
|
||||
if err != nil {
|
||||
log.Fatalf("invalid --as-of: %v", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
db := database.Connect(config.DBHost, config.DBName)
|
||||
fifoStockV2Svc := commonSvc.NewFifoStockV2Service(db, nil)
|
||||
|
||||
exists, err := projectFlockKandangExists(ctx, db, projectFlockKandangID)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to check project flock kandang: %v", err)
|
||||
}
|
||||
if !exists {
|
||||
log.Fatalf("project_flock_kandang_id %d not found", projectFlockKandangID)
|
||||
}
|
||||
|
||||
scopedPWs, err := loadScopedProductWarehouses(ctx, db, projectFlockKandangID, includeShared)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to load scoped product warehouses: %v", err)
|
||||
}
|
||||
if len(scopedPWs) == 0 {
|
||||
fmt.Printf("Mode: %s\n", modeLabel(apply))
|
||||
fmt.Printf("Scope: project_flock_kandang_id=%d\n", projectFlockKandangID)
|
||||
fmt.Println("No product warehouse found in scope")
|
||||
return
|
||||
}
|
||||
|
||||
targets := make([]reflowTarget, 0, len(scopedPWs))
|
||||
skippedPW := 0
|
||||
failedResolve := 0
|
||||
|
||||
for _, pw := range scopedPWs {
|
||||
flagGroups, err := resolveFlagGroupsByProductWarehouse(ctx, db, pw.ProductWarehouseID)
|
||||
if err != nil {
|
||||
fmt.Printf("FAIL pw=%d error=resolve flag groups: %v\n", pw.ProductWarehouseID, err)
|
||||
failedResolve++
|
||||
continue
|
||||
}
|
||||
if len(flagGroups) == 0 {
|
||||
fmt.Printf("SKIP pw=%d reason=no active fifo v2 route by product flag\n", pw.ProductWarehouseID)
|
||||
skippedPW++
|
||||
continue
|
||||
}
|
||||
for _, group := range flagGroups {
|
||||
targets = append(targets, reflowTarget{
|
||||
ProductWarehouseID: pw.ProductWarehouseID,
|
||||
ProductID: pw.ProductID,
|
||||
WarehouseID: pw.WarehouseID,
|
||||
ProjectFlockKandangID: pw.ProjectFlockKandangID,
|
||||
FlagGroupCode: group,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(targets, func(i, j int) bool {
|
||||
if targets[i].ProductWarehouseID == targets[j].ProductWarehouseID {
|
||||
return targets[i].FlagGroupCode < targets[j].FlagGroupCode
|
||||
}
|
||||
return targets[i].ProductWarehouseID < targets[j].ProductWarehouseID
|
||||
})
|
||||
|
||||
fmt.Printf("Mode: %s\n", modeLabel(apply))
|
||||
fmt.Printf("Scope: project_flock_kandang_id=%d include_shared=%t\n", projectFlockKandangID, includeShared)
|
||||
if asOf != nil {
|
||||
fmt.Printf("AsOf: %s\n", asOf.UTC().Format(time.RFC3339))
|
||||
} else {
|
||||
fmt.Println("AsOf: <nil> (full timeline)")
|
||||
}
|
||||
fmt.Printf("Product warehouses in scope: %d\n", len(scopedPWs))
|
||||
fmt.Printf("Planned reflow targets: %d\n\n", len(targets))
|
||||
|
||||
for _, target := range targets {
|
||||
fmt.Printf(
|
||||
"PLAN pw=%d product=%d warehouse=%d pw_pfk=%s flag_group=%s\n",
|
||||
target.ProductWarehouseID,
|
||||
target.ProductID,
|
||||
target.WarehouseID,
|
||||
displayOptionalUint(target.ProjectFlockKandangID),
|
||||
target.FlagGroupCode,
|
||||
)
|
||||
}
|
||||
|
||||
if !apply {
|
||||
fmt.Println()
|
||||
fmt.Printf("Summary: planned=%d skipped_pw=%d failed_resolve=%d applied=0 failed_apply=0\n", len(targets), skippedPW, failedResolve)
|
||||
if failedResolve > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
successApply := 0
|
||||
failedApply := 0
|
||||
for idx, target := range targets {
|
||||
req := commonSvc.FifoStockV2ReflowRequest{
|
||||
FlagGroupCode: target.FlagGroupCode,
|
||||
ProductWarehouseID: target.ProductWarehouseID,
|
||||
AsOf: asOf,
|
||||
IdempotencyKey: fmt.Sprintf(
|
||||
"manual-pfk-reflow-%d-%d-%s-%d-%d",
|
||||
projectFlockKandangID,
|
||||
target.ProductWarehouseID,
|
||||
strings.ToUpper(strings.TrimSpace(target.FlagGroupCode)),
|
||||
time.Now().UnixNano(),
|
||||
idx,
|
||||
),
|
||||
}
|
||||
|
||||
res, err := fifoStockV2Svc.Reflow(ctx, req)
|
||||
if err != nil {
|
||||
fmt.Printf("FAIL pw=%d flag_group=%s error=%v\n", target.ProductWarehouseID, target.FlagGroupCode, err)
|
||||
failedApply++
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf(
|
||||
"DONE pw=%d flag_group=%s rollback=%.3f allocate=%.3f pending=%.3f processed_usable=%d\n",
|
||||
target.ProductWarehouseID,
|
||||
target.FlagGroupCode,
|
||||
res.Rollback.ReleasedQty,
|
||||
res.Allocate.AllocatedQty,
|
||||
res.Allocate.PendingQty,
|
||||
res.ProcessedUsables,
|
||||
)
|
||||
successApply++
|
||||
}
|
||||
|
||||
orphanPopulationRows := int64(0)
|
||||
syncedPopulationQtyRows := int64(0)
|
||||
syncedPopulationUsedRows := int64(0)
|
||||
traceReleasedRows := int64(0)
|
||||
traceInsertedRows := int64(0)
|
||||
if rowsOrphan, rowsQty, rowsUsed, err := resyncProjectFlockPopulation(ctx, db, projectFlockKandangID); err != nil {
|
||||
fmt.Printf("FAIL population_resync project_flock_kandang_id=%d error=%v\n", projectFlockKandangID, err)
|
||||
failedApply++
|
||||
} else {
|
||||
orphanPopulationRows = rowsOrphan
|
||||
syncedPopulationQtyRows = rowsQty
|
||||
syncedPopulationUsedRows = rowsUsed
|
||||
fmt.Printf(
|
||||
"SYNC project_flock_populations orphan_marked=%d qty_synced=%d used_synced=%d\n",
|
||||
orphanPopulationRows,
|
||||
syncedPopulationQtyRows,
|
||||
syncedPopulationUsedRows,
|
||||
)
|
||||
}
|
||||
|
||||
if released, inserted, err := resyncChickinTraceByProjectFlockKandang(ctx, db, fifoStockV2Svc, projectFlockKandangID); err != nil {
|
||||
fmt.Printf("FAIL chickin_trace_resync project_flock_kandang_id=%d error=%v\n", projectFlockKandangID, err)
|
||||
failedApply++
|
||||
} else {
|
||||
traceReleasedRows = released
|
||||
traceInsertedRows = inserted
|
||||
fmt.Printf(
|
||||
"SYNC chickin_trace released=%d inserted=%d\n",
|
||||
traceReleasedRows,
|
||||
traceInsertedRows,
|
||||
)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Printf(
|
||||
"Summary: planned=%d skipped_pw=%d failed_resolve=%d applied=%d failed_apply=%d population_orphan=%d population_qty_synced=%d population_used_synced=%d trace_released=%d trace_inserted=%d\n",
|
||||
len(targets),
|
||||
skippedPW,
|
||||
failedResolve,
|
||||
successApply,
|
||||
failedApply,
|
||||
orphanPopulationRows,
|
||||
syncedPopulationQtyRows,
|
||||
syncedPopulationUsedRows,
|
||||
traceReleasedRows,
|
||||
traceInsertedRows,
|
||||
)
|
||||
if failedResolve > 0 || failedApply > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func parseAsOf(raw string) (*time.Time, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
layouts := []string{
|
||||
time.RFC3339Nano,
|
||||
time.RFC3339,
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02",
|
||||
}
|
||||
|
||||
for _, layout := range layouts {
|
||||
parsed, err := time.Parse(layout, raw)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if layout == "2006-01-02" {
|
||||
endOfDay := time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 23, 59, 59, int(time.Second-time.Nanosecond), time.UTC)
|
||||
return &endOfDay, nil
|
||||
}
|
||||
asOf := parsed.UTC()
|
||||
return &asOf, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("unsupported format %q", raw)
|
||||
}
|
||||
|
||||
func modeLabel(apply bool) string {
|
||||
if apply {
|
||||
return "APPLY"
|
||||
}
|
||||
return "DRY-RUN"
|
||||
}
|
||||
|
||||
func displayOptionalUint(v *uint) string {
|
||||
if v == nil {
|
||||
return "NULL"
|
||||
}
|
||||
return fmt.Sprintf("%d", *v)
|
||||
}
|
||||
|
||||
func projectFlockKandangExists(ctx context.Context, db *gorm.DB, projectFlockKandangID uint) (bool, error) {
|
||||
var count int64
|
||||
err := db.WithContext(ctx).
|
||||
Table("project_flock_kandangs").
|
||||
Where("id = ?", projectFlockKandangID).
|
||||
Count(&count).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func loadScopedProductWarehouses(ctx context.Context, db *gorm.DB, projectFlockKandangID uint, includeShared bool) ([]productWarehouseScopeRow, error) {
|
||||
if !includeShared {
|
||||
var rows []productWarehouseScopeRow
|
||||
err := db.WithContext(ctx).
|
||||
Table("product_warehouses").
|
||||
Select("id AS product_warehouse_id, product_id, warehouse_id, project_flock_kandang_id").
|
||||
Where("project_flock_kandang_id = ?", projectFlockKandangID).
|
||||
Order("id ASC").
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
query := `
|
||||
WITH scoped_pw AS (
|
||||
SELECT pw.id AS product_warehouse_id
|
||||
FROM product_warehouses pw
|
||||
WHERE pw.project_flock_kandang_id = ?
|
||||
|
||||
UNION
|
||||
SELECT pc.product_warehouse_id
|
||||
FROM project_chickins pc
|
||||
WHERE pc.project_flock_kandang_id = ?
|
||||
AND pc.deleted_at IS NULL
|
||||
|
||||
UNION
|
||||
SELECT rs.product_warehouse_id
|
||||
FROM recordings r
|
||||
JOIN recording_stocks rs ON rs.recording_id = r.id
|
||||
WHERE r.project_flock_kandangs_id = ?
|
||||
AND r.deleted_at IS NULL
|
||||
|
||||
UNION
|
||||
SELECT rd.product_warehouse_id
|
||||
FROM recordings r
|
||||
JOIN recording_depletions rd ON rd.recording_id = r.id
|
||||
WHERE r.project_flock_kandangs_id = ?
|
||||
AND r.deleted_at IS NULL
|
||||
|
||||
UNION
|
||||
SELECT rd.source_product_warehouse_id
|
||||
FROM recordings r
|
||||
JOIN recording_depletions rd ON rd.recording_id = r.id
|
||||
WHERE r.project_flock_kandangs_id = ?
|
||||
AND r.deleted_at IS NULL
|
||||
AND rd.source_product_warehouse_id IS NOT NULL
|
||||
|
||||
UNION
|
||||
SELECT re.product_warehouse_id
|
||||
FROM recordings r
|
||||
JOIN recording_eggs re ON re.recording_id = r.id
|
||||
WHERE r.project_flock_kandangs_id = ?
|
||||
AND r.deleted_at IS NULL
|
||||
|
||||
UNION
|
||||
SELECT lts.product_warehouse_id
|
||||
FROM laying_transfer_sources lts
|
||||
WHERE lts.source_project_flock_kandang_id = ?
|
||||
AND lts.deleted_at IS NULL
|
||||
AND lts.product_warehouse_id IS NOT NULL
|
||||
|
||||
UNION
|
||||
SELECT ltt.product_warehouse_id
|
||||
FROM laying_transfer_targets ltt
|
||||
WHERE ltt.target_project_flock_kandang_id = ?
|
||||
AND ltt.deleted_at IS NULL
|
||||
AND ltt.product_warehouse_id IS NOT NULL
|
||||
|
||||
UNION
|
||||
SELECT pi.product_warehouse_id
|
||||
FROM purchase_items pi
|
||||
WHERE pi.project_flock_kandang_id = ?
|
||||
AND pi.product_warehouse_id IS NOT NULL
|
||||
)
|
||||
SELECT DISTINCT
|
||||
pw.id AS product_warehouse_id,
|
||||
pw.product_id,
|
||||
pw.warehouse_id,
|
||||
pw.project_flock_kandang_id
|
||||
FROM scoped_pw s
|
||||
JOIN product_warehouses pw ON pw.id = s.product_warehouse_id
|
||||
ORDER BY pw.id ASC
|
||||
`
|
||||
|
||||
var rows []productWarehouseScopeRow
|
||||
err := db.WithContext(ctx).
|
||||
Raw(
|
||||
query,
|
||||
projectFlockKandangID,
|
||||
projectFlockKandangID,
|
||||
projectFlockKandangID,
|
||||
projectFlockKandangID,
|
||||
projectFlockKandangID,
|
||||
projectFlockKandangID,
|
||||
projectFlockKandangID,
|
||||
projectFlockKandangID,
|
||||
projectFlockKandangID,
|
||||
).
|
||||
Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func resolveFlagGroupsByProductWarehouse(ctx context.Context, db *gorm.DB, productWarehouseID uint) ([]string, error) {
|
||||
var groups []string
|
||||
err := db.WithContext(ctx).
|
||||
Table("fifo_stock_v2_route_rules rr").
|
||||
Select("DISTINCT rr.flag_group_code").
|
||||
Joins("JOIN fifo_stock_v2_flag_groups fg ON fg.code = rr.flag_group_code AND fg.is_active = TRUE").
|
||||
Where("rr.is_active = TRUE").
|
||||
Where(`
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM product_warehouses pw
|
||||
JOIN flags f ON f.flagable_id = pw.product_id
|
||||
JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.is_active = TRUE
|
||||
WHERE pw.id = ?
|
||||
AND f.flagable_type = ?
|
||||
AND fm.flag_group_code = rr.flag_group_code
|
||||
)
|
||||
`, productWarehouseID, entity.FlagableTypeProduct).
|
||||
Order("rr.flag_group_code ASC").
|
||||
Scan(&groups).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
func resyncProjectFlockPopulation(ctx context.Context, db *gorm.DB, projectFlockKandangID uint) (int64, int64, int64, error) {
|
||||
if projectFlockKandangID == 0 {
|
||||
return 0, 0, 0, nil
|
||||
}
|
||||
|
||||
orphanResult := db.WithContext(ctx).Exec(`
|
||||
UPDATE project_flock_populations pfp
|
||||
SET deleted_at = NOW(),
|
||||
updated_at = NOW()
|
||||
FROM project_chickins pc
|
||||
WHERE pfp.project_chickin_id = pc.id
|
||||
AND pc.project_flock_kandang_id = ?
|
||||
AND pc.deleted_at IS NOT NULL
|
||||
AND pfp.deleted_at IS NULL
|
||||
`, projectFlockKandangID)
|
||||
if orphanResult.Error != nil {
|
||||
return 0, 0, 0, orphanResult.Error
|
||||
}
|
||||
|
||||
qtyResult := db.WithContext(ctx).Exec(`
|
||||
UPDATE project_flock_populations p
|
||||
SET total_qty = GREATEST(COALESCE(pc.usage_qty, 0), 0),
|
||||
updated_at = NOW()
|
||||
FROM project_chickins pc
|
||||
WHERE p.project_chickin_id = pc.id
|
||||
AND pc.project_flock_kandang_id = ?
|
||||
AND pc.deleted_at IS NULL
|
||||
AND p.deleted_at IS NULL
|
||||
`, projectFlockKandangID)
|
||||
if qtyResult.Error != nil {
|
||||
return 0, 0, 0, qtyResult.Error
|
||||
}
|
||||
|
||||
usedResult := db.WithContext(ctx).Exec(`
|
||||
WITH scoped AS (
|
||||
SELECT pfp.id, pfp.total_qty
|
||||
FROM project_flock_populations pfp
|
||||
JOIN project_chickins pc ON pc.id = pfp.project_chickin_id
|
||||
WHERE pc.project_flock_kandang_id = ?
|
||||
AND pc.deleted_at IS NULL
|
||||
AND pfp.deleted_at IS NULL
|
||||
),
|
||||
alloc AS (
|
||||
SELECT sa.stockable_id, SUM(sa.qty) AS used_qty
|
||||
FROM stock_allocations sa
|
||||
WHERE sa.stockable_type = 'PROJECT_FLOCK_POPULATION'
|
||||
AND sa.status = 'ACTIVE'
|
||||
AND sa.allocation_purpose = 'CONSUME'
|
||||
GROUP BY sa.stockable_id
|
||||
)
|
||||
UPDATE project_flock_populations p
|
||||
SET total_used_qty = LEAST(COALESCE(a.used_qty, 0), GREATEST(s.total_qty, 0)),
|
||||
updated_at = NOW()
|
||||
FROM scoped s
|
||||
LEFT JOIN alloc a ON a.stockable_id = s.id
|
||||
WHERE p.id = s.id
|
||||
`, projectFlockKandangID)
|
||||
if usedResult.Error != nil {
|
||||
return 0, 0, 0, usedResult.Error
|
||||
}
|
||||
|
||||
return orphanResult.RowsAffected, qtyResult.RowsAffected, usedResult.RowsAffected, nil
|
||||
}
|
||||
func resyncChickinTraceByProjectFlockKandang(
|
||||
ctx context.Context,
|
||||
db *gorm.DB,
|
||||
fifoStockV2Svc commonSvc.FifoStockV2Service,
|
||||
projectFlockKandangID uint,
|
||||
) (int64, int64, error) {
|
||||
if projectFlockKandangID == 0 {
|
||||
return 0, 0, nil
|
||||
}
|
||||
|
||||
var productWarehouseIDs []uint
|
||||
if err := db.WithContext(ctx).
|
||||
Table("project_chickins").
|
||||
Distinct("product_warehouse_id").
|
||||
Where("project_flock_kandang_id = ?", projectFlockKandangID).
|
||||
Where("deleted_at IS NULL").
|
||||
Order("product_warehouse_id ASC").
|
||||
Pluck("product_warehouse_id", &productWarehouseIDs).Error; err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if len(productWarehouseIDs) == 0 {
|
||||
return 0, 0, nil
|
||||
}
|
||||
|
||||
totalReleased := int64(0)
|
||||
totalInserted := int64(0)
|
||||
|
||||
for _, productWarehouseID := range productWarehouseIDs {
|
||||
var releasedRows int64
|
||||
var insertedRows int64
|
||||
|
||||
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
flagGroups, err := resolveFlagGroupsByProductWarehouse(ctx, tx, productWarehouseID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(flagGroups) == 0 {
|
||||
return nil
|
||||
}
|
||||
flagGroupCode := strings.TrimSpace(flagGroups[0])
|
||||
if flagGroupCode == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
released := tx.WithContext(ctx).
|
||||
Table("stock_allocations").
|
||||
Where("product_warehouse_id = ?", productWarehouseID).
|
||||
Where("usable_type = ?", fifo.UsableKeyProjectChickin.String()).
|
||||
Where("allocation_purpose = ?", entity.StockAllocationPurposeTraceChickin).
|
||||
Where("status = ?", entity.StockAllocationStatusActive).
|
||||
Updates(map[string]any{
|
||||
"status": entity.StockAllocationStatusReleased,
|
||||
"released_at": time.Now(),
|
||||
"updated_at": time.Now(),
|
||||
"note": "chickin_trace_reflow_reset",
|
||||
})
|
||||
if released.Error != nil {
|
||||
return released.Error
|
||||
}
|
||||
releasedRows = released.RowsAffected
|
||||
|
||||
type chickinRow struct {
|
||||
ID uint `gorm:"column:id"`
|
||||
UsageQty float64 `gorm:"column:usage_qty"`
|
||||
ChickIn time.Time `gorm:"column:chick_in_date"`
|
||||
}
|
||||
chickins := make([]chickinRow, 0)
|
||||
if err := tx.WithContext(ctx).
|
||||
Table("project_chickins").
|
||||
Select("id, usage_qty, chick_in_date").
|
||||
Where("product_warehouse_id = ?", productWarehouseID).
|
||||
Where("deleted_at IS NULL").
|
||||
Where("usage_qty > 0").
|
||||
Order("chick_in_date ASC, id ASC").
|
||||
Scan(&chickins).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(chickins) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
gatherRows, err := fifoStockV2Svc.Gather(ctx, commonSvc.FifoStockV2GatherRequest{
|
||||
FlagGroupCode: flagGroupCode,
|
||||
Lane: "STOCKABLE",
|
||||
AllocationPurpose: entity.StockAllocationPurposeTraceChickin,
|
||||
IgnoreSourceUsed: true,
|
||||
ProductWarehouseID: productWarehouseID,
|
||||
Limit: 50000,
|
||||
Tx: tx,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(gatherRows) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
type lotKey struct {
|
||||
StockableType string
|
||||
StockableID uint
|
||||
}
|
||||
remainingByLot := make(map[lotKey]float64, len(gatherRows))
|
||||
for _, row := range gatherRows {
|
||||
key := lotKey{StockableType: row.Ref.LegacyTypeKey, StockableID: row.Ref.ID}
|
||||
remainingByLot[key] = row.AvailableQuantity
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
lotIndex := 0
|
||||
for _, chickinRow := range chickins {
|
||||
remaining := chickinRow.UsageQty
|
||||
for remaining > 1e-6 && lotIndex < len(gatherRows) {
|
||||
lot := gatherRows[lotIndex]
|
||||
key := lotKey{StockableType: lot.Ref.LegacyTypeKey, StockableID: lot.Ref.ID}
|
||||
available := remainingByLot[key]
|
||||
if available <= 1e-6 {
|
||||
lotIndex++
|
||||
continue
|
||||
}
|
||||
|
||||
portion := math.Min(remaining, available)
|
||||
if portion <= 1e-6 {
|
||||
lotIndex++
|
||||
continue
|
||||
}
|
||||
|
||||
insert := map[string]any{
|
||||
"product_warehouse_id": productWarehouseID,
|
||||
"stockable_type": lot.Ref.LegacyTypeKey,
|
||||
"stockable_id": lot.Ref.ID,
|
||||
"usable_type": fifo.UsableKeyProjectChickin.String(),
|
||||
"usable_id": chickinRow.ID,
|
||||
"qty": portion,
|
||||
"status": entity.StockAllocationStatusActive,
|
||||
"allocation_purpose": entity.StockAllocationPurposeTraceChickin,
|
||||
"engine_version": "v2",
|
||||
"flag_group_code": flagGroupCode,
|
||||
"function_code": "CHICKIN_TRACE",
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
if err := tx.WithContext(ctx).Table("stock_allocations").Create(insert).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
insertedRows++
|
||||
remaining -= portion
|
||||
remainingByLot[key] = available - portion
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return totalReleased, totalInserted, err
|
||||
}
|
||||
|
||||
totalReleased += releasedRows
|
||||
totalInserted += insertedRows
|
||||
}
|
||||
|
||||
return totalReleased, totalInserted, nil
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"os"
|
||||
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/config"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/database"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type mismatchRow struct {
|
||||
ChickinID uint `gorm:"column:chickin_id"`
|
||||
ProjectFlockKandang uint `gorm:"column:project_flock_kandang_id"`
|
||||
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
|
||||
UsageQty float64 `gorm:"column:usage_qty"`
|
||||
TraceQty float64 `gorm:"column:trace_qty"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
var projectFlockKandangID uint
|
||||
flag.UintVar(&projectFlockKandangID, "project-flock-kandang-id", 0, "Optional project flock kandang scope")
|
||||
flag.Parse()
|
||||
|
||||
ctx := context.Background()
|
||||
db := database.Connect(config.DBHost, config.DBName)
|
||||
|
||||
rows, err := loadTraceMismatches(ctx, db, projectFlockKandangID)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to load trace mismatches: %v", err)
|
||||
}
|
||||
|
||||
activeConsumeRows, err := countActiveConsumeProjectChickin(ctx, db, projectFlockKandangID)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to count active consume rows: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Scope project_flock_kandang_id=%d\n", projectFlockKandangID)
|
||||
fmt.Printf("Mismatched chickin trace rows: %d\n", len(rows))
|
||||
fmt.Printf("Active PROJECT_CHICKIN consume rows: %d\n", activeConsumeRows)
|
||||
|
||||
if len(rows) > 0 {
|
||||
for _, row := range rows {
|
||||
fmt.Printf(
|
||||
"MISMATCH chickin_id=%d pfk=%d pw=%d usage=%.3f trace=%.3f diff=%.3f\n",
|
||||
row.ChickinID,
|
||||
row.ProjectFlockKandang,
|
||||
row.ProductWarehouseID,
|
||||
row.UsageQty,
|
||||
row.TraceQty,
|
||||
row.TraceQty-row.UsageQty,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if len(rows) > 0 || activeConsumeRows > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func loadTraceMismatches(ctx context.Context, db *gorm.DB, projectFlockKandangID uint) ([]mismatchRow, error) {
|
||||
query := db.WithContext(ctx).
|
||||
Table("project_chickins pc").
|
||||
Select(`
|
||||
pc.id AS chickin_id,
|
||||
pc.project_flock_kandang_id,
|
||||
pc.product_warehouse_id,
|
||||
COALESCE(pc.usage_qty, 0) AS usage_qty,
|
||||
COALESCE(SUM(sa.qty), 0) AS trace_qty
|
||||
`).
|
||||
Joins(`
|
||||
LEFT JOIN stock_allocations sa
|
||||
ON sa.usable_type = ?
|
||||
AND sa.usable_id = pc.id
|
||||
AND sa.status = 'ACTIVE'
|
||||
AND sa.allocation_purpose = 'TRACE_CHICKIN'
|
||||
`, fifo.UsableKeyProjectChickin.String()).
|
||||
Where("pc.deleted_at IS NULL").
|
||||
Where("COALESCE(pc.usage_qty,0) > 0").
|
||||
Group("pc.id, pc.project_flock_kandang_id, pc.product_warehouse_id, pc.usage_qty")
|
||||
|
||||
if projectFlockKandangID > 0 {
|
||||
query = query.Where("pc.project_flock_kandang_id = ?", projectFlockKandangID)
|
||||
}
|
||||
|
||||
rows := make([]mismatchRow, 0)
|
||||
if err := query.Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]mismatchRow, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
if math.Abs(row.TraceQty-row.UsageQty) > 1e-3 {
|
||||
out = append(out, row)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func countActiveConsumeProjectChickin(ctx context.Context, db *gorm.DB, projectFlockKandangID uint) (int64, error) {
|
||||
q := db.WithContext(ctx).
|
||||
Table("stock_allocations sa").
|
||||
Joins("JOIN project_chickins pc ON pc.id = sa.usable_id").
|
||||
Where("sa.usable_type = ?", fifo.UsableKeyProjectChickin.String()).
|
||||
Where("sa.status = 'ACTIVE'").
|
||||
Where("sa.allocation_purpose = 'CONSUME'")
|
||||
|
||||
if projectFlockKandangID > 0 {
|
||||
q = q.Where("pc.project_flock_kandang_id = ?", projectFlockKandangID)
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := q.Count(&count).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
@@ -1,313 +0,0 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type HppCostRepository interface {
|
||||
GetProjectFlockKandangIDs(ctx context.Context, projectFlockId uint) ([]uint, error)
|
||||
GetDocCost(ctx context.Context, projectFlockKandangIDs []uint) (float64, error)
|
||||
GetBudgetCostByProjectFlockId(ctx context.Context, projectFlockId uint) (float64, error)
|
||||
GetExpedisionCost(ctx context.Context, projectFlockKandangIDs []uint) (float64, error)
|
||||
GetFeedUsageCost(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, error)
|
||||
GetOvkUsageCost(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, error)
|
||||
GetTotalPopulation(ctx context.Context, projectFlockKandangIDs []uint) (float64, error)
|
||||
GetPulletCost(ctx context.Context, projectFlockKandangId uint) (float64, error)
|
||||
GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, float64, error)
|
||||
GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, startDate *time.Time, endDate *time.Time) (float64, float64, error)
|
||||
GetProjectFlockIDByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (uint, error)
|
||||
GetTransferSourceSummary(ctx context.Context, projectFlockKandangId uint) (uint, float64, error)
|
||||
}
|
||||
|
||||
type HppRepositoryImpl struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewHppCostRepository(db *gorm.DB) HppCostRepository {
|
||||
return &HppRepositoryImpl{db: db}
|
||||
}
|
||||
|
||||
func (r *HppRepositoryImpl) GetProjectFlockKandangIDs(ctx context.Context, projectFlockId uint) ([]uint, error) {
|
||||
var ids []uint
|
||||
err := r.db.WithContext(ctx).
|
||||
Table("project_flock_kandangs").
|
||||
Select("id").
|
||||
Where("project_flock_id = ?", projectFlockId).
|
||||
Scan(&ids).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (r *HppRepositoryImpl) GetDocCost(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) {
|
||||
var total float64
|
||||
err := r.db.WithContext(ctx).
|
||||
Table("project_chickins AS pc").
|
||||
Select("COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0)").
|
||||
Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?", fifo.UsableKeyProjectChickin.String(), fifo.StockableKeyPurchaseItems.String(), entity.StockAllocationStatusActive, entity.StockAllocationPurposeTraceChickin).
|
||||
Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id").
|
||||
Where("pc.project_flock_kandang_id IN (?)", projectFlockKandangIDs).
|
||||
Scan(&total).Error
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (r *HppRepositoryImpl) GetBudgetCostByProjectFlockId(ctx context.Context, projectFlockId uint) (float64, error) {
|
||||
var total float64
|
||||
err := r.db.WithContext(ctx).
|
||||
Table("project_budgets AS pb").
|
||||
Select("COALESCE(SUM(pb.qty * pb.price), 0)").
|
||||
Where("pb.project_flock_id = ?", projectFlockId).
|
||||
Scan(&total).Error
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (r *HppRepositoryImpl) GetExpedisionCost(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) {
|
||||
var total float64
|
||||
err := r.db.WithContext(ctx).
|
||||
Table("expense_nonstocks AS en").
|
||||
Select("COALESCE(SUM(er.qty * er.price), 0)").
|
||||
Joins("JOIN expense_realizations AS er ON er.expense_nonstock_id = en.id").
|
||||
Joins("JOIN flags AS f ON f.flagable_id = en.nonstock_id AND f.flagable_type = ?", entity.FlagableTypeNonstock).
|
||||
Where("en.project_flock_kandang_id IN (?)", projectFlockKandangIDs).
|
||||
Where("f.name = ?", utils.FlagEkspedisi).
|
||||
Scan(&total).Error
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (r *HppRepositoryImpl) GetFeedUsageCost(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, error) {
|
||||
if date == nil {
|
||||
now := time.Now()
|
||||
date = &now
|
||||
}
|
||||
|
||||
var total float64
|
||||
err := r.db.WithContext(ctx).
|
||||
Table("recordings AS r").
|
||||
Select("COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0)").
|
||||
Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id").
|
||||
Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id").
|
||||
Joins("JOIN flags AS f ON f.flagable_id = pw.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct).
|
||||
Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String(), entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume).
|
||||
Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id").
|
||||
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
|
||||
Where("r.record_datetime <= ?", *date).
|
||||
Where("f.name = ?", utils.FlagPakan).
|
||||
Scan(&total).Error
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (r *HppRepositoryImpl) GetOvkUsageCost(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, error) {
|
||||
if date == nil {
|
||||
now := time.Now()
|
||||
date = &now
|
||||
}
|
||||
|
||||
flags := []utils.FlagType{
|
||||
utils.FlagOVK,
|
||||
utils.FlagObat,
|
||||
utils.FlagVitamin,
|
||||
utils.FlagKimia,
|
||||
}
|
||||
|
||||
var total float64
|
||||
err := r.db.WithContext(ctx).
|
||||
Table("recordings AS r").
|
||||
Select("COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0)").
|
||||
Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id").
|
||||
Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id").
|
||||
Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String(), entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume).
|
||||
Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id").
|
||||
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
|
||||
Where("r.record_datetime <= ?", *date).
|
||||
Where("EXISTS (SELECT 1 FROM flags f WHERE f.flagable_id = pw.product_id AND f.flagable_type = ? AND f.name IN ?)", entity.FlagableTypeProduct, flags).
|
||||
Scan(&total).Error
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (r *HppRepositoryImpl) GetTotalPopulation(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) {
|
||||
var total float64
|
||||
err := r.db.WithContext(ctx).
|
||||
Table("project_chickins AS pc").
|
||||
Select("COALESCE(SUM(pc.usage_qty), 0)").
|
||||
Where("pc.project_flock_kandang_id IN (?)", projectFlockKandangIDs).
|
||||
Scan(&total).Error
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (r *HppRepositoryImpl) GetPulletCost(ctx context.Context, projectFlockKandangId uint) (float64, error) {
|
||||
stockablePurchase := fifo.StockableKeyPurchaseItems.String()
|
||||
stockableTransferIn := fifo.StockableKeyStockTransferIn.String()
|
||||
usableProjectChickin := fifo.UsableKeyProjectChickin.String()
|
||||
|
||||
var total float64
|
||||
err := r.db.WithContext(ctx).
|
||||
Table("project_chickins AS pc").
|
||||
Select(`
|
||||
COALESCE(SUM(sa.qty * CASE
|
||||
WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0)
|
||||
WHEN sa.stockable_type = ? THEN COALESCE(tpi.price, 0)
|
||||
ELSE 0
|
||||
END), 0)`,
|
||||
stockablePurchase, stockableTransferIn).
|
||||
Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id AND sa.status = ? AND sa.allocation_purpose = ?", usableProjectChickin, entity.StockAllocationStatusActive, entity.StockAllocationPurposeTraceChickin).
|
||||
Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase).
|
||||
Joins("LEFT JOIN stock_allocations AS tsa ON tsa.usable_type = ? AND tsa.usable_id = sa.stockable_id AND sa.stockable_type = ? AND tsa.stockable_type = ? AND tsa.status = ? AND tsa.allocation_purpose = ?", stockableTransferIn, stockableTransferIn, stockablePurchase, entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume).
|
||||
Joins("LEFT JOIN purchase_items AS tpi ON tpi.id = tsa.stockable_id").
|
||||
Where("pc.project_flock_kandang_id = ?", projectFlockKandangId).
|
||||
Scan(&total).Error
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (r *HppRepositoryImpl) GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, float64, error) {
|
||||
if date == nil {
|
||||
now := time.Now()
|
||||
date = &now
|
||||
}
|
||||
|
||||
var totals struct {
|
||||
TotalPieces float64
|
||||
TotalWeightKg float64
|
||||
}
|
||||
err := r.db.WithContext(ctx).
|
||||
Table("recordings AS r").
|
||||
Select("COALESCE(SUM(re.qty), 0) AS total_pieces, COALESCE(SUM(re.weight), 0)AS total_weight_kg").
|
||||
Joins("JOIN recording_eggs AS re ON re.recording_id = r.id").
|
||||
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
|
||||
Where("r.record_datetime <= ?", *date).
|
||||
Scan(&totals).Error
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
return totals.TotalPieces, totals.TotalWeightKg, nil
|
||||
}
|
||||
|
||||
func (r *HppRepositoryImpl) GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(
|
||||
ctx context.Context,
|
||||
projectFlockKandangIDs []uint,
|
||||
startDate *time.Time,
|
||||
endDate *time.Time,
|
||||
) (float64, float64, error) {
|
||||
|
||||
if endDate == nil {
|
||||
now := time.Now()
|
||||
endDate = &now
|
||||
}
|
||||
|
||||
type subResult struct {
|
||||
UsableID uint
|
||||
MdpUsageQty float64
|
||||
MdpWeight float64
|
||||
}
|
||||
|
||||
subQuery := r.db.WithContext(ctx).
|
||||
Table("recordings AS r").
|
||||
Select(`
|
||||
DISTINCT sa.usable_id,
|
||||
mdp.usage_qty AS mdp_usage_qty,
|
||||
mdp.total_weight AS mdp_weight
|
||||
`).
|
||||
Joins("JOIN recording_eggs re ON re.recording_id = r.id").
|
||||
Joins(
|
||||
"JOIN stock_allocations sa ON sa.stockable_type = ? AND sa.stockable_id = re.id AND sa.usable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?",
|
||||
fifo.StockableKeyRecordingEgg.String(),
|
||||
fifo.UsableKeyMarketingDelivery.String(),
|
||||
entity.StockAllocationStatusActive,
|
||||
entity.StockAllocationPurposeConsume,
|
||||
).
|
||||
Joins("JOIN marketing_delivery_products mdp ON mdp.id = sa.usable_id").
|
||||
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
|
||||
Where("r.record_datetime <= ?", *endDate).
|
||||
Where("mdp.delivery_date <= ?", *startDate)
|
||||
|
||||
var totals struct {
|
||||
TotalPieces float64
|
||||
TotalWeight float64
|
||||
}
|
||||
|
||||
err := r.db.WithContext(ctx).
|
||||
Table("(?) AS x", subQuery).
|
||||
Select(`
|
||||
COALESCE(SUM(x.mdp_usage_qty), 0) AS total_pieces,
|
||||
COALESCE(SUM(x.mdp_weight), 0) AS total_weight
|
||||
`).
|
||||
Scan(&totals).Error
|
||||
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
return totals.TotalPieces, totals.TotalWeight, nil
|
||||
}
|
||||
|
||||
func (r *HppRepositoryImpl) GetProjectFlockIDByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (uint, error) {
|
||||
var projectFlockID uint
|
||||
err := r.db.WithContext(ctx).
|
||||
Table("project_flock_kandangs").
|
||||
Select("project_flock_id").
|
||||
Where("id = ?", projectFlockKandangId).
|
||||
Scan(&projectFlockID).Error
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return projectFlockID, nil
|
||||
}
|
||||
|
||||
func (r *HppRepositoryImpl) GetTransferSourceSummary(ctx context.Context, projectFlockKandangId uint) (uint, float64, error) {
|
||||
var summary struct {
|
||||
ProjectFlockID uint
|
||||
TotalQty float64
|
||||
}
|
||||
err := r.db.WithContext(ctx).
|
||||
Table("laying_transfer_targets AS ltt").
|
||||
Select("lt.from_project_flock_id AS project_flock_id, COALESCE(SUM(ltt.total_qty), 0) AS total_qty").
|
||||
Joins("JOIN laying_transfers AS lt ON lt.id = ltt.laying_transfer_id").
|
||||
Where("lt.deleted_at IS NULL").
|
||||
Where("ltt.deleted_at IS NULL").
|
||||
Where("lt.executed_at IS NOT NULL").
|
||||
Where("ltt.target_project_flock_kandang_id = ?", projectFlockKandangId).
|
||||
Group("lt.from_project_flock_id").
|
||||
Scan(&summary).Error
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
return summary.ProjectFlockID, summary.TotalQty, nil
|
||||
}
|
||||
@@ -33,7 +33,7 @@ func (r *StockAllocationRepositoryImpl) FindActiveByUsable(
|
||||
var allocations []entity.StockAllocation
|
||||
|
||||
q := r.DB().WithContext(ctx).
|
||||
Where("usable_type = ? AND usable_id = ? AND status = ? AND allocation_purpose = ?", usableType, usableID, entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume)
|
||||
Where("usable_type = ? AND usable_id = ? AND status = ?", usableType, usableID, entity.StockAllocationStatusActive)
|
||||
|
||||
if modifier != nil {
|
||||
q = modifier(q)
|
||||
@@ -70,7 +70,7 @@ func (r *StockAllocationRepositoryImpl) ReleaseByUsable(
|
||||
|
||||
q := baseDB.WithContext(ctx).
|
||||
Model(&entity.StockAllocation{}).
|
||||
Where("usable_type = ? AND usable_id = ? AND status = ? AND allocation_purpose = ?", usableType, usableID, entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume)
|
||||
Where("usable_type = ? AND usable_id = ? AND status = ?", usableType, usableID, entity.StockAllocationStatusActive)
|
||||
|
||||
return q.Updates(updates).Error
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ type ApprovalService interface {
|
||||
WorkflowSteps(workflow approvalutils.ApprovalWorkflowKey) map[approvalutils.ApprovalStep]string
|
||||
WorkflowStepName(workflow approvalutils.ApprovalWorkflowKey, step approvalutils.ApprovalStep) (string, bool)
|
||||
CreateApproval(ctx context.Context, workflow approvalutils.ApprovalWorkflowKey, approvableID uint, step approvalutils.ApprovalStep, action *entity.ApprovalAction, actorID uint, note *string) (*entity.Approval, error)
|
||||
List(ctx context.Context, module string, approvableID *uint, page, limit int, search string, orderByDate string) ([]entity.Approval, int64, error)
|
||||
List(ctx context.Context, module string, approvableID *uint, page, limit int, search string) ([]entity.Approval, int64, error)
|
||||
ListByTarget(ctx context.Context, workflow approvalutils.ApprovalWorkflowKey, approvableID uint, modifier func(*gorm.DB) *gorm.DB) ([]entity.Approval, error)
|
||||
LatestByTarget(ctx context.Context, workflow approvalutils.ApprovalWorkflowKey, approvableID uint, modifier func(*gorm.DB) *gorm.DB) (*entity.Approval, error)
|
||||
LatestByTargets(ctx context.Context, workflow approvalutils.ApprovalWorkflowKey, approvableIDs []uint, modifier func(*gorm.DB) *gorm.DB) (map[uint]*entity.Approval, error)
|
||||
@@ -70,14 +70,9 @@ func (s *approvalService) List(
|
||||
approvableID *uint,
|
||||
page, limit int,
|
||||
search string,
|
||||
orderByDate string,
|
||||
) ([]entity.Approval, int64, error) {
|
||||
module = strings.TrimSpace(strings.ToUpper(module))
|
||||
search = strings.TrimSpace(search)
|
||||
orderByDate = strings.TrimSpace(strings.ToUpper(orderByDate))
|
||||
if orderByDate != "ASC" && orderByDate != "DESC" {
|
||||
orderByDate = "DESC"
|
||||
}
|
||||
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
@@ -95,7 +90,7 @@ func (s *approvalService) List(
|
||||
func(db *gorm.DB) *gorm.DB {
|
||||
query := db.
|
||||
Where("approvable_type = ?", module).
|
||||
Order("action_at " + orderByDate).
|
||||
Order("action_at DESC").
|
||||
Preload("ActionUser")
|
||||
|
||||
if approvableID != nil {
|
||||
|
||||
@@ -20,7 +20,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
defaultDocumentPathLimit = 255
|
||||
defaultDocumentPathLimit = 50
|
||||
defaultDocumentKeyPrefix = "docs"
|
||||
maxDocumentNameLength = 50
|
||||
)
|
||||
@@ -363,19 +363,13 @@ func (s *documentService) generateObjectKey(ext string) (string, error) {
|
||||
}
|
||||
|
||||
u := uuid.New().String()
|
||||
keyPrefix := strings.Trim(s.keyPrefix, "/")
|
||||
key := fmt.Sprintf("%s%s", u, normalizedExt)
|
||||
if keyPrefix != "" {
|
||||
key = fmt.Sprintf("%s/%s%s", keyPrefix, u, normalizedExt)
|
||||
key := fmt.Sprintf("%s/%s%s", strings.Trim(s.keyPrefix, "/"), u, normalizedExt)
|
||||
if s.keyPrefix == "" {
|
||||
key = fmt.Sprintf("%s%s", u, normalizedExt)
|
||||
}
|
||||
|
||||
if len(key) > s.maxPathLength {
|
||||
compact := strings.ReplaceAll(u, "-", "")
|
||||
if keyPrefix != "" {
|
||||
key = fmt.Sprintf("%s/%s%s", keyPrefix, compact, normalizedExt)
|
||||
} else {
|
||||
key = fmt.Sprintf("%s%s", compact, normalizedExt)
|
||||
}
|
||||
key = fmt.Sprintf("%s%s", u, normalizedExt)
|
||||
}
|
||||
|
||||
if len(key) > s.maxPathLength {
|
||||
|
||||
@@ -25,8 +25,6 @@ type FifoService interface {
|
||||
Replenish(ctx context.Context, req StockReplenishRequest) (*StockReplenishResult, error)
|
||||
Consume(ctx context.Context, req StockConsumeRequest) (*StockConsumeResult, error)
|
||||
ReleaseUsage(ctx context.Context, req StockReleaseRequest) error
|
||||
AdjustStockableQuantity(ctx context.Context, req StockAdjustRequest) error
|
||||
ResolvePending(ctx context.Context, req PendingResolveRequest) ([]PendingResolution, error)
|
||||
}
|
||||
|
||||
type fifoService struct {
|
||||
@@ -97,26 +95,12 @@ type StockReplenishRequest struct {
|
||||
Tx *gorm.DB
|
||||
}
|
||||
|
||||
type StockAdjustRequest struct {
|
||||
StockableKey fifo.StockableKey
|
||||
StockableID uint
|
||||
ProductWarehouseID uint
|
||||
Quantity float64
|
||||
Note *string
|
||||
Tx *gorm.DB
|
||||
}
|
||||
|
||||
type PendingResolution struct {
|
||||
UsableKey fifo.UsableKey
|
||||
UsableID uint
|
||||
Quantity float64
|
||||
}
|
||||
|
||||
type PendingResolveRequest struct {
|
||||
ProductWarehouseID uint
|
||||
Tx *gorm.DB
|
||||
}
|
||||
|
||||
type StockReplenishResult struct {
|
||||
AddedQuantity float64
|
||||
PendingResolved []PendingResolution
|
||||
@@ -154,38 +138,6 @@ type StockReleaseRequest struct {
|
||||
Tx *gorm.DB
|
||||
}
|
||||
|
||||
func (s *fifoService) AdjustStockableQuantity(ctx context.Context, req StockAdjustRequest) error {
|
||||
if req.StockableID == 0 || strings.TrimSpace(req.StockableKey.String()) == "" {
|
||||
return errors.New("stockable key and id are required")
|
||||
}
|
||||
if req.ProductWarehouseID == 0 {
|
||||
return errors.New("product warehouse id is required")
|
||||
}
|
||||
if req.Quantity == 0 {
|
||||
return nil
|
||||
}
|
||||
if req.Quantity > 0 {
|
||||
return errors.New("quantity must be negative")
|
||||
}
|
||||
|
||||
cfg, ok := fifo.Stockable(req.StockableKey)
|
||||
if !ok {
|
||||
return fmt.Errorf("stockable %q is not registered", req.StockableKey)
|
||||
}
|
||||
|
||||
return s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error {
|
||||
if err := s.incrementStockableQty(ctx, tx, cfg, req.StockableID, req.Quantity); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.productWarehouseRepo.AdjustQuantities(ctx, map[uint]float64{
|
||||
req.ProductWarehouseID: req.Quantity,
|
||||
}, func(db *gorm.DB) *gorm.DB {
|
||||
return s.txOrDB(tx, db)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (s *fifoService) Replenish(ctx context.Context, req StockReplenishRequest) (*StockReplenishResult, error) {
|
||||
if req.StockableID == 0 || strings.TrimSpace(req.StockableKey.String()) == "" {
|
||||
return nil, errors.New("stockable key and id are required")
|
||||
@@ -233,23 +185,6 @@ func (s *fifoService) Replenish(ctx context.Context, req StockReplenishRequest)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *fifoService) ResolvePending(ctx context.Context, req PendingResolveRequest) ([]PendingResolution, error) {
|
||||
if req.ProductWarehouseID == 0 {
|
||||
return nil, errors.New("product warehouse id is required")
|
||||
}
|
||||
|
||||
var resolved []PendingResolution
|
||||
err := s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error {
|
||||
var err error
|
||||
resolved, err = s.resolvePendingForWarehouse(ctx, tx, req.ProductWarehouseID)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
func (s *fifoService) Consume(ctx context.Context, req StockConsumeRequest) (*StockConsumeResult, error) {
|
||||
if req.UsableID == 0 || strings.TrimSpace(req.UsableKey.String()) == "" {
|
||||
return nil, errors.New("usable key and id are required")
|
||||
@@ -332,7 +267,7 @@ func (s *fifoService) Consume(ctx context.Context, req StockConsumeRequest) (*St
|
||||
}
|
||||
|
||||
if reductionTarget > 0 {
|
||||
released, err := s.releaseUsagePortion(ctx, tx, req.UsableKey, req.UsableID, reductionTarget, productWarehouseID)
|
||||
released, err := s.releaseUsagePortion(ctx, tx, req.UsableKey, req.UsableID, reductionTarget)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -379,7 +314,7 @@ func (s *fifoService) ReleaseUsage(ctx context.Context, req StockReleaseRequest)
|
||||
}
|
||||
var usageDelta, pendingDelta float64
|
||||
if ctxRow.UsageQty > 0 {
|
||||
if _, err := s.releaseUsagePortion(ctx, tx, req.UsableKey, req.UsableID, ctxRow.UsageQty, ctxRow.ProductWarehouseID); err != nil {
|
||||
if _, err := s.releaseUsagePortion(ctx, tx, req.UsableKey, req.UsableID, ctxRow.UsageQty); err != nil {
|
||||
return err
|
||||
}
|
||||
usageDelta -= ctxRow.UsageQty
|
||||
@@ -528,7 +463,6 @@ func (s *fifoService) allocateFromStock(
|
||||
UsableType: usableKey.String(),
|
||||
UsableId: usableID,
|
||||
Qty: portion,
|
||||
AllocationPurpose: entities.StockAllocationPurposeConsume,
|
||||
Status: entities.StockAllocationStatusActive,
|
||||
})
|
||||
|
||||
@@ -746,7 +680,6 @@ func (s *fifoService) releaseUsagePortion(
|
||||
usableKey fifo.UsableKey,
|
||||
usableID uint,
|
||||
target float64,
|
||||
expectedWarehouseID uint,
|
||||
) (float64, error) {
|
||||
if target <= 0 {
|
||||
return 0, nil
|
||||
@@ -762,18 +695,6 @@ func (s *fifoService) releaseUsagePortion(
|
||||
if len(allocations) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
for i := range allocations {
|
||||
alloc := &allocations[i]
|
||||
if expectedWarehouseID == 0 || alloc.ProductWarehouseId == expectedWarehouseID {
|
||||
continue
|
||||
}
|
||||
if err := tx.Model(&entities.StockAllocation{}).
|
||||
Where("id = ?", alloc.Id).
|
||||
Update("product_warehouse_id", expectedWarehouseID).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
alloc.ProductWarehouseId = expectedWarehouseID
|
||||
}
|
||||
|
||||
var (
|
||||
remaining = target
|
||||
@@ -870,79 +791,41 @@ func (s *fifoService) fetchPendingCandidates(ctx context.Context, tx *gorm.DB, p
|
||||
cfg.Columns.CreatedAt,
|
||||
)
|
||||
|
||||
if cfg.Columns.CreatedAt == cfg.Columns.ID {
|
||||
var rows []struct {
|
||||
ID uint
|
||||
Pending float64 `gorm:"column:pending_qty"`
|
||||
CreatedAt int64 `gorm:"column:created_at"`
|
||||
}
|
||||
var rows []struct {
|
||||
ID uint
|
||||
Pending float64
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
query := tx.Table(cfg.Table).
|
||||
Select(selectStmt).
|
||||
Where(fmt.Sprintf("%s = ?", cfg.Columns.ProductWarehouseID), productWarehouseID).
|
||||
Where(fmt.Sprintf("%s > 0", cfg.Columns.PendingQuantity)).
|
||||
Limit(s.pendingBatchPerUsable)
|
||||
query := tx.Table(cfg.Table).
|
||||
Select(selectStmt).
|
||||
Where(fmt.Sprintf("%s = ?", cfg.Columns.ProductWarehouseID), productWarehouseID).
|
||||
Where(fmt.Sprintf("%s > 0", cfg.Columns.PendingQuantity)).
|
||||
Limit(s.pendingBatchPerUsable)
|
||||
|
||||
if cfg.Scope != nil {
|
||||
query = cfg.Scope(query)
|
||||
}
|
||||
if cfg.Scope != nil {
|
||||
query = cfg.Scope(query)
|
||||
}
|
||||
|
||||
for _, order := range s.orderClauses(cfg.OrderBy) {
|
||||
query = query.Order(order)
|
||||
}
|
||||
for _, order := range s.orderClauses(cfg.OrderBy) {
|
||||
query = query.Order(order)
|
||||
}
|
||||
|
||||
if err := query.Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, row := range rows {
|
||||
if row.Pending <= 0 {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, pendingCandidate{
|
||||
UsableKey: key,
|
||||
Config: cfg,
|
||||
UsableID: row.ID,
|
||||
Pending: row.Pending,
|
||||
CreatedAt: time.Unix(0, row.CreatedAt),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
var rows []struct {
|
||||
ID uint
|
||||
Pending float64 `gorm:"column:pending_qty"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
}
|
||||
if err := query.Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query := tx.Table(cfg.Table).
|
||||
Select(selectStmt).
|
||||
Where(fmt.Sprintf("%s = ?", cfg.Columns.ProductWarehouseID), productWarehouseID).
|
||||
Where(fmt.Sprintf("%s > 0", cfg.Columns.PendingQuantity)).
|
||||
Limit(s.pendingBatchPerUsable)
|
||||
|
||||
if cfg.Scope != nil {
|
||||
query = cfg.Scope(query)
|
||||
}
|
||||
|
||||
for _, order := range s.orderClauses(cfg.OrderBy) {
|
||||
query = query.Order(order)
|
||||
}
|
||||
|
||||
if err := query.Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
if row.Pending <= 0 {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, pendingCandidate{
|
||||
UsableKey: key,
|
||||
Config: cfg,
|
||||
UsableID: row.ID,
|
||||
Pending: row.Pending,
|
||||
CreatedAt: row.CreatedAt,
|
||||
})
|
||||
for _, row := range rows {
|
||||
if row.Pending <= 0 {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, pendingCandidate{
|
||||
UsableKey: key,
|
||||
Config: cfg,
|
||||
UsableID: row.ID,
|
||||
Pending: row.Pending,
|
||||
CreatedAt: row.CreatedAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"github.com/sirupsen/logrus"
|
||||
fifoStockV2 "gitlab.com/mbugroup/lti-api.git/internal/common/service/fifo_stock_v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type FifoStockV2Service = fifoStockV2.Service
|
||||
|
||||
type FifoStockV2Lane = fifoStockV2.Lane
|
||||
|
||||
type FifoStockV2Ref = fifoStockV2.Ref
|
||||
|
||||
type FifoStockV2GatherRequest = fifoStockV2.GatherRequest
|
||||
|
||||
type FifoStockV2GatherRow = fifoStockV2.GatherRow
|
||||
|
||||
type FifoStockV2AllocateRequest = fifoStockV2.AllocateRequest
|
||||
|
||||
type FifoStockV2AllocateResult = fifoStockV2.AllocateResult
|
||||
|
||||
type FifoStockV2AllocationDetail = fifoStockV2.AllocationDetail
|
||||
|
||||
type FifoStockV2RollbackRequest = fifoStockV2.RollbackRequest
|
||||
|
||||
type FifoStockV2RollbackResult = fifoStockV2.RollbackResult
|
||||
|
||||
type FifoStockV2ReflowRequest = fifoStockV2.ReflowRequest
|
||||
|
||||
type FifoStockV2ReflowResult = fifoStockV2.ReflowResult
|
||||
|
||||
type FifoStockV2RecalculateRequest = fifoStockV2.RecalculateRequest
|
||||
|
||||
type FifoStockV2RecalculateResult = fifoStockV2.RecalculateResult
|
||||
|
||||
type FifoStockV2WarehouseDrift = fifoStockV2.WarehouseDrift
|
||||
|
||||
func NewFifoStockV2Service(db *gorm.DB, logger *logrus.Logger) FifoStockV2Service {
|
||||
return fifoStockV2.NewService(db, logger)
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
|
||||
)
|
||||
|
||||
type HppService interface {
|
||||
CalculateHppCost(projectFlockKandangId uint, date *time.Time) (*HppCostResponse, error)
|
||||
GetTotalDepresiasiFlockGrowing(sourceProjectFlockID uint, date *time.Time) (float64, error)
|
||||
GetTotalProductionCost(projectFlockKandangId uint, endDate *time.Time, depresiasiTransfer float64) (float64, error)
|
||||
GetBudgetKandangLaying(projectFlockKandangId uint, endDate *time.Time) (float64, error)
|
||||
GetDepresiasiTransfer(projectFlockKandangId uint, date *time.Time) (float64, error)
|
||||
GetHppEstimationDanRealisasi(totalProductionCost float64, projectFlockKandangId uint, startDate *time.Time, endDate *time.Time) (*HppCostResponse, error)
|
||||
}
|
||||
|
||||
type HppCostResponse struct {
|
||||
Estimation HppCostDetail `json:"estimation"`
|
||||
Real HppCostDetail `json:"real"`
|
||||
}
|
||||
|
||||
type HppCostDetail struct {
|
||||
HargaKg float64 `json:"harga_kg"`
|
||||
HargaButir float64 `json:"harga_butir"`
|
||||
Total float64 `json:"total"`
|
||||
Kg float64 `json:"kg"`
|
||||
Butir float64 `json:"butir"`
|
||||
}
|
||||
|
||||
type hppService struct {
|
||||
hppRepo commonRepo.HppCostRepository
|
||||
}
|
||||
|
||||
func NewHppService(hppRepo commonRepo.HppCostRepository) HppService {
|
||||
return &hppService{hppRepo: hppRepo}
|
||||
}
|
||||
|
||||
func (s *hppService) CalculateHppCost(projectFlockKandangId uint, date *time.Time) (*HppCostResponse, error) {
|
||||
if date == nil {
|
||||
now := time.Now()
|
||||
date = &now
|
||||
}
|
||||
|
||||
location, err := time.LoadLocation("Asia/Jakarta")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
startOfDay := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, location)
|
||||
endOfDay := startOfDay.Add(24 * time.Hour)
|
||||
|
||||
depresiasiTransfer, err := s.GetDepresiasiTransfer(projectFlockKandangId, &endOfDay)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
totalProductionCost, err := s.GetTotalProductionCost(projectFlockKandangId, &endOfDay, depresiasiTransfer)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.GetHppEstimationDanRealisasi(totalProductionCost, projectFlockKandangId, &startOfDay, &endOfDay)
|
||||
|
||||
}
|
||||
|
||||
func (s *hppService) GetTotalDepresiasiFlockGrowing(sourceProjectFlockID uint, date *time.Time) (float64, error) {
|
||||
if date == nil {
|
||||
now := time.Now()
|
||||
date = &now
|
||||
}
|
||||
|
||||
if s.hppRepo == nil {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
kandangIDs, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), sourceProjectFlockID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
docCost, err := s.hppRepo.GetDocCost(context.Background(), kandangIDs)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
budgetCost, err := s.hppRepo.GetBudgetCostByProjectFlockId(context.Background(), sourceProjectFlockID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
expedisionCost, err := s.hppRepo.GetExpedisionCost(context.Background(), kandangIDs)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
feedCost, err := s.hppRepo.GetFeedUsageCost(context.Background(), kandangIDs, date)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
ovkCost, err := s.hppRepo.GetOvkUsageCost(context.Background(), kandangIDs, date)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return docCost + budgetCost + expedisionCost + feedCost + ovkCost, nil
|
||||
}
|
||||
|
||||
func (s *hppService) GetTotalProductionCost(projectFlockKandangId uint, endDate *time.Time, depresiasiTransfer float64) (float64, error) {
|
||||
// if date == nil {
|
||||
// now := time.Now()
|
||||
// date = &now
|
||||
// }
|
||||
|
||||
costPullet, err := s.hppRepo.GetPulletCost(context.Background(), projectFlockKandangId)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
costFeed, err := s.hppRepo.GetFeedUsageCost(context.Background(), []uint{projectFlockKandangId}, endDate)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
costOvk, err := s.hppRepo.GetOvkUsageCost(context.Background(), []uint{projectFlockKandangId}, endDate)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
costExpedision, err := s.hppRepo.GetExpedisionCost(context.Background(), []uint{projectFlockKandangId})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
costBudget, err := s.GetBudgetKandangLaying(projectFlockKandangId, endDate)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return depresiasiTransfer + costPullet + costFeed + costOvk + costExpedision + costBudget, nil
|
||||
}
|
||||
|
||||
func (s *hppService) GetBudgetKandangLaying(projectFlockKandangId uint, endDate *time.Time) (float64, error) {
|
||||
// if date == nil {
|
||||
// now := time.Now()
|
||||
// date = &now
|
||||
// }
|
||||
|
||||
if s.hppRepo == nil {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
projectFlockId, err := s.hppRepo.GetProjectFlockIDByProjectFlockKandangID(context.Background(), projectFlockKandangId)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
projectFlockKandangIds, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), projectFlockId)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
eggProduksiPiecesFlock, _, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), projectFlockKandangIds, endDate)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
eggProduksiPiecesKandang, _, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, endDate)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
totalBudgetCost, err := s.hppRepo.GetBudgetCostByProjectFlockId(context.Background(), projectFlockId)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if eggProduksiPiecesFlock == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
return (totalBudgetCost * eggProduksiPiecesKandang) / eggProduksiPiecesFlock, nil
|
||||
}
|
||||
|
||||
func (s *hppService) GetDepresiasiTransfer(projectFlockKandangId uint, endDate *time.Time) (float64, error) {
|
||||
// if endDate == nil {
|
||||
// now := time.Now()
|
||||
// endDate = &now
|
||||
// }
|
||||
|
||||
if s.hppRepo == nil {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
sourceProjectFlockID, transferTotalQty, err := s.hppRepo.GetTransferSourceSummary(context.Background(), projectFlockKandangId)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
kandangIDsGrowing, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), sourceProjectFlockID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
totalPopulationFlockGrowing, err := s.hppRepo.GetTotalPopulation(context.Background(), kandangIDsGrowing)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if totalPopulationFlockGrowing == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
totalDepresiasiFlockGrowing, err := s.GetTotalDepresiasiFlockGrowing(sourceProjectFlockID, endDate)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return (totalDepresiasiFlockGrowing * transferTotalQty) / totalPopulationFlockGrowing, nil
|
||||
}
|
||||
|
||||
func (s *hppService) GetHppEstimationDanRealisasi(totalProductionCost float64, projectFlockKandangId uint, startDate *time.Time, endDate *time.Time) (*HppCostResponse, error) {
|
||||
|
||||
if s.hppRepo == nil {
|
||||
return &HppCostResponse{}, nil
|
||||
}
|
||||
|
||||
estimPieces, estimWeightKg, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, endDate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
realPieces, realWeightKg, err := s.hppRepo.GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, startDate, endDate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
estimation := HppCostDetail{
|
||||
Total: totalProductionCost,
|
||||
Kg: estimWeightKg,
|
||||
Butir: estimPieces,
|
||||
}
|
||||
if estimWeightKg > 0 {
|
||||
estimation.HargaKg = roundToTwoDecimals(totalProductionCost / estimWeightKg)
|
||||
}
|
||||
if estimPieces > 0 {
|
||||
estimation.HargaButir = roundToTwoDecimals(totalProductionCost / estimPieces)
|
||||
}
|
||||
|
||||
real := HppCostDetail{
|
||||
Total: totalProductionCost,
|
||||
Kg: realWeightKg,
|
||||
Butir: realPieces,
|
||||
}
|
||||
if realWeightKg > 0 {
|
||||
real.HargaKg = roundToTwoDecimals(totalProductionCost / realWeightKg)
|
||||
}
|
||||
if realPieces > 0 {
|
||||
real.HargaButir = roundToTwoDecimals(totalProductionCost / realPieces)
|
||||
}
|
||||
|
||||
return &HppCostResponse{
|
||||
Estimation: estimation,
|
||||
Real: real,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func roundToTwoDecimals(value float64) float64 {
|
||||
return math.Round(value*100) / 100
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
# RFC Ringkas: FIFO Stock V2
|
||||
|
||||
## Tujuan
|
||||
`fifo_stock_v2` adalah engine FIFO baru berbasis konfigurasi `Flag Group + Jalur` yang berjalan paralel dengan v1 tanpa memutus kompatibilitas `stock_allocations`, HPP, dan closing/reporting existing.
|
||||
|
||||
## Prinsip
|
||||
- V1 tidak dihapus, V2 jalan paralel.
|
||||
- Semua operasi transactional.
|
||||
- FIFO sorting deterministic lintas tabel.
|
||||
- Default over-consume `ALLOW` (pending), exception dapat `BLOCK`.
|
||||
- Reflow idempotent.
|
||||
- Recalculate bisa memperbaiki drift `product_warehouses.qty`.
|
||||
|
||||
## Komponen
|
||||
- `fifo_stock_v2_flag_groups`: master grouping flag produk.
|
||||
- `fifo_stock_v2_flag_members`: pemetaan flag -> group.
|
||||
- `fifo_stock_v2_traits`: trait sort per `table:date_column` (+ optional join date source).
|
||||
- `fifo_stock_v2_route_rules`: rule per `flag_group + lane + function + table`.
|
||||
- `fifo_stock_v2_overconsume_rules`: policy pending/over-consume.
|
||||
- `fifo_stock_v2_operation_log`: idempotency + audit operasi.
|
||||
- `fifo_stock_v2_reflow_runs` + checkpoints + shadow allocations: bulk reflow resumable/observable.
|
||||
|
||||
## API Service
|
||||
- `Gather`: union cross-table berdasarkan route rules + trait sorting.
|
||||
- `Allocate`: alokasi lot FIFO ke usable.
|
||||
- `Rollback`: batalkan alokasi aktif.
|
||||
- `Reflow`: rollback penuh lalu allocate ulang (idempotent).
|
||||
- `Recalculate`: rekonsiliasi qty warehouse dari ledger FIFO.
|
||||
|
||||
## Deterministic Sorting
|
||||
Urutan gather:
|
||||
1. `sort_at ASC` (dari trait `date_column`)
|
||||
2. `sort_priority ASC`
|
||||
3. `source_table ASC`
|
||||
4. `source_id ASC`
|
||||
|
||||
Fallback waktu: `1970-01-01 00:00:00+00` bila tanggal null.
|
||||
|
||||
## Compat Strategy
|
||||
- Tetap menulis ke `stock_allocations` dengan tambahan metadata:
|
||||
- `engine_version` (`v1`/`v2`)
|
||||
- `flag_group_code`
|
||||
- `function_code`
|
||||
- `idempotency_key`
|
||||
- Query lama yang bergantung `stockable_type/usable_type` tetap berjalan.
|
||||
|
||||
## Migration Strategy
|
||||
1. Deploy schema + seed v2.
|
||||
2. Aktifkan shadow-run comparator v1 vs v2.
|
||||
3. Canary cutover per flag group.
|
||||
4. Full cutover jika parity aman.
|
||||
5. Jalankan bulk reflow existing data.
|
||||
|
||||
## Acceptance Criteria Singkat
|
||||
- Parity mismatch terkendali pada aggregate + detail alokasi.
|
||||
- Tidak ada regression closing/HPP.
|
||||
- Drift qty warehouse turun signifikan pasca reflow.
|
||||
- Rollback via feature flag memungkinkan kembali ke v1.
|
||||
@@ -1,748 +0,0 @@
|
||||
package fifo_stock_v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type allocationRow struct {
|
||||
ID uint `gorm:"column:id"`
|
||||
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
|
||||
StockableType string `gorm:"column:stockable_type"`
|
||||
StockableID uint `gorm:"column:stockable_id"`
|
||||
UsableType string `gorm:"column:usable_type"`
|
||||
UsableID uint `gorm:"column:usable_id"`
|
||||
Qty float64 `gorm:"column:qty"`
|
||||
Status string `gorm:"column:status"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
}
|
||||
|
||||
type usableQtySnapshot struct {
|
||||
Usage float64 `gorm:"column:usage_qty"`
|
||||
Pending float64 `gorm:"column:pending_qty"`
|
||||
}
|
||||
|
||||
func (s *fifoStockV2Service) Allocate(ctx context.Context, req AllocateRequest) (*AllocateResult, error) {
|
||||
if err := s.validateAllocateRequest(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := &AllocateResult{}
|
||||
err := s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error {
|
||||
if err := s.ensureStockAllocationColumns(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.lockShard(tx, req.FlagGroupCode, req.ProductWarehouseID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hash := requestHash(map[string]any{
|
||||
"flag_group_code": req.FlagGroupCode,
|
||||
"product_warehouse_id": req.ProductWarehouseID,
|
||||
"usable_type": req.Usable.LegacyTypeKey,
|
||||
"usable_id": req.Usable.ID,
|
||||
"need_qty": req.NeedQty,
|
||||
"as_of": req.AsOf,
|
||||
"allow_over_consume": req.AllowOverConsume,
|
||||
})
|
||||
logRow, reused, err := s.beginOperation(
|
||||
tx,
|
||||
OperationAllocate,
|
||||
req.IdempotencyKey,
|
||||
hash,
|
||||
req.ProductWarehouseID,
|
||||
req.FlagGroupCode,
|
||||
req.Usable.LegacyTypeKey,
|
||||
req.Usable.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if reused {
|
||||
if len(logRow.ResultPayload) == 0 {
|
||||
return fmt.Errorf("idempotent allocate has empty payload")
|
||||
}
|
||||
if err := json.Unmarshal(logRow.ResultPayload, result); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if logRow != nil {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
s.failOperation(tx, logRow, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
allocated, allocErr := s.allocateInternal(ctx, tx, req)
|
||||
if allocErr != nil {
|
||||
err = allocErr
|
||||
return allocErr
|
||||
}
|
||||
*result = *allocated
|
||||
|
||||
if finishErr := s.finishOperation(tx, logRow, result); finishErr != nil {
|
||||
err = finishErr
|
||||
return finishErr
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *fifoStockV2Service) allocateInternal(ctx context.Context, tx *gorm.DB, req AllocateRequest) (*AllocateResult, error) {
|
||||
usableRule, err := s.loadRouteRuleByLegacyType(ctx, tx, LaneUsable, req.FlagGroupCode, req.Usable.LegacyTypeKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
allowOverConsume := usableRule.AllowPendingDefault
|
||||
if req.AllowOverConsume != nil {
|
||||
allowOverConsume = *req.AllowOverConsume
|
||||
} else {
|
||||
allowOverConsume, err = s.resolveOverConsume(tx, req.FlagGroupCode, req.Usable.FunctionCode, LaneUsable, allowOverConsume)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
gatherRows, err := s.gatherRows(ctx, tx, GatherRequest{
|
||||
FlagGroupCode: req.FlagGroupCode,
|
||||
Lane: LaneStockable,
|
||||
ProductWarehouseID: req.ProductWarehouseID,
|
||||
AsOf: req.AsOf,
|
||||
Limit: s.defaultGatherLimit,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stockableRuleMap, err := s.loadStockableRuleMap(ctx, tx, req.FlagGroupCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
remaining := req.NeedQty
|
||||
result := &AllocateResult{Details: make([]AllocationDetail, 0)}
|
||||
|
||||
for _, lot := range gatherRows {
|
||||
if remaining <= 0 {
|
||||
break
|
||||
}
|
||||
if lot.AvailableQuantity <= 0 {
|
||||
continue
|
||||
}
|
||||
portion := math.Min(remaining, lot.AvailableQuantity)
|
||||
if nearlyZero(portion) {
|
||||
continue
|
||||
}
|
||||
|
||||
allocationInsert := map[string]any{
|
||||
"product_warehouse_id": req.ProductWarehouseID,
|
||||
"stockable_type": lot.Ref.LegacyTypeKey,
|
||||
"stockable_id": lot.Ref.ID,
|
||||
"usable_type": req.Usable.LegacyTypeKey,
|
||||
"usable_id": req.Usable.ID,
|
||||
"qty": portion,
|
||||
"status": activeAllocationStatus(),
|
||||
"allocation_purpose": defaultAllocationPurpose(),
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"engine_version": "v2",
|
||||
"flag_group_code": req.FlagGroupCode,
|
||||
"function_code": req.Usable.FunctionCode,
|
||||
}
|
||||
if strings.TrimSpace(req.IdempotencyKey) != "" {
|
||||
allocationInsert["idempotency_key"] = req.IdempotencyKey
|
||||
}
|
||||
if err := tx.Table("stock_allocations").Create(allocationInsert).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rule, ok := stockableRuleMap[lot.Ref.LegacyTypeKey]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("missing stockable route rule for type %s", lot.Ref.LegacyTypeKey)
|
||||
}
|
||||
if err := s.adjustStockableUsedQuantity(tx, rule, lot.Ref.ID, portion); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result.Details = append(result.Details, AllocationDetail{
|
||||
StockableType: lot.Ref.LegacyTypeKey,
|
||||
StockableID: lot.Ref.ID,
|
||||
Qty: portion,
|
||||
SortAt: lot.SortAt,
|
||||
})
|
||||
|
||||
remaining -= portion
|
||||
result.AllocatedQty += portion
|
||||
}
|
||||
|
||||
if remaining > 0 {
|
||||
if !allowOverConsume {
|
||||
return nil, fmt.Errorf("%w: requested %.3f, allocated %.3f", ErrInsufficientStock, req.NeedQty, result.AllocatedQty)
|
||||
}
|
||||
result.PendingQty = remaining
|
||||
}
|
||||
|
||||
if err := s.applyUsableDeltas(tx, *usableRule, req.Usable.ID, result.AllocatedQty, result.PendingQty); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.adjustProductWarehouseQty(tx, req.ProductWarehouseID, -result.AllocatedQty); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *fifoStockV2Service) Rollback(ctx context.Context, req RollbackRequest) (*RollbackResult, error) {
|
||||
if err := s.validateRollbackRequest(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := &RollbackResult{}
|
||||
err := s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error {
|
||||
if err := s.ensureStockAllocationColumns(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
flagGroupCode, err := s.resolveRollbackFlagGroup(ctx, tx, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.lockShard(tx, flagGroupCode, req.ProductWarehouseID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hash := requestHash(map[string]any{
|
||||
"product_warehouse_id": req.ProductWarehouseID,
|
||||
"usable_type": req.Usable.LegacyTypeKey,
|
||||
"usable_id": req.Usable.ID,
|
||||
"release_qty": req.ReleaseQty,
|
||||
"reason": req.Reason,
|
||||
"flag_group_code": flagGroupCode,
|
||||
})
|
||||
logRow, reused, beginErr := s.beginOperation(
|
||||
tx,
|
||||
OperationRollback,
|
||||
req.IdempotencyKey,
|
||||
hash,
|
||||
req.ProductWarehouseID,
|
||||
flagGroupCode,
|
||||
req.Usable.LegacyTypeKey,
|
||||
req.Usable.ID,
|
||||
)
|
||||
if beginErr != nil {
|
||||
return beginErr
|
||||
}
|
||||
if reused {
|
||||
if len(logRow.ResultPayload) == 0 {
|
||||
return fmt.Errorf("idempotent rollback has empty payload")
|
||||
}
|
||||
if err := json.Unmarshal(logRow.ResultPayload, result); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if logRow != nil {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
s.failOperation(tx, logRow, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
rolled, rollbackErr := s.rollbackInternal(ctx, tx, req, flagGroupCode)
|
||||
if rollbackErr != nil {
|
||||
err = rollbackErr
|
||||
return rollbackErr
|
||||
}
|
||||
*result = *rolled
|
||||
|
||||
if finishErr := s.finishOperation(tx, logRow, result); finishErr != nil {
|
||||
err = finishErr
|
||||
return finishErr
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *fifoStockV2Service) rollbackInternal(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
req RollbackRequest,
|
||||
flagGroupCode string,
|
||||
) (*RollbackResult, error) {
|
||||
usableRule, err := s.loadRouteRuleByLegacyType(ctx, tx, LaneUsable, flagGroupCode, req.Usable.LegacyTypeKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
allocations, err := s.loadActiveAllocations(tx, req.Usable.LegacyTypeKey, req.Usable.ID, req.ProductWarehouseID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(allocations) == 0 {
|
||||
if req.ReleaseQty == nil {
|
||||
if err := s.resetUsableQuantities(tx, *usableRule, req.Usable.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &RollbackResult{}, nil
|
||||
}
|
||||
|
||||
stockableRuleMap, err := s.loadStockableRuleMap(ctx, tx, flagGroupCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
target := 0.0
|
||||
for _, alloc := range allocations {
|
||||
target += alloc.Qty
|
||||
}
|
||||
if req.ReleaseQty != nil {
|
||||
if *req.ReleaseQty < 0 {
|
||||
return nil, fmt.Errorf("%w: release qty must be >= 0", ErrInvalidRequest)
|
||||
}
|
||||
target = *req.ReleaseQty
|
||||
}
|
||||
if nearlyZero(target) {
|
||||
return &RollbackResult{}, nil
|
||||
}
|
||||
|
||||
result := &RollbackResult{Details: make([]AllocationDetail, 0)}
|
||||
now := time.Now()
|
||||
remaining := target
|
||||
|
||||
for _, alloc := range allocations {
|
||||
if remaining <= 0 {
|
||||
break
|
||||
}
|
||||
portion := math.Min(remaining, alloc.Qty)
|
||||
if nearlyZero(portion) {
|
||||
continue
|
||||
}
|
||||
|
||||
if nearlyZero(alloc.Qty - portion) {
|
||||
updates := map[string]any{
|
||||
"status": releasedAllocationStatus(),
|
||||
"released_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
if strings.TrimSpace(req.Reason) != "" {
|
||||
updates["note"] = req.Reason
|
||||
}
|
||||
if err := tx.Table("stock_allocations").Where("id = ?", alloc.ID).Updates(updates).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
if err := tx.Table("stock_allocations").
|
||||
Where("id = ?", alloc.ID).
|
||||
Updates(map[string]any{
|
||||
"qty": alloc.Qty - portion,
|
||||
"updated_at": now,
|
||||
}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
stockableRule, ok := stockableRuleMap[alloc.StockableType]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("missing stockable route rule for type %s", alloc.StockableType)
|
||||
}
|
||||
if err := s.adjustStockableUsedQuantity(tx, stockableRule, alloc.StockableID, -portion); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result.ReleasedQty += portion
|
||||
remaining -= portion
|
||||
result.Details = append(result.Details, AllocationDetail{
|
||||
StockableType: alloc.StockableType,
|
||||
StockableID: alloc.StockableID,
|
||||
Qty: portion,
|
||||
SortAt: alloc.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
if req.ReleaseQty != nil && remaining > 1e-6 {
|
||||
return nil, fmt.Errorf("unable to release %.3f; only %.3f allocation exists", target, result.ReleasedQty)
|
||||
}
|
||||
|
||||
if req.ReleaseQty == nil {
|
||||
if err := s.resetUsableQuantities(tx, *usableRule, req.Usable.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
if err := s.applyUsableDeltas(tx, *usableRule, req.Usable.ID, -result.ReleasedQty, 0); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.adjustProductWarehouseQty(tx, req.ProductWarehouseID, result.ReleasedQty); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *fifoStockV2Service) Reflow(ctx context.Context, req ReflowRequest) (*ReflowResult, error) {
|
||||
if strings.TrimSpace(req.FlagGroupCode) == "" || req.ProductWarehouseID == 0 {
|
||||
return nil, fmt.Errorf("%w: invalid reflow request", ErrInvalidRequest)
|
||||
}
|
||||
|
||||
result := &ReflowResult{}
|
||||
err := s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error {
|
||||
if err := s.ensureStockAllocationColumns(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.lockShard(tx, req.FlagGroupCode, req.ProductWarehouseID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hash := requestHash(map[string]any{
|
||||
"flag_group_code": req.FlagGroupCode,
|
||||
"product_warehouse_id": req.ProductWarehouseID,
|
||||
"as_of": req.AsOf,
|
||||
})
|
||||
logRow, reused, err := s.beginOperation(
|
||||
tx,
|
||||
OperationReflow,
|
||||
req.IdempotencyKey,
|
||||
hash,
|
||||
req.ProductWarehouseID,
|
||||
req.FlagGroupCode,
|
||||
"",
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if reused {
|
||||
if len(logRow.ResultPayload) == 0 {
|
||||
return fmt.Errorf("idempotent reflow has empty payload")
|
||||
}
|
||||
if err := json.Unmarshal(logRow.ResultPayload, result); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if logRow != nil {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
s.failOperation(tx, logRow, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
usableRows, gatherErr := s.gatherAllRows(ctx, tx, GatherRequest{
|
||||
FlagGroupCode: req.FlagGroupCode,
|
||||
Lane: LaneUsable,
|
||||
ProductWarehouseID: req.ProductWarehouseID,
|
||||
Limit: s.defaultGatherLimit,
|
||||
})
|
||||
if gatherErr != nil {
|
||||
err = gatherErr
|
||||
return gatherErr
|
||||
}
|
||||
result.ProcessedUsables = len(usableRows)
|
||||
|
||||
for _, usableRow := range usableRows {
|
||||
desiredQty := usableRow.Quantity + usableRow.PendingQuantity
|
||||
|
||||
rollbackRes, rollbackErr := s.rollbackInternal(ctx, tx, RollbackRequest{
|
||||
ProductWarehouseID: req.ProductWarehouseID,
|
||||
Usable: usableRow.Ref,
|
||||
ReleaseQty: nil,
|
||||
Reason: "reflow reset",
|
||||
}, req.FlagGroupCode)
|
||||
if rollbackErr != nil {
|
||||
err = rollbackErr
|
||||
return rollbackErr
|
||||
}
|
||||
result.Rollback.ReleasedQty += rollbackRes.ReleasedQty
|
||||
if len(rollbackRes.Details) > 0 {
|
||||
result.Rollback.Details = append(result.Rollback.Details, rollbackRes.Details...)
|
||||
}
|
||||
minDesired := rollbackRes.ReleasedQty + usableRow.PendingQuantity
|
||||
if desiredQty < minDesired {
|
||||
desiredQty = minDesired
|
||||
}
|
||||
|
||||
if desiredQty <= 0 {
|
||||
continue
|
||||
}
|
||||
allocateRes, allocateErr := s.allocateInternal(ctx, tx, AllocateRequest{
|
||||
FlagGroupCode: req.FlagGroupCode,
|
||||
ProductWarehouseID: req.ProductWarehouseID,
|
||||
Usable: usableRow.Ref,
|
||||
NeedQty: desiredQty,
|
||||
AsOf: nil,
|
||||
})
|
||||
if allocateErr != nil {
|
||||
err = allocateErr
|
||||
return allocateErr
|
||||
}
|
||||
result.Allocate.AllocatedQty += allocateRes.AllocatedQty
|
||||
result.Allocate.PendingQty += allocateRes.PendingQty
|
||||
if len(allocateRes.Details) > 0 {
|
||||
result.Allocate.Details = append(result.Allocate.Details, allocateRes.Details...)
|
||||
}
|
||||
}
|
||||
|
||||
expectedQty, calcErr := s.calculateWarehouseAvailableForGroup(ctx, tx, req.ProductWarehouseID, req.FlagGroupCode, nil)
|
||||
if calcErr != nil {
|
||||
err = calcErr
|
||||
return calcErr
|
||||
}
|
||||
actualQty, loadErr := s.loadWarehouseQty(ctx, tx, req.ProductWarehouseID)
|
||||
if loadErr != nil {
|
||||
err = loadErr
|
||||
return loadErr
|
||||
}
|
||||
drift := expectedQty - actualQty
|
||||
if math.Abs(drift) >= 1e-6 {
|
||||
if adjustErr := s.adjustProductWarehouseQty(tx, req.ProductWarehouseID, drift); adjustErr != nil {
|
||||
err = adjustErr
|
||||
return adjustErr
|
||||
}
|
||||
}
|
||||
|
||||
if finishErr := s.finishOperation(tx, logRow, result); finishErr != nil {
|
||||
err = finishErr
|
||||
return finishErr
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *fifoStockV2Service) gatherAllRows(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
req GatherRequest,
|
||||
) ([]GatherRow, error) {
|
||||
limit := req.Limit
|
||||
if limit <= 0 {
|
||||
limit = s.defaultGatherLimit
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 1000
|
||||
}
|
||||
|
||||
req.Limit = limit
|
||||
out := make([]GatherRow, 0, limit)
|
||||
|
||||
var cursorSortAt *time.Time
|
||||
cursorSourceTable := ""
|
||||
var cursorSourceID uint
|
||||
|
||||
for {
|
||||
req.AfterSortAt = cursorSortAt
|
||||
req.AfterSourceTable = cursorSourceTable
|
||||
req.AfterSourceID = cursorSourceID
|
||||
|
||||
rows, err := s.gatherRows(ctx, tx, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
out = append(out, rows...)
|
||||
if len(rows) < limit {
|
||||
break
|
||||
}
|
||||
|
||||
last := rows[len(rows)-1]
|
||||
lastSortAt := last.SortAt
|
||||
cursorSortAt = &lastSortAt
|
||||
cursorSourceTable = last.SourceTable
|
||||
cursorSourceID = last.SourceID
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *fifoStockV2Service) loadActiveAllocations(
|
||||
tx *gorm.DB,
|
||||
usableType string,
|
||||
usableID uint,
|
||||
productWarehouseID uint,
|
||||
) ([]allocationRow, error) {
|
||||
query := tx.Table("stock_allocations").
|
||||
Select("id, product_warehouse_id, stockable_type, stockable_id, usable_type, usable_id, qty, status, created_at").
|
||||
Where("usable_type = ? AND usable_id = ? AND status = ? AND allocation_purpose = ?", usableType, usableID, activeAllocationStatus(), defaultAllocationPurpose())
|
||||
if productWarehouseID > 0 {
|
||||
query = query.Where("product_warehouse_id = ?", productWarehouseID)
|
||||
}
|
||||
query = query.Order("created_at DESC, id DESC")
|
||||
|
||||
var rows []allocationRow
|
||||
if err := query.Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (s *fifoStockV2Service) loadStockableRuleMap(ctx context.Context, tx *gorm.DB, flagGroupCode string) (map[string]routeRule, error) {
|
||||
rules, err := s.loadRouteRules(ctx, tx, flagGroupCode, LaneStockable)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := make(map[string]routeRule, len(rules))
|
||||
for _, rule := range rules {
|
||||
m[rule.LegacyTypeKey] = rule
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (s *fifoStockV2Service) adjustStockableUsedQuantity(tx *gorm.DB, rule routeRule, sourceID uint, delta float64) error {
|
||||
if nearlyZero(delta) || sourceID == 0 {
|
||||
return nil
|
||||
}
|
||||
if rule.UsedQuantityCol == nil || strings.TrimSpace(*rule.UsedQuantityCol) == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
usedCol, _ := mustSafeIdentifier(*rule.UsedQuantityCol)
|
||||
sourceIDCol, _ := mustSafeIdentifier(rule.SourceIDColumn)
|
||||
sourceTable, _ := mustSafeIdentifier(rule.SourceTable)
|
||||
|
||||
expr := fmt.Sprintf("GREATEST(0, COALESCE(%s,0) + ?)", usedCol)
|
||||
return tx.Table(sourceTable).
|
||||
Where(fmt.Sprintf("%s = ?", sourceIDCol), sourceID).
|
||||
Update(usedCol, gorm.Expr(expr, delta)).Error
|
||||
}
|
||||
|
||||
func (s *fifoStockV2Service) applyUsableDeltas(tx *gorm.DB, rule routeRule, sourceID uint, usageDelta, pendingDelta float64) error {
|
||||
if sourceID == 0 || (nearlyZero(usageDelta) && nearlyZero(pendingDelta)) {
|
||||
return nil
|
||||
}
|
||||
sourceTable, _ := mustSafeIdentifier(rule.SourceTable)
|
||||
sourceIDCol, _ := mustSafeIdentifier(rule.SourceIDColumn)
|
||||
usageCol, _ := mustSafeIdentifier(rule.QuantityCol)
|
||||
|
||||
updates := map[string]any{}
|
||||
if !nearlyZero(usageDelta) {
|
||||
expr := fmt.Sprintf("GREATEST(0, COALESCE(%s,0) + ?)", usageCol)
|
||||
updates[usageCol] = gorm.Expr(expr, usageDelta)
|
||||
}
|
||||
if rule.PendingQuantityCol != nil && strings.TrimSpace(*rule.PendingQuantityCol) != "" && !nearlyZero(pendingDelta) {
|
||||
pendingCol, _ := mustSafeIdentifier(*rule.PendingQuantityCol)
|
||||
expr := fmt.Sprintf("GREATEST(0, COALESCE(%s,0) + ?)", pendingCol)
|
||||
updates[pendingCol] = gorm.Expr(expr, pendingDelta)
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return tx.Table(sourceTable).
|
||||
Where(fmt.Sprintf("%s = ?", sourceIDCol), sourceID).
|
||||
Updates(updates).Error
|
||||
}
|
||||
|
||||
func (s *fifoStockV2Service) resetUsableQuantities(tx *gorm.DB, rule routeRule, sourceID uint) error {
|
||||
if sourceID == 0 {
|
||||
return nil
|
||||
}
|
||||
sourceTable, _ := mustSafeIdentifier(rule.SourceTable)
|
||||
sourceIDCol, _ := mustSafeIdentifier(rule.SourceIDColumn)
|
||||
usageCol, _ := mustSafeIdentifier(rule.QuantityCol)
|
||||
|
||||
updates := map[string]any{usageCol: 0}
|
||||
if rule.PendingQuantityCol != nil && strings.TrimSpace(*rule.PendingQuantityCol) != "" {
|
||||
pendingCol, _ := mustSafeIdentifier(*rule.PendingQuantityCol)
|
||||
updates[pendingCol] = 0
|
||||
}
|
||||
|
||||
return tx.Table(sourceTable).
|
||||
Where(fmt.Sprintf("%s = ?", sourceIDCol), sourceID).
|
||||
Updates(updates).Error
|
||||
}
|
||||
|
||||
func (s *fifoStockV2Service) resolveRollbackFlagGroup(ctx context.Context, tx *gorm.DB, req RollbackRequest) (string, error) {
|
||||
type row struct {
|
||||
FlagGroupCode string `gorm:"column:flag_group_code"`
|
||||
}
|
||||
var latest row
|
||||
err := tx.WithContext(ctx).
|
||||
Table("stock_allocations").
|
||||
Select("flag_group_code").
|
||||
Where("usable_type = ? AND usable_id = ?", req.Usable.LegacyTypeKey, req.Usable.ID).
|
||||
Where("engine_version = 'v2'").
|
||||
Where("allocation_purpose = ?", defaultAllocationPurpose()).
|
||||
Where("flag_group_code IS NOT NULL AND flag_group_code <> ''").
|
||||
Order("id DESC").
|
||||
Limit(1).
|
||||
Take(&latest).Error
|
||||
if err == nil && strings.TrimSpace(latest.FlagGroupCode) != "" {
|
||||
return latest.FlagGroupCode, nil
|
||||
}
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var rules []routeRule
|
||||
err = tx.WithContext(ctx).
|
||||
Table("fifo_stock_v2_route_rules").
|
||||
Where("is_active = TRUE").
|
||||
Where("lane = ?", string(LaneUsable)).
|
||||
Where("legacy_type_key = ?", req.Usable.LegacyTypeKey).
|
||||
Find(&rules).Error
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(rules) == 0 {
|
||||
return "", fmt.Errorf("cannot resolve flag group for usable type %s", req.Usable.LegacyTypeKey)
|
||||
}
|
||||
if len(rules) > 1 {
|
||||
return "", fmt.Errorf("ambiguous rollback flag group for usable type %s", req.Usable.LegacyTypeKey)
|
||||
}
|
||||
return rules[0].FlagGroupCode, nil
|
||||
}
|
||||
|
||||
func (s *fifoStockV2Service) validateAllocateRequest(req AllocateRequest) error {
|
||||
if strings.TrimSpace(req.FlagGroupCode) == "" || req.ProductWarehouseID == 0 {
|
||||
return fmt.Errorf("%w: missing flag group or product warehouse", ErrInvalidRequest)
|
||||
}
|
||||
if req.Usable.ID == 0 || strings.TrimSpace(req.Usable.LegacyTypeKey) == "" {
|
||||
return fmt.Errorf("%w: usable id and type are required", ErrInvalidRequest)
|
||||
}
|
||||
if req.NeedQty < 0 {
|
||||
return fmt.Errorf("%w: need qty must be >= 0", ErrInvalidRequest)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fifoStockV2Service) validateRollbackRequest(req RollbackRequest) error {
|
||||
if req.ProductWarehouseID == 0 {
|
||||
return fmt.Errorf("%w: product warehouse is required", ErrInvalidRequest)
|
||||
}
|
||||
if req.Usable.ID == 0 || strings.TrimSpace(req.Usable.LegacyTypeKey) == "" {
|
||||
return fmt.Errorf("%w: usable id and type are required", ErrInvalidRequest)
|
||||
}
|
||||
if req.ReleaseQty != nil && *req.ReleaseQty < 0 {
|
||||
return fmt.Errorf("%w: release qty must be >= 0", ErrInvalidRequest)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
package fifo_stock_v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type routeRule struct {
|
||||
ID uint `gorm:"column:id"`
|
||||
FlagGroupCode string `gorm:"column:flag_group_code"`
|
||||
Lane string `gorm:"column:lane"`
|
||||
FunctionCode string `gorm:"column:function_code"`
|
||||
SourceTable string `gorm:"column:source_table"`
|
||||
SourceIDColumn string `gorm:"column:source_id_column"`
|
||||
ProductWarehouseCol string `gorm:"column:product_warehouse_col"`
|
||||
QuantityCol string `gorm:"column:quantity_col"`
|
||||
UsedQuantityCol *string `gorm:"column:used_quantity_col"`
|
||||
PendingQuantityCol *string `gorm:"column:pending_quantity_col"`
|
||||
ScopeSQL *string `gorm:"column:scope_sql"`
|
||||
LegacyTypeKey string `gorm:"column:legacy_type_key"`
|
||||
AllowPendingDefault bool `gorm:"column:allow_pending_default"`
|
||||
}
|
||||
|
||||
type traitRule struct {
|
||||
ID uint `gorm:"column:id"`
|
||||
SourceTable string `gorm:"column:source_table"`
|
||||
Lane string `gorm:"column:lane"`
|
||||
DateTable *string `gorm:"column:date_table"`
|
||||
DateJoinLeftCol *string `gorm:"column:date_join_left_col"`
|
||||
DateJoinRightCol *string `gorm:"column:date_join_right_col"`
|
||||
DateColumn string `gorm:"column:date_column"`
|
||||
FallbackDateColumn *string `gorm:"column:fallback_date_column"`
|
||||
SortPriority int `gorm:"column:sort_priority"`
|
||||
IDColumn string `gorm:"column:id_column"`
|
||||
}
|
||||
|
||||
func (s *fifoStockV2Service) loadRouteRules(ctx context.Context, tx *gorm.DB, flagGroupCode string, lane Lane) ([]routeRule, error) {
|
||||
var rules []routeRule
|
||||
err := tx.WithContext(ctx).
|
||||
Table("fifo_stock_v2_route_rules").
|
||||
Where("is_active = TRUE").
|
||||
Where("flag_group_code = ?", flagGroupCode).
|
||||
Where("lane = ?", string(lane)).
|
||||
Order("id ASC").
|
||||
Find(&rules).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, rule := range rules {
|
||||
if err := validateRouteRule(rule); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
func (s *fifoStockV2Service) loadRouteRuleByLegacyType(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
lane Lane,
|
||||
flagGroupCode string,
|
||||
legacyTypeKey string,
|
||||
) (*routeRule, error) {
|
||||
var rule routeRule
|
||||
err := tx.WithContext(ctx).
|
||||
Table("fifo_stock_v2_route_rules").
|
||||
Where("is_active = TRUE").
|
||||
Where("lane = ?", string(lane)).
|
||||
Where("flag_group_code = ?", flagGroupCode).
|
||||
Where("legacy_type_key = ?", legacyTypeKey).
|
||||
Order("id ASC").
|
||||
Limit(1).
|
||||
Take(&rule).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateRouteRule(rule); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &rule, nil
|
||||
}
|
||||
|
||||
func (s *fifoStockV2Service) loadTraitMap(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
lane Lane,
|
||||
sourceTables []string,
|
||||
) (map[string]traitRule, error) {
|
||||
if len(sourceTables) == 0 {
|
||||
return map[string]traitRule{}, nil
|
||||
}
|
||||
|
||||
var traits []traitRule
|
||||
err := tx.WithContext(ctx).
|
||||
Table("fifo_stock_v2_traits").
|
||||
Where("is_active = TRUE").
|
||||
Where("lane = ?", string(lane)).
|
||||
Where("source_table IN ?", sourceTables).
|
||||
Find(&traits).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make(map[string]traitRule, len(traits))
|
||||
for _, tr := range traits {
|
||||
if err := validateTraitRule(tr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[tr.SourceTable] = tr
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func validateRouteRule(rule routeRule) error {
|
||||
fields := []string{rule.SourceTable, rule.SourceIDColumn, rule.ProductWarehouseCol, rule.QuantityCol}
|
||||
for _, value := range fields {
|
||||
if _, err := mustSafeIdentifier(value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if rule.UsedQuantityCol != nil {
|
||||
if _, err := mustSafeIdentifier(*rule.UsedQuantityCol); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if rule.PendingQuantityCol != nil {
|
||||
if _, err := mustSafeIdentifier(*rule.PendingQuantityCol); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(rule.LegacyTypeKey) == "" {
|
||||
return fmt.Errorf("route rule has empty legacy type key")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateTraitRule(rule traitRule) error {
|
||||
if _, err := mustSafeIdentifier(rule.SourceTable); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := mustSafeIdentifier(rule.DateColumn); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := mustSafeIdentifier(rule.IDColumn); err != nil {
|
||||
return err
|
||||
}
|
||||
if rule.DateTable != nil {
|
||||
if _, err := mustSafeIdentifier(*rule.DateTable); err != nil {
|
||||
return err
|
||||
}
|
||||
if rule.DateJoinLeftCol == nil || rule.DateJoinRightCol == nil {
|
||||
return fmt.Errorf("trait %s requires date join columns", rule.SourceTable)
|
||||
}
|
||||
if _, err := mustSafeIdentifier(*rule.DateJoinLeftCol); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := mustSafeIdentifier(*rule.DateJoinRightCol); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if rule.FallbackDateColumn != nil {
|
||||
if _, err := mustSafeIdentifier(*rule.FallbackDateColumn); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
package fifo_stock_v2
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrInvalidRequest = errors.New("invalid fifo stock v2 request")
|
||||
ErrInsufficientStock = errors.New("insufficient stock")
|
||||
)
|
||||
@@ -1,293 +0,0 @@
|
||||
package fifo_stock_v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type gatherSQLRow struct {
|
||||
SourceTable string `gorm:"column:source_table"`
|
||||
LegacyTypeKey string `gorm:"column:legacy_type_key"`
|
||||
FunctionCode string `gorm:"column:function_code"`
|
||||
SourceID uint `gorm:"column:source_id"`
|
||||
ProductWarehouseID uint `gorm:"column:product_warehouse_id"`
|
||||
SortAt time.Time `gorm:"column:sort_at"`
|
||||
SortPriority int `gorm:"column:sort_priority"`
|
||||
Quantity float64 `gorm:"column:quantity"`
|
||||
UsedQuantity float64 `gorm:"column:used_quantity"`
|
||||
PendingQuantity float64 `gorm:"column:pending_quantity"`
|
||||
AvailableQuantity float64 `gorm:"column:available_quantity"`
|
||||
}
|
||||
|
||||
func (s *fifoStockV2Service) Gather(ctx context.Context, req GatherRequest) ([]GatherRow, error) {
|
||||
if strings.TrimSpace(req.FlagGroupCode) == "" || req.ProductWarehouseID == 0 {
|
||||
return nil, fmt.Errorf("%w: flag group and product warehouse are required", ErrInvalidRequest)
|
||||
}
|
||||
if req.Lane != LaneStockable && req.Lane != LaneUsable {
|
||||
return nil, fmt.Errorf("%w: unsupported lane %q", ErrInvalidRequest, req.Lane)
|
||||
}
|
||||
|
||||
var out []GatherRow
|
||||
err := s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error {
|
||||
rows, err := s.gatherRows(ctx, tx, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out = rows
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *fifoStockV2Service) gatherRows(ctx context.Context, tx *gorm.DB, req GatherRequest) ([]GatherRow, error) {
|
||||
req.AllocationPurpose = normalizeAllocationPurpose(req.AllocationPurpose)
|
||||
|
||||
rules, err := s.loadRouteRules(ctx, tx, req.FlagGroupCode, req.Lane)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(rules) == 0 {
|
||||
return []GatherRow{}, nil
|
||||
}
|
||||
|
||||
tables := make([]string, 0, len(rules))
|
||||
for _, rule := range rules {
|
||||
tables = append(tables, rule.SourceTable)
|
||||
}
|
||||
|
||||
traits, err := s.loadTraitMap(ctx, tx, req.Lane, tables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
subqueries := make([]string, 0, len(rules))
|
||||
args := make([]any, 0, len(rules)*10)
|
||||
|
||||
for _, rule := range rules {
|
||||
trait, ok := traits[rule.SourceTable]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("missing trait for table %s lane %s", rule.SourceTable, req.Lane)
|
||||
}
|
||||
subSQL, subArgs, err := s.buildGatherSubquery(rule, trait, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
subqueries = append(subqueries, subSQL)
|
||||
args = append(args, subArgs...)
|
||||
}
|
||||
|
||||
if len(subqueries) == 0 {
|
||||
return []GatherRow{}, nil
|
||||
}
|
||||
|
||||
limit := req.Limit
|
||||
if limit <= 0 {
|
||||
limit = s.defaultGatherLimit
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 1000
|
||||
}
|
||||
|
||||
query := "SELECT * FROM (" + strings.Join(subqueries, " UNION ALL ") + ") AS g"
|
||||
if req.AfterSortAt != nil {
|
||||
query += `
|
||||
WHERE
|
||||
(g.sort_at > ?)
|
||||
OR (g.sort_at = ? AND g.source_table > ?)
|
||||
OR (g.sort_at = ? AND g.source_table = ? AND g.source_id > ?)
|
||||
`
|
||||
args = append(args,
|
||||
*req.AfterSortAt,
|
||||
*req.AfterSortAt, req.AfterSourceTable,
|
||||
*req.AfterSortAt, req.AfterSourceTable, req.AfterSourceID,
|
||||
)
|
||||
}
|
||||
query += " ORDER BY g.sort_at ASC, g.sort_priority ASC, g.source_table ASC, g.source_id ASC LIMIT ?"
|
||||
args = append(args, limit)
|
||||
|
||||
var rows []gatherSQLRow
|
||||
if err := tx.WithContext(ctx).Raw(query, args...).Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]GatherRow, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, GatherRow{
|
||||
Ref: Ref{
|
||||
Table: row.SourceTable,
|
||||
ID: row.SourceID,
|
||||
LegacyTypeKey: row.LegacyTypeKey,
|
||||
FunctionCode: row.FunctionCode,
|
||||
},
|
||||
FlagGroupCode: req.FlagGroupCode,
|
||||
ProductWarehouseID: row.ProductWarehouseID,
|
||||
SortAt: row.SortAt,
|
||||
SortPriority: row.SortPriority,
|
||||
Quantity: row.Quantity,
|
||||
UsedQuantity: row.UsedQuantity,
|
||||
PendingQuantity: row.PendingQuantity,
|
||||
AvailableQuantity: row.AvailableQuantity,
|
||||
SourceTable: row.SourceTable,
|
||||
SourceID: row.SourceID,
|
||||
})
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *fifoStockV2Service) buildGatherSubquery(rule routeRule, trait traitRule, req GatherRequest) (string, []any, error) {
|
||||
sourceTable, _ := mustSafeIdentifier(rule.SourceTable)
|
||||
sourceIDCol, _ := mustSafeIdentifier(rule.SourceIDColumn)
|
||||
productWarehouseCol, _ := mustSafeIdentifier(rule.ProductWarehouseCol)
|
||||
quantityCol, _ := mustSafeIdentifier(rule.QuantityCol)
|
||||
|
||||
baseQtyExpr := fmt.Sprintf("COALESCE(src.%s,0)::numeric", quantityCol)
|
||||
usedExpr := "0::numeric"
|
||||
pendingExpr := "0::numeric"
|
||||
availableExpr := baseQtyExpr
|
||||
extraArgs := make([]any, 0, 2)
|
||||
whereExtraArgs := make([]any, 0, 1)
|
||||
|
||||
if req.Lane == LaneStockable {
|
||||
if !req.IgnoreSourceUsed && rule.UsedQuantityCol != nil && strings.TrimSpace(*rule.UsedQuantityCol) != "" {
|
||||
usedCol, _ := mustSafeIdentifier(*rule.UsedQuantityCol)
|
||||
usedExpr = fmt.Sprintf("COALESCE(src.%s,0)::numeric", usedCol)
|
||||
} else {
|
||||
// NOTE:
|
||||
// usedExpr is referenced twice in the generated SELECT:
|
||||
// 1) as used_quantity
|
||||
// 2) inside available_quantity = base - usedExpr
|
||||
// plus once in stockable WHERE clause via availableExpr > 0.
|
||||
// We split the args because the WHERE placeholder order appears
|
||||
// after product/flag filter placeholders in the final SQL.
|
||||
usedExpr = fmt.Sprintf(
|
||||
"(SELECT COALESCE(SUM(sa.qty),0)::numeric FROM stock_allocations sa WHERE sa.stockable_type = ? AND sa.stockable_id = src.%s AND sa.status = '%s' AND sa.allocation_purpose = ?)",
|
||||
sourceIDCol,
|
||||
activeAllocationStatus(),
|
||||
)
|
||||
extraArgs = append(extraArgs, rule.LegacyTypeKey, req.AllocationPurpose)
|
||||
extraArgs = append(extraArgs, rule.LegacyTypeKey, req.AllocationPurpose)
|
||||
whereExtraArgs = append(whereExtraArgs, rule.LegacyTypeKey, req.AllocationPurpose)
|
||||
}
|
||||
availableExpr = fmt.Sprintf("(%s - %s)", baseQtyExpr, usedExpr)
|
||||
} else {
|
||||
if rule.PendingQuantityCol != nil && strings.TrimSpace(*rule.PendingQuantityCol) != "" {
|
||||
pendingCol, _ := mustSafeIdentifier(*rule.PendingQuantityCol)
|
||||
pendingExpr = fmt.Sprintf("COALESCE(src.%s,0)::numeric", pendingCol)
|
||||
}
|
||||
availableExpr = baseQtyExpr
|
||||
}
|
||||
|
||||
sortExpr, joinClause, err := buildSortExpr(trait)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
functionCodeExpr := "?::text"
|
||||
functionCodeArgs := []any{rule.FunctionCode}
|
||||
if rule.SourceTable == "adjustment_stocks" {
|
||||
functionCodeExpr = "COALESCE(NULLIF(src.function_code,''), ?::text)"
|
||||
}
|
||||
|
||||
whereParts := []string{
|
||||
fmt.Sprintf("src.%s = ?", productWarehouseCol),
|
||||
fmt.Sprintf(`EXISTS (
|
||||
SELECT 1
|
||||
FROM product_warehouses pw
|
||||
JOIN flags f ON f.flagable_type = ? AND f.flagable_id = pw.product_id
|
||||
JOIN fifo_stock_v2_flag_members fm ON fm.flag_name = f.name AND fm.is_active = TRUE
|
||||
WHERE pw.id = src.%s AND fm.flag_group_code = ?
|
||||
)`, productWarehouseCol),
|
||||
}
|
||||
|
||||
if req.Lane == LaneStockable {
|
||||
whereParts = append(whereParts, fmt.Sprintf("%s > 0", availableExpr))
|
||||
}
|
||||
|
||||
if req.AsOf != nil {
|
||||
whereParts = append(whereParts, fmt.Sprintf("%s <= ?", sortExpr))
|
||||
}
|
||||
if req.From != nil {
|
||||
whereParts = append(whereParts, fmt.Sprintf("%s >= ?", sortExpr))
|
||||
}
|
||||
|
||||
if rule.ScopeSQL != nil && strings.TrimSpace(*rule.ScopeSQL) != "" {
|
||||
whereParts = append(whereParts, fmt.Sprintf("(%s)", normalizeScopeSQL(*rule.ScopeSQL)))
|
||||
}
|
||||
|
||||
subquery := fmt.Sprintf(`
|
||||
SELECT
|
||||
?::text AS source_table,
|
||||
?::text AS legacy_type_key,
|
||||
%s AS function_code,
|
||||
src.%s AS source_id,
|
||||
src.%s AS product_warehouse_id,
|
||||
%s AS sort_at,
|
||||
?::int AS sort_priority,
|
||||
%s AS quantity,
|
||||
%s AS used_quantity,
|
||||
%s AS pending_quantity,
|
||||
%s AS available_quantity
|
||||
FROM %s src
|
||||
%s
|
||||
WHERE %s
|
||||
`, functionCodeExpr, sourceIDCol, productWarehouseCol, sortExpr, baseQtyExpr, usedExpr, pendingExpr, availableExpr, sourceTable, joinClause, strings.Join(whereParts, " AND "))
|
||||
|
||||
args := []any{
|
||||
rule.SourceTable,
|
||||
rule.LegacyTypeKey,
|
||||
}
|
||||
args = append(args, functionCodeArgs...)
|
||||
args = append(args, trait.SortPriority)
|
||||
args = append(args, extraArgs...)
|
||||
args = append(args,
|
||||
req.ProductWarehouseID,
|
||||
entity.FlagableTypeProduct,
|
||||
req.FlagGroupCode,
|
||||
)
|
||||
args = append(args, whereExtraArgs...)
|
||||
|
||||
if req.AsOf != nil {
|
||||
args = append(args, *req.AsOf)
|
||||
}
|
||||
if req.From != nil {
|
||||
args = append(args, *req.From)
|
||||
}
|
||||
|
||||
return subquery, args, nil
|
||||
}
|
||||
|
||||
func buildSortExpr(trait traitRule) (string, string, error) {
|
||||
dateCol, _ := mustSafeIdentifier(trait.DateColumn)
|
||||
idCol, _ := mustSafeIdentifier(trait.IDColumn)
|
||||
_ = idCol
|
||||
|
||||
joinClause := ""
|
||||
sortBase := fmt.Sprintf("src.%s", dateCol)
|
||||
if trait.DateTable != nil && strings.TrimSpace(*trait.DateTable) != "" {
|
||||
dateTable, _ := mustSafeIdentifier(*trait.DateTable)
|
||||
if trait.DateJoinLeftCol == nil || trait.DateJoinRightCol == nil {
|
||||
return "", "", fmt.Errorf("trait %s requires date join columns", trait.SourceTable)
|
||||
}
|
||||
leftCol, _ := mustSafeIdentifier(*trait.DateJoinLeftCol)
|
||||
rightCol, _ := mustSafeIdentifier(*trait.DateJoinRightCol)
|
||||
joinClause = fmt.Sprintf("LEFT JOIN %s dt ON src.%s = dt.%s", dateTable, leftCol, rightCol)
|
||||
sortBase = fmt.Sprintf("dt.%s", dateCol)
|
||||
}
|
||||
|
||||
if trait.FallbackDateColumn != nil && strings.TrimSpace(*trait.FallbackDateColumn) != "" {
|
||||
fallbackCol, _ := mustSafeIdentifier(*trait.FallbackDateColumn)
|
||||
sortBase = fmt.Sprintf("COALESCE(%s, src.%s)", sortBase, fallbackCol)
|
||||
}
|
||||
|
||||
sortExpr := fmt.Sprintf("COALESCE(%s, '1970-01-01 00:00:00+00'::timestamptz)", sortBase)
|
||||
return sortExpr, joinClause, nil
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
package fifo_stock_v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math"
|
||||
"sort"
|
||||
|
||||
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
|
||||
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func ReleasePopulationConsumptionByUsable(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
usableType string,
|
||||
usableID uint,
|
||||
) error {
|
||||
if tx == nil {
|
||||
return errors.New("transaction is required")
|
||||
}
|
||||
if usableType == "" || usableID == 0 {
|
||||
return errors.New("usable type and id are required")
|
||||
}
|
||||
|
||||
stockAllocationRepo := commonRepo.NewStockAllocationRepository(tx)
|
||||
allocations, err := stockAllocationRepo.FindActiveByUsable(ctx, usableType, usableID, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, allocation := range allocations {
|
||||
if allocation.StockableType != fifo.StockableKeyProjectFlockPopulation.String() || allocation.StockableId == 0 || allocation.Qty <= 0 {
|
||||
continue
|
||||
}
|
||||
if err := tx.WithContext(ctx).
|
||||
Model(&entity.ProjectFlockPopulation{}).
|
||||
Where("id = ?", allocation.StockableId).
|
||||
Update("total_used_qty", gorm.Expr("GREATEST(total_used_qty - ?, 0)", allocation.Qty)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return stockAllocationRepo.ReleaseByUsable(ctx, usableType, usableID, nil, nil)
|
||||
}
|
||||
|
||||
func AllocatePopulationConsumption(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
populations []entity.ProjectFlockPopulation,
|
||||
productWarehouseID uint,
|
||||
usableType string,
|
||||
usableID uint,
|
||||
consumeQty float64,
|
||||
) error {
|
||||
if consumeQty <= 0 {
|
||||
return nil
|
||||
}
|
||||
if tx == nil {
|
||||
return errors.New("transaction is required")
|
||||
}
|
||||
if productWarehouseID == 0 {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "Product warehouse tidak valid")
|
||||
}
|
||||
if usableType == "" || usableID == 0 {
|
||||
return errors.New("usable type and id are required")
|
||||
}
|
||||
if len(populations) == 0 {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "Populasi tidak ditemukan")
|
||||
}
|
||||
|
||||
if err := ReleasePopulationConsumptionByUsable(ctx, tx, usableType, usableID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sort.Slice(populations, func(i, j int) bool {
|
||||
if populations[i].CreatedAt.Equal(populations[j].CreatedAt) {
|
||||
return populations[i].Id < populations[j].Id
|
||||
}
|
||||
return populations[i].CreatedAt.Before(populations[j].CreatedAt)
|
||||
})
|
||||
|
||||
stockAllocationRepo := commonRepo.NewStockAllocationRepository(tx)
|
||||
remaining := consumeQty
|
||||
for _, pop := range populations {
|
||||
available := pop.TotalQty - pop.TotalUsedQty
|
||||
if available <= 0 {
|
||||
continue
|
||||
}
|
||||
portion := math.Min(available, remaining)
|
||||
if portion <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
allocation := &entity.StockAllocation{
|
||||
ProductWarehouseId: productWarehouseID,
|
||||
StockableType: fifo.StockableKeyProjectFlockPopulation.String(),
|
||||
StockableId: pop.Id,
|
||||
UsableType: usableType,
|
||||
UsableId: usableID,
|
||||
Qty: portion,
|
||||
Status: entity.StockAllocationStatusActive,
|
||||
AllocationPurpose: entity.StockAllocationPurposeConsume,
|
||||
}
|
||||
if err := stockAllocationRepo.CreateOne(ctx, allocation, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.WithContext(ctx).
|
||||
Model(&entity.ProjectFlockPopulation{}).
|
||||
Where("id = ?", pop.Id).
|
||||
Update("total_used_qty", gorm.Expr("total_used_qty + ?", portion)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
remaining -= portion
|
||||
if remaining <= 1e-6 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if remaining > 1e-6 {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "Populasi tidak mencukupi")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
package fifo_stock_v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func (s *fifoStockV2Service) Recalculate(ctx context.Context, req RecalculateRequest) (*RecalculateResult, error) {
|
||||
result := &RecalculateResult{Drifts: make([]WarehouseDrift, 0)}
|
||||
|
||||
err := s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error {
|
||||
hash := requestHash(map[string]any{
|
||||
"product_warehouse_ids": req.ProductWarehouseIDs,
|
||||
"flag_group_codes": req.FlagGroupCodes,
|
||||
"as_of": req.AsOf,
|
||||
"fix_drift": req.FixDrift,
|
||||
})
|
||||
logRow, reused, err := s.beginOperation(
|
||||
tx,
|
||||
OperationRecalculate,
|
||||
req.IdempotencyKey,
|
||||
hash,
|
||||
0,
|
||||
"RECALCULATE",
|
||||
"",
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if reused {
|
||||
if len(logRow.ResultPayload) == 0 {
|
||||
return fmt.Errorf("idempotent recalculate has empty payload")
|
||||
}
|
||||
if err := json.Unmarshal(logRow.ResultPayload, result); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if logRow != nil {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
s.failOperation(tx, logRow, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
warehouseIDs, err := s.resolveRecalculateWarehouseIDs(ctx, tx, req.ProductWarehouseIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
groupCodes, err := s.resolveRecalculateGroupCodes(ctx, tx, req.FlagGroupCodes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, warehouseID := range warehouseIDs {
|
||||
expected := 0.0
|
||||
for _, flagGroup := range groupCodes {
|
||||
available, calcErr := s.calculateWarehouseAvailableForGroup(ctx, tx, warehouseID, flagGroup, req.AsOf)
|
||||
if calcErr != nil {
|
||||
return calcErr
|
||||
}
|
||||
expected += available
|
||||
}
|
||||
|
||||
actual, actualErr := s.loadWarehouseQty(ctx, tx, warehouseID)
|
||||
if actualErr != nil {
|
||||
return actualErr
|
||||
}
|
||||
|
||||
delta := expected - actual
|
||||
result.Checked++
|
||||
if math.Abs(delta) < 1e-6 {
|
||||
continue
|
||||
}
|
||||
|
||||
drift := WarehouseDrift{
|
||||
ProductWarehouseID: warehouseID,
|
||||
ExpectedQty: expected,
|
||||
ActualQty: actual,
|
||||
Delta: delta,
|
||||
}
|
||||
result.Drifts = append(result.Drifts, drift)
|
||||
|
||||
if req.FixDrift {
|
||||
if err := s.adjustProductWarehouseQty(tx, warehouseID, delta); err != nil {
|
||||
return err
|
||||
}
|
||||
result.Fixed++
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.finishOperation(tx, logRow, result); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *fifoStockV2Service) resolveRecalculateWarehouseIDs(ctx context.Context, tx *gorm.DB, provided []uint) ([]uint, error) {
|
||||
if len(provided) > 0 {
|
||||
return provided, nil
|
||||
}
|
||||
var ids []uint
|
||||
err := tx.WithContext(ctx).Table("product_warehouses").Select("id").Order("id ASC").Scan(&ids).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (s *fifoStockV2Service) resolveRecalculateGroupCodes(ctx context.Context, tx *gorm.DB, provided []string) ([]string, error) {
|
||||
if len(provided) > 0 {
|
||||
return provided, nil
|
||||
}
|
||||
var groups []string
|
||||
err := tx.WithContext(ctx).
|
||||
Table("fifo_stock_v2_flag_groups").
|
||||
Select("code").
|
||||
Where("is_active = TRUE").
|
||||
Order("priority ASC, code ASC").
|
||||
Scan(&groups).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
func (s *fifoStockV2Service) calculateWarehouseAvailableForGroup(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
warehouseID uint,
|
||||
flagGroupCode string,
|
||||
asOf *time.Time,
|
||||
) (float64, error) {
|
||||
rows, err := s.gatherRows(ctx, tx, GatherRequest{
|
||||
FlagGroupCode: flagGroupCode,
|
||||
Lane: LaneStockable,
|
||||
ProductWarehouseID: warehouseID,
|
||||
AsOf: asOf,
|
||||
Limit: 50000,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
total := 0.0
|
||||
for _, row := range rows {
|
||||
total += row.AvailableQuantity
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (s *fifoStockV2Service) loadWarehouseQty(ctx context.Context, tx *gorm.DB, warehouseID uint) (float64, error) {
|
||||
type row struct {
|
||||
Qty float64 `gorm:"column:qty"`
|
||||
}
|
||||
var out row
|
||||
err := tx.WithContext(ctx).
|
||||
Table("product_warehouses").
|
||||
Select("COALESCE(qty,0) AS qty").
|
||||
Where("id = ?", warehouseID).
|
||||
Take(&out).Error
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return out.Qty, nil
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
package fifo_stock_v2
|
||||
|
||||
import "strings"
|
||||
|
||||
func normalizeScopeSQL(scopeSQL string) string {
|
||||
scopeSQL = strings.TrimSpace(scopeSQL)
|
||||
if scopeSQL == "" {
|
||||
return scopeSQL
|
||||
}
|
||||
|
||||
var out strings.Builder
|
||||
out.Grow(len(scopeSQL) + 16)
|
||||
|
||||
inSingleQuote := false
|
||||
inDoubleQuote := false
|
||||
|
||||
for i := 0; i < len(scopeSQL); {
|
||||
ch := scopeSQL[i]
|
||||
|
||||
if inSingleQuote {
|
||||
out.WriteByte(ch)
|
||||
i++
|
||||
if ch == '\'' {
|
||||
if i < len(scopeSQL) && scopeSQL[i] == '\'' {
|
||||
out.WriteByte(scopeSQL[i])
|
||||
i++
|
||||
} else {
|
||||
inSingleQuote = false
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if inDoubleQuote {
|
||||
out.WriteByte(ch)
|
||||
i++
|
||||
if ch == '"' {
|
||||
inDoubleQuote = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if ch == '\'' {
|
||||
inSingleQuote = true
|
||||
out.WriteByte(ch)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
if ch == '"' {
|
||||
inDoubleQuote = true
|
||||
out.WriteByte(ch)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
if isIdentifierStart(ch) {
|
||||
start := i
|
||||
i++
|
||||
for i < len(scopeSQL) && isIdentifierPart(scopeSQL[i]) {
|
||||
i++
|
||||
}
|
||||
|
||||
token := scopeSQL[start:i]
|
||||
if strings.EqualFold(token, "deleted_at") && !hasAliasQualifier(scopeSQL, start) {
|
||||
out.WriteString("src.deleted_at")
|
||||
} else {
|
||||
out.WriteString(token)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
out.WriteByte(ch)
|
||||
i++
|
||||
}
|
||||
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func hasAliasQualifier(scopeSQL string, tokenStart int) bool {
|
||||
for i := tokenStart - 1; i >= 0; i-- {
|
||||
switch scopeSQL[i] {
|
||||
case ' ', '\t', '\n', '\r':
|
||||
continue
|
||||
case '.':
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isIdentifierStart(ch byte) bool {
|
||||
return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || ch == '_'
|
||||
}
|
||||
|
||||
func isIdentifierPart(ch byte) bool {
|
||||
return isIdentifierStart(ch) || (ch >= '0' && ch <= '9')
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
package fifo_stock_v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"math"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var identifierPattern = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
|
||||
|
||||
type fifoStockV2Service struct {
|
||||
db *gorm.DB
|
||||
logger *logrus.Logger
|
||||
defaultGatherLimit int
|
||||
}
|
||||
|
||||
func NewService(db *gorm.DB, logger *logrus.Logger) Service {
|
||||
if logger == nil {
|
||||
logger = logrus.StandardLogger()
|
||||
}
|
||||
|
||||
return &fifoStockV2Service{
|
||||
db: db,
|
||||
logger: logger,
|
||||
defaultGatherLimit: 1000,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *fifoStockV2Service) withTransaction(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
fn func(*gorm.DB) error,
|
||||
) error {
|
||||
if tx != nil {
|
||||
return fn(tx.WithContext(ctx))
|
||||
}
|
||||
return s.db.WithContext(ctx).Transaction(func(inner *gorm.DB) error {
|
||||
return fn(inner)
|
||||
})
|
||||
}
|
||||
|
||||
func isSafeIdentifier(v string) bool {
|
||||
return identifierPattern.MatchString(strings.TrimSpace(v))
|
||||
}
|
||||
|
||||
func mustSafeIdentifier(v string) (string, error) {
|
||||
v = strings.TrimSpace(v)
|
||||
if !isSafeIdentifier(v) {
|
||||
return "", fmt.Errorf("unsafe identifier: %s", v)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func requestHash(v any) string {
|
||||
payload, _ := json.Marshal(v)
|
||||
sum := sha256.Sum256(payload)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func shardLockKey(flagGroupCode string, productWarehouseID uint) int64 {
|
||||
h := fnv.New64a()
|
||||
_, _ = h.Write([]byte(strings.TrimSpace(strings.ToUpper(flagGroupCode))))
|
||||
_, _ = h.Write([]byte("|"))
|
||||
_, _ = h.Write([]byte(fmt.Sprintf("%d", productWarehouseID)))
|
||||
return int64(h.Sum64())
|
||||
}
|
||||
|
||||
func (s *fifoStockV2Service) lockShard(tx *gorm.DB, flagGroupCode string, productWarehouseID uint) error {
|
||||
if strings.TrimSpace(flagGroupCode) == "" || productWarehouseID == 0 {
|
||||
return fmt.Errorf("lock shard requires flag group and product warehouse")
|
||||
}
|
||||
return tx.Exec("SELECT pg_advisory_xact_lock(?)", shardLockKey(flagGroupCode, productWarehouseID)).Error
|
||||
}
|
||||
|
||||
type operationLogRow struct {
|
||||
ID uint `gorm:"column:id"`
|
||||
Status string `gorm:"column:status"`
|
||||
RequestHash string `gorm:"column:request_hash"`
|
||||
ResultPayload json.RawMessage `gorm:"column:result_payload"`
|
||||
}
|
||||
|
||||
func (s *fifoStockV2Service) beginOperation(
|
||||
tx *gorm.DB,
|
||||
op Operation,
|
||||
idempotencyKey string,
|
||||
requestHashValue string,
|
||||
productWarehouseID uint,
|
||||
flagGroupCode string,
|
||||
usableType string,
|
||||
usableID uint,
|
||||
) (*operationLogRow, bool, error) {
|
||||
if strings.TrimSpace(idempotencyKey) == "" {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
inserted := operationLogRow{}
|
||||
insertSQL := `
|
||||
INSERT INTO fifo_stock_v2_operation_log
|
||||
(idempotency_key, operation, product_warehouse_id, flag_group_code, usable_type, usable_id, request_hash, status, created_at)
|
||||
VALUES (?, ?, ?, ?, NULLIF(?, ''), NULLIF(?, 0), ?, 'RUNNING', NOW())
|
||||
ON CONFLICT (idempotency_key, operation) DO NOTHING
|
||||
RETURNING id, status, request_hash
|
||||
`
|
||||
if err := tx.Raw(insertSQL,
|
||||
idempotencyKey,
|
||||
string(op),
|
||||
productWarehouseID,
|
||||
flagGroupCode,
|
||||
usableType,
|
||||
usableID,
|
||||
requestHashValue,
|
||||
).Scan(&inserted).Error; err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if inserted.ID != 0 {
|
||||
return &inserted, false, nil
|
||||
}
|
||||
|
||||
existing := operationLogRow{}
|
||||
if err := tx.Table("fifo_stock_v2_operation_log").
|
||||
Select("id, status, request_hash, result_payload").
|
||||
Where("idempotency_key = ? AND operation = ?", idempotencyKey, string(op)).
|
||||
Take(&existing).Error; err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
if existing.RequestHash != requestHashValue {
|
||||
return nil, false, fmt.Errorf("idempotency key %s reused with different payload", idempotencyKey)
|
||||
}
|
||||
|
||||
switch strings.ToUpper(existing.Status) {
|
||||
case "DONE":
|
||||
return &existing, true, nil
|
||||
case "RUNNING":
|
||||
return nil, false, fmt.Errorf("operation %s with idempotency key %s is still running", op, idempotencyKey)
|
||||
case "FAILED":
|
||||
if err := tx.Table("fifo_stock_v2_operation_log").
|
||||
Where("id = ?", existing.ID).
|
||||
Updates(map[string]any{
|
||||
"status": "RUNNING",
|
||||
"error_text": nil,
|
||||
"finished_at": nil,
|
||||
}).Error; err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
existing.Status = "RUNNING"
|
||||
return &existing, false, nil
|
||||
default:
|
||||
return nil, false, fmt.Errorf("unknown operation status: %s", existing.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *fifoStockV2Service) finishOperation(tx *gorm.DB, logRow *operationLogRow, payload any) error {
|
||||
if logRow == nil || logRow.ID == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Table("fifo_stock_v2_operation_log").
|
||||
Where("id = ?", logRow.ID).
|
||||
Updates(map[string]any{
|
||||
"status": "DONE",
|
||||
"result_payload": encoded,
|
||||
"finished_at": gorm.Expr("NOW()"),
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (s *fifoStockV2Service) failOperation(tx *gorm.DB, logRow *operationLogRow, failure error) {
|
||||
if logRow == nil || logRow.ID == 0 || failure == nil {
|
||||
return
|
||||
}
|
||||
_ = tx.Table("fifo_stock_v2_operation_log").
|
||||
Where("id = ?", logRow.ID).
|
||||
Updates(map[string]any{
|
||||
"status": "FAILED",
|
||||
"error_text": failure.Error(),
|
||||
"finished_at": gorm.Expr("NOW()"),
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (s *fifoStockV2Service) resolveOverConsume(
|
||||
tx *gorm.DB,
|
||||
flagGroupCode string,
|
||||
functionCode string,
|
||||
lane Lane,
|
||||
defaultValue bool,
|
||||
) (bool, error) {
|
||||
type row struct {
|
||||
Allow bool `gorm:"column:allow_overconsume"`
|
||||
}
|
||||
selected := row{}
|
||||
err := tx.Table("fifo_stock_v2_overconsume_rules").
|
||||
Select("allow_overconsume").
|
||||
Where("is_active = TRUE").
|
||||
Where("lane = ?", string(lane)).
|
||||
Where("(flag_group_code IS NULL OR flag_group_code = ?)", flagGroupCode).
|
||||
Where("(function_code IS NULL OR function_code = ?)", functionCode).
|
||||
Order("CASE WHEN flag_group_code IS NULL THEN 1 ELSE 0 END ASC").
|
||||
Order("CASE WHEN function_code IS NULL THEN 1 ELSE 0 END ASC").
|
||||
Order("priority ASC, id ASC").
|
||||
Limit(1).
|
||||
Take(&selected).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return defaultValue, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return selected.Allow, nil
|
||||
}
|
||||
|
||||
func (s *fifoStockV2Service) adjustProductWarehouseQty(tx *gorm.DB, productWarehouseID uint, delta float64) error {
|
||||
if productWarehouseID == 0 || delta == 0 {
|
||||
return nil
|
||||
}
|
||||
return tx.Table("product_warehouses").
|
||||
Where("id = ?", productWarehouseID).
|
||||
Update("qty", gorm.Expr("COALESCE(qty,0) + ?", delta)).Error
|
||||
}
|
||||
|
||||
func nearlyZero(v float64) bool {
|
||||
return math.Abs(v) < 1e-6
|
||||
}
|
||||
|
||||
func (s *fifoStockV2Service) ensureStockAllocationColumns(tx *gorm.DB) error {
|
||||
checkCols := []string{"engine_version", "flag_group_code", "function_code", "idempotency_key", "allocation_purpose"}
|
||||
for _, col := range checkCols {
|
||||
var count int64
|
||||
err := tx.Raw(`
|
||||
SELECT COUNT(1)
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = 'stock_allocations' AND column_name = ?
|
||||
`, col).Scan(&count).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return fmt.Errorf("stock_allocations.%s does not exist, run fifo_stock_v2 migration first", col)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func activeAllocationStatus() string {
|
||||
return entity.StockAllocationStatusActive
|
||||
}
|
||||
|
||||
func releasedAllocationStatus() string {
|
||||
return entity.StockAllocationStatusReleased
|
||||
}
|
||||
|
||||
func defaultAllocationPurpose() string {
|
||||
return entity.StockAllocationPurposeConsume
|
||||
}
|
||||
|
||||
func normalizeAllocationPurpose(purpose string) string {
|
||||
purpose = strings.TrimSpace(strings.ToUpper(purpose))
|
||||
if purpose == "" {
|
||||
return defaultAllocationPurpose()
|
||||
}
|
||||
return purpose
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
package fifo_stock_v2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Lane string
|
||||
|
||||
const (
|
||||
LaneStockable Lane = "STOCKABLE"
|
||||
LaneUsable Lane = "USABLE"
|
||||
)
|
||||
|
||||
type Operation string
|
||||
|
||||
const (
|
||||
OperationAllocate Operation = "ALLOCATE"
|
||||
OperationRollback Operation = "ROLLBACK"
|
||||
OperationReflow Operation = "REFLOW"
|
||||
OperationRecalculate Operation = "RECALCULATE"
|
||||
)
|
||||
|
||||
type Ref struct {
|
||||
Table string
|
||||
ID uint
|
||||
LegacyTypeKey string
|
||||
FunctionCode string
|
||||
}
|
||||
|
||||
type GatherRequest struct {
|
||||
FlagGroupCode string
|
||||
Lane Lane
|
||||
AllocationPurpose string
|
||||
IgnoreSourceUsed bool
|
||||
ProductWarehouseID uint
|
||||
From *time.Time
|
||||
AsOf *time.Time
|
||||
Limit int
|
||||
AfterSortAt *time.Time
|
||||
AfterSourceTable string
|
||||
AfterSourceID uint
|
||||
ForUpdate bool
|
||||
Tx *gorm.DB
|
||||
}
|
||||
|
||||
type GatherRow struct {
|
||||
Ref Ref
|
||||
FlagGroupCode string
|
||||
ProductWarehouseID uint
|
||||
SortAt time.Time
|
||||
SortPriority int
|
||||
Quantity float64
|
||||
UsedQuantity float64
|
||||
PendingQuantity float64
|
||||
AvailableQuantity float64
|
||||
SourceTable string
|
||||
SourceID uint
|
||||
}
|
||||
|
||||
type AllocateRequest struct {
|
||||
FlagGroupCode string
|
||||
ProductWarehouseID uint
|
||||
Usable Ref
|
||||
NeedQty float64
|
||||
AllowOverConsume *bool
|
||||
IdempotencyKey string
|
||||
AsOf *time.Time
|
||||
Tx *gorm.DB
|
||||
}
|
||||
|
||||
type AllocationDetail struct {
|
||||
StockableType string
|
||||
StockableID uint
|
||||
Qty float64
|
||||
SortAt time.Time
|
||||
}
|
||||
|
||||
type AllocateResult struct {
|
||||
AllocatedQty float64
|
||||
PendingQty float64
|
||||
Details []AllocationDetail
|
||||
}
|
||||
|
||||
type RollbackRequest struct {
|
||||
ProductWarehouseID uint
|
||||
Usable Ref
|
||||
ReleaseQty *float64
|
||||
Reason string
|
||||
IdempotencyKey string
|
||||
Tx *gorm.DB
|
||||
}
|
||||
|
||||
type RollbackResult struct {
|
||||
ReleasedQty float64
|
||||
Details []AllocationDetail
|
||||
}
|
||||
|
||||
type ReflowRequest struct {
|
||||
FlagGroupCode string
|
||||
ProductWarehouseID uint
|
||||
AsOf *time.Time
|
||||
IdempotencyKey string
|
||||
Tx *gorm.DB
|
||||
}
|
||||
|
||||
type ReflowResult struct {
|
||||
ProcessedUsables int
|
||||
Rollback RollbackResult
|
||||
Allocate AllocateResult
|
||||
}
|
||||
|
||||
type RecalculateRequest struct {
|
||||
ProductWarehouseIDs []uint
|
||||
FlagGroupCodes []string
|
||||
AsOf *time.Time
|
||||
FixDrift bool
|
||||
IdempotencyKey string
|
||||
Tx *gorm.DB
|
||||
}
|
||||
|
||||
type WarehouseDrift struct {
|
||||
ProductWarehouseID uint
|
||||
ExpectedQty float64
|
||||
ActualQty float64
|
||||
Delta float64
|
||||
}
|
||||
|
||||
type RecalculateResult struct {
|
||||
Checked int
|
||||
Fixed int
|
||||
Drifts []WarehouseDrift
|
||||
}
|
||||
|
||||
type Service interface {
|
||||
Gather(ctx context.Context, req GatherRequest) ([]GatherRow, error)
|
||||
Allocate(ctx context.Context, req AllocateRequest) (*AllocateResult, error)
|
||||
Rollback(ctx context.Context, req RollbackRequest) (*RollbackResult, error)
|
||||
Reflow(ctx context.Context, req ReflowRequest) (*ReflowResult, error)
|
||||
Recalculate(ctx context.Context, req RecalculateRequest) (*RecalculateResult, error)
|
||||
}
|
||||
+53
-81
@@ -22,61 +22,58 @@ type SSOClientConfig struct {
|
||||
}
|
||||
|
||||
var (
|
||||
IsProd bool
|
||||
AppHost string
|
||||
Version string
|
||||
LogLevel string
|
||||
AppPort int
|
||||
DBHost string
|
||||
DBUser string
|
||||
DBPassword string
|
||||
DBName string
|
||||
DBPort int
|
||||
DBSSLMode string
|
||||
DBSSLRootCert string
|
||||
DBSSLCert string
|
||||
DBSSLKey string
|
||||
JWTSecret string
|
||||
JWTAccessExp int
|
||||
JWTRefreshExp int
|
||||
JWTResetPasswordExp int
|
||||
JWTVerifyEmailExp int
|
||||
RedisURL string
|
||||
CORSAllowOrigins []string
|
||||
CORSAllowMethods []string
|
||||
CORSAllowHeaders []string
|
||||
CORSExposeHeaders []string
|
||||
CORSAllowCredentials bool
|
||||
CORSMaxAge int
|
||||
SSOIssuer string
|
||||
SSOJWKSURL string
|
||||
SSOAllowedAudiences []string
|
||||
SSOAuthorizeURL string
|
||||
SSOTokenURL string
|
||||
SSOGetMeURL string
|
||||
SSOPortalURL string
|
||||
SSOClients map[string]SSOClientConfig
|
||||
SSOAccessCookieName string
|
||||
SSORefreshCookieName string
|
||||
SSOCookieDomain string
|
||||
SSOCookieSecure bool
|
||||
SSOCookieSameSite string
|
||||
SSOAccessTokenMaxBytes int
|
||||
SSOTokenBlacklistPrefix string
|
||||
SSOPKCETTL time.Duration
|
||||
SSOUserSyncDrift time.Duration
|
||||
SSOUserSyncNonceTTL time.Duration
|
||||
SSOUserSyncMaxBodyBytes int
|
||||
S3Endpoint string
|
||||
S3Region string
|
||||
S3Bucket string
|
||||
S3AccessKey string
|
||||
S3SecretKey string
|
||||
S3ForcePathStyle bool
|
||||
S3PublicBaseURL string
|
||||
S3EnvPrefix string
|
||||
S3DocumentKeyPrefix string
|
||||
TransferToLayingGrowingMaxWeek int
|
||||
IsProd bool
|
||||
AppHost string
|
||||
Version string
|
||||
LogLevel string
|
||||
AppPort int
|
||||
DBHost string
|
||||
DBUser string
|
||||
DBPassword string
|
||||
DBName string
|
||||
DBPort int
|
||||
DBSSLMode string
|
||||
DBSSLRootCert string
|
||||
DBSSLCert string
|
||||
DBSSLKey string
|
||||
JWTSecret string
|
||||
JWTAccessExp int
|
||||
JWTRefreshExp int
|
||||
JWTResetPasswordExp int
|
||||
JWTVerifyEmailExp int
|
||||
RedisURL string
|
||||
CORSAllowOrigins []string
|
||||
CORSAllowMethods []string
|
||||
CORSAllowHeaders []string
|
||||
CORSExposeHeaders []string
|
||||
CORSAllowCredentials bool
|
||||
CORSMaxAge int
|
||||
SSOIssuer string
|
||||
SSOJWKSURL string
|
||||
SSOAllowedAudiences []string
|
||||
SSOAuthorizeURL string
|
||||
SSOTokenURL string
|
||||
SSOGetMeURL string
|
||||
SSOPortalURL string
|
||||
SSOClients map[string]SSOClientConfig
|
||||
SSOAccessCookieName string
|
||||
SSORefreshCookieName string
|
||||
SSOCookieDomain string
|
||||
SSOCookieSecure bool
|
||||
SSOCookieSameSite string
|
||||
SSOTokenBlacklistPrefix string
|
||||
SSOPKCETTL time.Duration
|
||||
SSOUserSyncDrift time.Duration
|
||||
SSOUserSyncNonceTTL time.Duration
|
||||
SSOUserSyncMaxBodyBytes int
|
||||
S3Endpoint string
|
||||
S3Region string
|
||||
S3Bucket string
|
||||
S3AccessKey string
|
||||
S3SecretKey string
|
||||
S3ForcePathStyle bool
|
||||
S3PublicBaseURL string
|
||||
S3DocumentKeyPrefix string
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -118,11 +115,6 @@ func init() {
|
||||
// Redis
|
||||
RedisURL = viper.GetString("REDIS_URL")
|
||||
|
||||
TransferToLayingGrowingMaxWeek = viper.GetInt("TRANSFER_TO_LAYING_GROWING_MAX_WEEK")
|
||||
if TransferToLayingGrowingMaxWeek <= 0 {
|
||||
TransferToLayingGrowingMaxWeek = 19
|
||||
}
|
||||
|
||||
// Object storage
|
||||
S3Endpoint = strings.TrimSpace(viper.GetString("S3_ENDPOINT"))
|
||||
S3Region = strings.TrimSpace(viper.GetString("S3_REGION"))
|
||||
@@ -131,12 +123,7 @@ func init() {
|
||||
S3SecretKey = strings.TrimSpace(viper.GetString("S3_SECRET_KEY"))
|
||||
S3ForcePathStyle = viper.GetBool("S3_FORCE_PATH_STYLE")
|
||||
S3PublicBaseURL = strings.TrimSuffix(strings.TrimSpace(viper.GetString("S3_PUBLIC_BASE_URL")), "/")
|
||||
S3EnvPrefix = defaultString(strings.Trim(strings.TrimSpace(viper.GetString("S3_ENV_PREFIX")), "/"), "local")
|
||||
docPrefix := strings.Trim(strings.TrimSpace(viper.GetString("S3_DOCUMENT_PREFIX")), "/")
|
||||
if docPrefix == "" {
|
||||
docPrefix = "docs"
|
||||
}
|
||||
S3DocumentKeyPrefix = joinPath(S3EnvPrefix, docPrefix)
|
||||
S3DocumentKeyPrefix = defaultString(strings.Trim(strings.TrimSpace(viper.GetString("S3_DOCUMENT_PREFIX")), "/"), "docs")
|
||||
|
||||
// SSO integration
|
||||
SSOIssuer = viper.GetString("SSO_ISSUER")
|
||||
@@ -151,10 +138,6 @@ func init() {
|
||||
SSOCookieDomain = viper.GetString("SSO_COOKIE_DOMAIN")
|
||||
SSOCookieSecure = viper.GetBool("SSO_COOKIE_SECURE")
|
||||
SSOCookieSameSite = defaultString(viper.GetString("SSO_COOKIE_SAMESITE"), "Lax")
|
||||
SSOAccessTokenMaxBytes = viper.GetInt("SSO_ACCESS_TOKEN_MAX_BYTES")
|
||||
if SSOAccessTokenMaxBytes <= 0 {
|
||||
SSOAccessTokenMaxBytes = 4096
|
||||
}
|
||||
SSOTokenBlacklistPrefix = defaultString(viper.GetString("SSO_TOKEN_BLACKLIST_PREFIX"), "sso:blacklist")
|
||||
if ttl := viper.GetInt("SSO_PKCE_TTL_SECONDS"); ttl > 0 {
|
||||
SSOPKCETTL = time.Duration(ttl) * time.Second
|
||||
@@ -259,17 +242,6 @@ func defaultString(v, def string) string {
|
||||
return v
|
||||
}
|
||||
|
||||
func joinPath(parts ...string) string {
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.Trim(part, "/")
|
||||
if part != "" {
|
||||
out = append(out, part)
|
||||
}
|
||||
}
|
||||
return strings.Join(out, "/")
|
||||
}
|
||||
|
||||
func ensureProdConfig() {
|
||||
if SSOAuthorizeURL == "" || !strings.HasPrefix(SSOAuthorizeURL, "https://") {
|
||||
panic("SSO_AUTHORIZE_URL must be https in production")
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
ALTER TABLE recording_eggs
|
||||
DROP COLUMN IF EXISTS total_used,
|
||||
DROP COLUMN IF EXISTS total_qty;
|
||||
@@ -1,7 +0,0 @@
|
||||
ALTER TABLE recording_eggs
|
||||
ADD COLUMN total_qty NUMERIC(15, 3) DEFAULT 0 NOT NULL,
|
||||
ADD COLUMN total_used NUMERIC(15, 3) DEFAULT 0 NOT NULL;
|
||||
|
||||
UPDATE recording_eggs
|
||||
SET total_qty = qty
|
||||
WHERE total_qty = 0;
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
-- Rollback: add price back to nonstock_suppliers
|
||||
ALTER TABLE nonstock_suppliers
|
||||
ADD COLUMN IF NOT EXISTS price NUMERIC(15, 3) NOT NULL DEFAULT 0;
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
-- Migration: remove price from nonstock_suppliers
|
||||
ALTER TABLE nonstock_suppliers
|
||||
DROP COLUMN IF EXISTS price;
|
||||
-4
@@ -1,4 +0,0 @@
|
||||
-- Rollback: Remove requested_qty column from laying_transfer_sources table
|
||||
|
||||
ALTER TABLE laying_transfer_sources
|
||||
DROP COLUMN IF EXISTS requested_qty;
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
-- Add requested_qty column to laying_transfer_sources table
|
||||
-- This field stores the quantity requested by user during create/update
|
||||
-- Separate from UsageQty (FIFO consumed) and PendingUsageQty (FIFO pending)
|
||||
|
||||
ALTER TABLE laying_transfer_sources
|
||||
ADD COLUMN requested_qty NUMERIC(15,3) DEFAULT 0 NOT NULL;
|
||||
|
||||
-- Add comment for documentation
|
||||
COMMENT ON COLUMN laying_transfer_sources.requested_qty IS 'Quantity requested by user during create/update';
|
||||
@@ -1,3 +0,0 @@
|
||||
ALTER TABLE recording_depletions
|
||||
DROP COLUMN IF EXISTS pending_qty,
|
||||
DROP COLUMN IF EXISTS source_product_warehouse_id;
|
||||
@@ -1,17 +0,0 @@
|
||||
ALTER TABLE recording_depletions
|
||||
ADD COLUMN IF NOT EXISTS pending_qty numeric(15,3) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS source_product_warehouse_id bigint;
|
||||
|
||||
UPDATE recording_depletions rd
|
||||
SET source_product_warehouse_id = src.product_warehouse_id
|
||||
FROM recordings r
|
||||
JOIN LATERAL (
|
||||
SELECT pfp.product_warehouse_id
|
||||
FROM project_chickins pc
|
||||
JOIN project_flock_populations pfp ON pfp.project_chickin_id = pc.id
|
||||
WHERE pc.project_flock_kandang_id = r.project_flock_kandangs_id
|
||||
ORDER BY pfp.created_at ASC, pfp.id ASC
|
||||
LIMIT 1
|
||||
) AS src ON true
|
||||
WHERE r.id = rd.recording_id
|
||||
AND rd.source_product_warehouse_id IS NULL;
|
||||
@@ -1,6 +0,0 @@
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE payments
|
||||
ALTER COLUMN bank_id SET NOT NULL;
|
||||
|
||||
COMMIT;
|
||||
@@ -1,6 +0,0 @@
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE payments
|
||||
ALTER COLUMN bank_id DROP NOT NULL;
|
||||
|
||||
COMMIT;
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
ALTER TABLE adjustment_stocks ADD COLUMN stock_log_id INTEGER;
|
||||
|
||||
CREATE INDEX idx_adjustment_stocks_stock_log_id ON adjustment_stocks (stock_log_id);
|
||||
-1
@@ -1 +0,0 @@
|
||||
ALTER TABLE adjustment_stocks DROP COLUMN IF EXISTS stock_log_id;
|
||||
-56
@@ -1,56 +0,0 @@
|
||||
BEGIN;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
t text;
|
||||
seq_name text;
|
||||
BEGIN
|
||||
FOREACH t IN ARRAY ARRAY[
|
||||
'daily_checklist_activity_task_assignments',
|
||||
'daily_checklist_activity_tasks',
|
||||
'daily_checklist_phases',
|
||||
'daily_checklist_tasks',
|
||||
'daily_checklists',
|
||||
'employee_kandangs',
|
||||
'employees',
|
||||
'phase_activities',
|
||||
'phases'
|
||||
]
|
||||
LOOP
|
||||
-- Sequence name convention
|
||||
seq_name := format('public.%I_id_seq', t);
|
||||
|
||||
-- 1) Drop default nextval (bigserial behavior)
|
||||
EXECUTE format(
|
||||
'ALTER TABLE public.%I ALTER COLUMN id DROP DEFAULT',
|
||||
t
|
||||
);
|
||||
|
||||
-- 2) Add IDENTITY back (BY DEFAULT is safer for rollback)
|
||||
EXECUTE format(
|
||||
'ALTER TABLE public.%I ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY',
|
||||
t
|
||||
);
|
||||
|
||||
-- 3) Detach & optionally drop sequence (safe)
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM pg_class
|
||||
WHERE relkind = 'S'
|
||||
AND relname = t || '_id_seq'
|
||||
) THEN
|
||||
EXECUTE format(
|
||||
'ALTER SEQUENCE %s OWNED BY NONE',
|
||||
seq_name
|
||||
);
|
||||
|
||||
-- Optional: drop sequence (comment if you want to keep it)
|
||||
EXECUTE format(
|
||||
'DROP SEQUENCE IF EXISTS %s',
|
||||
seq_name
|
||||
);
|
||||
END IF;
|
||||
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
-59
@@ -1,59 +0,0 @@
|
||||
BEGIN;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
t text;
|
||||
seq_name text;
|
||||
max_id bigint;
|
||||
BEGIN
|
||||
FOREACH t IN ARRAY ARRAY[
|
||||
'daily_checklist_activity_task_assignments',
|
||||
'daily_checklist_activity_tasks',
|
||||
'daily_checklist_phases',
|
||||
'daily_checklist_tasks',
|
||||
'daily_checklists',
|
||||
'employee_kandangs',
|
||||
'employees',
|
||||
'phase_activities',
|
||||
'phases'
|
||||
]
|
||||
LOOP
|
||||
-- Sequence name convention: public.<table>_id_seq
|
||||
seq_name := format('public.%I_id_seq', t);
|
||||
|
||||
-- Drop IDENTITY only if the column is identity (safe to re-run)
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = t
|
||||
AND column_name = 'id'
|
||||
AND is_identity = 'YES'
|
||||
) THEN
|
||||
EXECUTE format('ALTER TABLE public.%I ALTER COLUMN id DROP IDENTITY', t);
|
||||
END IF;
|
||||
|
||||
-- Ensure sequence exists
|
||||
EXECUTE format('CREATE SEQUENCE IF NOT EXISTS %s', seq_name);
|
||||
|
||||
-- Set default like bigserial
|
||||
EXECUTE format(
|
||||
'ALTER TABLE public.%I ALTER COLUMN id SET DEFAULT nextval(''%s'')',
|
||||
t, seq_name
|
||||
);
|
||||
|
||||
-- Own the sequence by the column
|
||||
EXECUTE format(
|
||||
'ALTER SEQUENCE %s OWNED BY public.%I.id',
|
||||
seq_name, t
|
||||
);
|
||||
|
||||
-- Sync sequence to MAX(id) + 1 to avoid duplicate key
|
||||
EXECUTE format('SELECT COALESCE(MAX(id), 0) FROM public.%I', t) INTO max_id;
|
||||
|
||||
EXECUTE format('SELECT setval(''%s'', $1, false)', seq_name)
|
||||
USING (max_id + 1);
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE stock_logs
|
||||
DROP COLUMN stock;
|
||||
@@ -1,18 +0,0 @@
|
||||
ALTER TABLE stock_logs
|
||||
ADD COLUMN stock NUMERIC(15, 3) NOT NULL DEFAULT 0;
|
||||
|
||||
WITH calc AS (
|
||||
SELECT
|
||||
id,
|
||||
SUM(COALESCE(increase, 0) - COALESCE(decrease, 0))
|
||||
OVER (
|
||||
PARTITION BY product_warehouse_id
|
||||
ORDER BY id
|
||||
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
|
||||
) AS running_stock
|
||||
FROM stock_logs
|
||||
)
|
||||
UPDATE stock_logs t
|
||||
SET stock = c.running_stock
|
||||
FROM calc c
|
||||
WHERE t.id = c.id;
|
||||
@@ -1,7 +0,0 @@
|
||||
BEGIN;
|
||||
|
||||
-- Migration: revert documents.path length
|
||||
ALTER TABLE documents
|
||||
ALTER COLUMN path TYPE VARCHAR(50);
|
||||
|
||||
COMMIT;
|
||||
@@ -1,7 +0,0 @@
|
||||
BEGIN;
|
||||
|
||||
-- Migration: extend documents.path length for environment prefixes
|
||||
ALTER TABLE documents
|
||||
ALTER COLUMN path TYPE VARCHAR(255);
|
||||
|
||||
COMMIT;
|
||||
@@ -1,2 +0,0 @@
|
||||
-- Drop transfer laying sequence
|
||||
DROP SEQUENCE IF EXISTS transfer_laying_seq;
|
||||
@@ -1,33 +0,0 @@
|
||||
-- Create sequence for transfer laying movement number
|
||||
CREATE SEQUENCE IF NOT EXISTS transfer_laying_seq START
|
||||
WITH
|
||||
1 INCREMENT BY 1 MINVALUE 1 MAXVALUE 99999 NO CYCLE;
|
||||
|
||||
-- Set sequence starting value based on existing data (if any)
|
||||
-- This prevents duplicate movement numbers if there's already data
|
||||
DO $$ DECLARE max_existing INTEGER;
|
||||
|
||||
BEGIN
|
||||
-- Check if table exists and has data
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.tables
|
||||
WHERE
|
||||
table_schema = 'public'
|
||||
AND table_name = 'transfer_to_layings'
|
||||
) THEN
|
||||
-- Get max ID from existing records
|
||||
SELECT COALESCE(MAX(id), 0) INTO max_existing
|
||||
FROM transfer_to_layings;
|
||||
|
||||
-- Set sequence to start after the highest existing ID
|
||||
IF max_existing > 0 THEN PERFORM setval (
|
||||
'transfer_laying_seq',
|
||||
max_existing
|
||||
);
|
||||
|
||||
END IF;
|
||||
|
||||
END IF;
|
||||
|
||||
END $$;
|
||||
@@ -1,6 +0,0 @@
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE adjustment_stocks
|
||||
DROP COLUMN adj_number;
|
||||
|
||||
COMMIT;
|
||||
@@ -1,10 +0,0 @@
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE adjustment_stocks
|
||||
ADD COLUMN adj_number VARCHAR(255);
|
||||
|
||||
UPDATE adjustment_stocks
|
||||
SET adj_number = CONCAT('ADJ-', LPAD(id::text, 5, '0'))
|
||||
WHERE adj_number IS NULL;
|
||||
|
||||
COMMIT;
|
||||
@@ -1,8 +0,0 @@
|
||||
-- Remove columns from marketing_products
|
||||
ALTER TABLE marketing_products
|
||||
DROP COLUMN IF EXISTS week,
|
||||
DROP COLUMN IF EXISTS weight_per_convertion,
|
||||
DROP COLUMN IF EXISTS convertion_unit;
|
||||
|
||||
-- Remove column from marketings
|
||||
ALTER TABLE marketings DROP COLUMN IF EXISTS marketing_type;
|
||||
@@ -1,9 +0,0 @@
|
||||
-- Add marketing_type to marketings table
|
||||
ALTER TABLE marketings
|
||||
ADD COLUMN IF NOT EXISTS marketing_type VARCHAR(50);
|
||||
|
||||
-- Add convertion fields to marketing_products table
|
||||
ALTER TABLE marketing_products
|
||||
ADD COLUMN IF NOT EXISTS convertion_unit VARCHAR(20),
|
||||
ADD COLUMN IF NOT EXISTS weight_per_convertion NUMERIC(15, 3),
|
||||
ADD COLUMN IF NOT EXISTS week INTEGER;
|
||||
@@ -1,47 +0,0 @@
|
||||
BEGIN;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
fallback_fcr_id BIGINT;
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'project_flocks'
|
||||
AND column_name = 'fcr_id'
|
||||
) THEN
|
||||
ALTER TABLE project_flocks
|
||||
ADD COLUMN fcr_id BIGINT;
|
||||
END IF;
|
||||
|
||||
SELECT id INTO fallback_fcr_id
|
||||
FROM fcrs
|
||||
ORDER BY id ASC
|
||||
LIMIT 1;
|
||||
|
||||
IF fallback_fcr_id IS NOT NULL THEN
|
||||
UPDATE project_flocks
|
||||
SET fcr_id = fallback_fcr_id
|
||||
WHERE fcr_id IS NULL;
|
||||
|
||||
ALTER TABLE project_flocks
|
||||
ALTER COLUMN fcr_id SET NOT NULL;
|
||||
END IF;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
WHERE conname = 'project_flocks_fcr_id_fkey'
|
||||
) THEN
|
||||
ALTER TABLE project_flocks
|
||||
DROP CONSTRAINT project_flocks_fcr_id_fkey;
|
||||
END IF;
|
||||
|
||||
ALTER TABLE project_flocks
|
||||
ADD CONSTRAINT project_flocks_fcr_id_fkey
|
||||
FOREIGN KEY (fcr_id) REFERENCES fcrs(id)
|
||||
ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
@@ -1,26 +0,0 @@
|
||||
BEGIN;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'project_flocks'
|
||||
AND column_name = 'fcr_id'
|
||||
) THEN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
WHERE conname = 'project_flocks_fcr_id_fkey'
|
||||
) THEN
|
||||
ALTER TABLE project_flocks
|
||||
DROP CONSTRAINT project_flocks_fcr_id_fkey;
|
||||
END IF;
|
||||
|
||||
ALTER TABLE project_flocks
|
||||
DROP COLUMN fcr_id;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE recording_depletions
|
||||
DROP CONSTRAINT IF EXISTS chk_recording_depletions_pending_zero;
|
||||
|
||||
ALTER TABLE recording_depletions
|
||||
DROP COLUMN IF EXISTS total_used_qty;
|
||||
|
||||
ALTER TABLE recording_depletions
|
||||
DROP COLUMN IF EXISTS usage_qty;
|
||||
|
||||
COMMIT;
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE recording_depletions
|
||||
ADD COLUMN IF NOT EXISTS total_used_qty numeric(15, 3) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS usage_qty numeric(15, 3) NOT NULL DEFAULT 0;
|
||||
|
||||
UPDATE recording_depletions
|
||||
SET pending_qty = 0
|
||||
WHERE pending_qty IS NULL OR pending_qty <> 0;
|
||||
|
||||
ALTER TABLE recording_depletions
|
||||
ADD CONSTRAINT chk_recording_depletions_pending_zero
|
||||
CHECK (pending_qty = 0);
|
||||
|
||||
COMMIT;
|
||||
@@ -1,37 +0,0 @@
|
||||
BEGIN;
|
||||
|
||||
DELETE FROM fifo_stock_v2_overconsume_rules
|
||||
WHERE reason IN (
|
||||
'fifo_v2_default_allow',
|
||||
'fifo_v2_exception_ayam_depletion_block',
|
||||
'fifo_v2_exception_marketing_block',
|
||||
'fifo_v2_exception_transfer_block',
|
||||
'fifo_v2_exception_adjustment_block',
|
||||
'fifo_v2_exception_transfer_laying_block'
|
||||
);
|
||||
|
||||
DELETE FROM fifo_stock_v2_route_rules
|
||||
WHERE flag_group_code IN ('AYAM', 'AFKIR_CULLING_MATI', 'PAKAN', 'OVK', 'TELUR', 'TELUR_GRADE');
|
||||
|
||||
DELETE FROM fifo_stock_v2_traits
|
||||
WHERE source_table IN (
|
||||
'purchase_items',
|
||||
'stock_transfer_details',
|
||||
'laying_transfer_targets',
|
||||
'laying_transfer_sources',
|
||||
'adjustment_stocks',
|
||||
'recording_stocks',
|
||||
'recording_depletions',
|
||||
'recording_eggs',
|
||||
'marketing_delivery_products',
|
||||
'project_chickins',
|
||||
'project_flock_populations'
|
||||
);
|
||||
|
||||
DELETE FROM fifo_stock_v2_flag_members
|
||||
WHERE flag_group_code IN ('AYAM', 'AFKIR_CULLING_MATI', 'PAKAN', 'OVK', 'TELUR', 'TELUR_GRADE');
|
||||
|
||||
DELETE FROM fifo_stock_v2_flag_groups
|
||||
WHERE code IN ('AYAM', 'AFKIR_CULLING_MATI', 'PAKAN', 'OVK', 'TELUR', 'TELUR_GRADE');
|
||||
|
||||
COMMIT;
|
||||
@@ -1,250 +0,0 @@
|
||||
BEGIN;
|
||||
|
||||
INSERT INTO fifo_stock_v2_flag_groups(code, name, priority)
|
||||
VALUES
|
||||
('AYAM', 'AYAM', 10),
|
||||
('AFKIR_CULLING_MATI', 'AFKIR/CULLING/MATI', 20),
|
||||
('PAKAN', 'PAKAN', 30),
|
||||
('OVK', 'OVK', 40),
|
||||
('TELUR', 'TELUR', 50),
|
||||
('TELUR_GRADE', 'UTUH/PUTIH/RETAK/PECAH/PAPACAL/JUMBO', 60)
|
||||
ON CONFLICT (code) DO UPDATE
|
||||
SET
|
||||
name = EXCLUDED.name,
|
||||
priority = EXCLUDED.priority,
|
||||
updated_at = NOW();
|
||||
|
||||
INSERT INTO fifo_stock_v2_flag_members(flag_name, flag_group_code, priority)
|
||||
VALUES
|
||||
('DOC', 'AYAM', 10),
|
||||
('PULLET', 'AYAM', 20),
|
||||
('LAYER', 'AYAM', 30),
|
||||
|
||||
('AYAM-AFKIR', 'AFKIR_CULLING_MATI', 10),
|
||||
('AYAM-CULLING', 'AFKIR_CULLING_MATI', 20),
|
||||
('AYAM-MATI', 'AFKIR_CULLING_MATI', 30),
|
||||
|
||||
('PAKAN', 'PAKAN', 10),
|
||||
('PRE-STARTER', 'PAKAN', 20),
|
||||
('STARTER', 'PAKAN', 30),
|
||||
('FINISHER', 'PAKAN', 40),
|
||||
|
||||
('OVK', 'OVK', 10),
|
||||
('OBAT', 'OVK', 20),
|
||||
('VITAMIN', 'OVK', 30),
|
||||
('KIMIA', 'OVK', 40),
|
||||
|
||||
('TELUR', 'TELUR', 10),
|
||||
|
||||
('TELUR-UTUH', 'TELUR_GRADE', 10),
|
||||
('TELUR-PUTIH', 'TELUR_GRADE', 20),
|
||||
('TELUR-RETAK', 'TELUR_GRADE', 30),
|
||||
('TELUR-PECAH', 'TELUR_GRADE', 40),
|
||||
('TELUR-PAPACAL', 'TELUR_GRADE', 50),
|
||||
('TELUR-JUMBO', 'TELUR_GRADE', 60)
|
||||
ON CONFLICT (flag_name) DO UPDATE
|
||||
SET
|
||||
flag_group_code = EXCLUDED.flag_group_code,
|
||||
priority = EXCLUDED.priority,
|
||||
updated_at = NOW();
|
||||
|
||||
INSERT INTO fifo_stock_v2_traits(
|
||||
source_table,
|
||||
lane,
|
||||
date_table,
|
||||
date_join_left_col,
|
||||
date_join_right_col,
|
||||
date_column,
|
||||
fallback_date_column,
|
||||
sort_priority,
|
||||
id_column
|
||||
)
|
||||
VALUES
|
||||
('purchase_items', 'STOCKABLE', NULL, NULL, NULL, 'received_date', NULL, 10, 'id'),
|
||||
|
||||
('stock_transfer_details', 'STOCKABLE', 'stock_transfers', 'stock_transfer_id', 'id', 'transfer_date', NULL, 20, 'id'),
|
||||
('stock_transfer_details', 'USABLE', 'stock_transfers', 'stock_transfer_id', 'id', 'transfer_date', NULL, 20, 'id'),
|
||||
|
||||
('laying_transfer_targets', 'STOCKABLE', 'laying_transfers', 'laying_transfer_id', 'id', 'transfer_date', NULL, 25, 'id'),
|
||||
('laying_transfer_sources', 'USABLE', 'laying_transfers', 'laying_transfer_id', 'id', 'transfer_date', NULL, 25, 'id'),
|
||||
|
||||
('adjustment_stocks', 'STOCKABLE', NULL, NULL, NULL, 'created_at', NULL, 30, 'id'),
|
||||
('adjustment_stocks', 'USABLE', NULL, NULL, NULL, 'created_at', NULL, 30, 'id'),
|
||||
|
||||
('recording_stocks', 'USABLE', 'recordings', 'recording_id', 'id', 'record_datetime', NULL, 35, 'id'),
|
||||
('recording_depletions', 'USABLE', 'recordings', 'recording_id', 'id', 'record_datetime', NULL, 35, 'id'),
|
||||
('recording_depletions', 'STOCKABLE', 'recordings', 'recording_id', 'id', 'record_datetime', NULL, 35, 'id'),
|
||||
|
||||
('recording_eggs', 'STOCKABLE', 'recordings', 'recording_id', 'id', 'record_datetime', 'created_at', 40, 'id'),
|
||||
|
||||
('marketing_delivery_products', 'USABLE', NULL, NULL, NULL, 'delivery_date', 'created_at', 45, 'id'),
|
||||
|
||||
('project_chickins', 'USABLE', NULL, NULL, NULL, 'chick_in_date', 'created_at', 50, 'id'),
|
||||
('project_flock_populations', 'STOCKABLE', 'project_chickins', 'project_chickin_id', 'id', 'chick_in_date', 'created_at', 55, 'id')
|
||||
ON CONFLICT (source_table, lane) DO UPDATE
|
||||
SET
|
||||
date_table = EXCLUDED.date_table,
|
||||
date_join_left_col = EXCLUDED.date_join_left_col,
|
||||
date_join_right_col = EXCLUDED.date_join_right_col,
|
||||
date_column = EXCLUDED.date_column,
|
||||
fallback_date_column = EXCLUDED.fallback_date_column,
|
||||
sort_priority = EXCLUDED.sort_priority,
|
||||
id_column = EXCLUDED.id_column,
|
||||
is_active = TRUE;
|
||||
|
||||
INSERT INTO fifo_stock_v2_route_rules(
|
||||
flag_group_code,
|
||||
lane,
|
||||
function_code,
|
||||
source_table,
|
||||
source_id_column,
|
||||
product_warehouse_col,
|
||||
quantity_col,
|
||||
used_quantity_col,
|
||||
pending_quantity_col,
|
||||
scope_sql,
|
||||
legacy_type_key,
|
||||
allow_pending_default,
|
||||
is_active
|
||||
)
|
||||
VALUES
|
||||
-- AYAM STOCKABLE
|
||||
('AYAM', 'STOCKABLE', 'ADJUSTMENT_IN', 'adjustment_stocks', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'ADJUSTMENT_IN', TRUE, TRUE),
|
||||
('AYAM', 'STOCKABLE', 'STOCK_TRANSFER_IN', 'stock_transfer_details', 'id', 'dest_product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'STOCK_TRANSFER_IN', TRUE, TRUE),
|
||||
('AYAM', 'STOCKABLE', 'PURCHASE_IN', 'purchase_items', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'PURCHASE_ITEMS', TRUE, TRUE),
|
||||
('AYAM', 'STOCKABLE', 'TRANSFER_TO_LAYING_IN', 'laying_transfer_targets', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'TRANSFERTOLAYING_IN', TRUE, TRUE),
|
||||
('AYAM', 'STOCKABLE', 'POPULATION_IN', 'project_flock_populations', 'id', 'product_warehouse_id', 'total_qty', 'total_used_qty', NULL, NULL, 'PROJECT_FLOCK_POPULATION', TRUE, TRUE),
|
||||
|
||||
-- AYAM USABLE
|
||||
('AYAM', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'ADJUSTMENT_OUT', TRUE, TRUE),
|
||||
('AYAM', 'USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details', 'id', 'source_product_warehouse_id', 'usage_qty', NULL, 'pending_qty', 'deleted_at IS NULL', 'STOCK_TRANSFER_OUT', TRUE, TRUE),
|
||||
('AYAM', 'USABLE', 'CHICKIN_OUT', 'project_chickins', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_usage_qty', 'deleted_at IS NULL', 'PROJECT_CHICKIN', TRUE, TRUE),
|
||||
('AYAM', 'USABLE', 'RECORDING_DEPLETION_OUT', 'recording_depletions', 'id', 'source_product_warehouse_id', 'qty', NULL, 'pending_qty', NULL, 'RECORDING_DEPLETION', TRUE, TRUE),
|
||||
('AYAM', 'USABLE', 'MARKETING_OUT', 'marketing_delivery_products', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'MARKETING_DELIVERY', TRUE, TRUE),
|
||||
('AYAM', 'USABLE', 'TRANSFER_TO_LAYING_OUT', 'laying_transfer_sources', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_usage_qty', 'deleted_at IS NULL', 'TRANSFERTOLAYING_OUT', TRUE, TRUE),
|
||||
|
||||
-- AFKIR/CULLING/MATI STOCKABLE
|
||||
('AFKIR_CULLING_MATI', 'STOCKABLE', 'ADJUSTMENT_IN', 'adjustment_stocks', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'ADJUSTMENT_IN', TRUE, TRUE),
|
||||
('AFKIR_CULLING_MATI', 'STOCKABLE', 'STOCK_TRANSFER_IN', 'stock_transfer_details', 'id', 'dest_product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'STOCK_TRANSFER_IN', TRUE, TRUE),
|
||||
('AFKIR_CULLING_MATI', 'STOCKABLE', 'RECORDING_DEPLETION_IN', 'recording_depletions', 'id', 'product_warehouse_id', 'qty', NULL, NULL, NULL, 'RECORDING_DEPLETION', TRUE, TRUE),
|
||||
|
||||
-- AFKIR/CULLING/MATI USABLE
|
||||
('AFKIR_CULLING_MATI', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'ADJUSTMENT_OUT', TRUE, TRUE),
|
||||
('AFKIR_CULLING_MATI', 'USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details', 'id', 'source_product_warehouse_id', 'usage_qty', NULL, 'pending_qty', 'deleted_at IS NULL', 'STOCK_TRANSFER_OUT', TRUE, TRUE),
|
||||
('AFKIR_CULLING_MATI', 'USABLE', 'MARKETING_OUT', 'marketing_delivery_products', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'MARKETING_DELIVERY', TRUE, TRUE),
|
||||
|
||||
-- PAKAN STOCKABLE
|
||||
('PAKAN', 'STOCKABLE', 'ADJUSTMENT_IN', 'adjustment_stocks', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'ADJUSTMENT_IN', TRUE, TRUE),
|
||||
('PAKAN', 'STOCKABLE', 'STOCK_TRANSFER_IN', 'stock_transfer_details', 'id', 'dest_product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'STOCK_TRANSFER_IN', TRUE, TRUE),
|
||||
('PAKAN', 'STOCKABLE', 'PURCHASE_IN', 'purchase_items', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'PURCHASE_ITEMS', TRUE, TRUE),
|
||||
|
||||
-- PAKAN USABLE
|
||||
('PAKAN', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'ADJUSTMENT_OUT', TRUE, TRUE),
|
||||
('PAKAN', 'USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details', 'id', 'source_product_warehouse_id', 'usage_qty', NULL, 'pending_qty', 'deleted_at IS NULL', 'STOCK_TRANSFER_OUT', TRUE, TRUE),
|
||||
('PAKAN', 'USABLE', 'RECORDING_STOCK_OUT', 'recording_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'RECORDING_STOCK', TRUE, TRUE),
|
||||
('PAKAN', 'USABLE', 'MARKETING_OUT', 'marketing_delivery_products', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'MARKETING_DELIVERY', TRUE, TRUE),
|
||||
|
||||
-- OVK STOCKABLE
|
||||
('OVK', 'STOCKABLE', 'ADJUSTMENT_IN', 'adjustment_stocks', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'ADJUSTMENT_IN', TRUE, TRUE),
|
||||
('OVK', 'STOCKABLE', 'STOCK_TRANSFER_IN', 'stock_transfer_details', 'id', 'dest_product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'STOCK_TRANSFER_IN', TRUE, TRUE),
|
||||
('OVK', 'STOCKABLE', 'PURCHASE_IN', 'purchase_items', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'PURCHASE_ITEMS', TRUE, TRUE),
|
||||
|
||||
-- OVK USABLE
|
||||
('OVK', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'ADJUSTMENT_OUT', TRUE, TRUE),
|
||||
('OVK', 'USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details', 'id', 'source_product_warehouse_id', 'usage_qty', NULL, 'pending_qty', 'deleted_at IS NULL', 'STOCK_TRANSFER_OUT', TRUE, TRUE),
|
||||
('OVK', 'USABLE', 'RECORDING_STOCK_OUT', 'recording_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'RECORDING_STOCK', TRUE, TRUE),
|
||||
('OVK', 'USABLE', 'MARKETING_OUT', 'marketing_delivery_products', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'MARKETING_DELIVERY', TRUE, TRUE),
|
||||
|
||||
-- TELUR STOCKABLE
|
||||
('TELUR', 'STOCKABLE', 'ADJUSTMENT_IN', 'adjustment_stocks', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'ADJUSTMENT_IN', TRUE, TRUE),
|
||||
('TELUR', 'STOCKABLE', 'STOCK_TRANSFER_IN', 'stock_transfer_details', 'id', 'dest_product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'STOCK_TRANSFER_IN', TRUE, TRUE),
|
||||
('TELUR', 'STOCKABLE', 'RECORDING_EGG_IN', 'recording_eggs', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'RECORDING_EGG', TRUE, TRUE),
|
||||
|
||||
-- TELUR USABLE
|
||||
('TELUR', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'ADJUSTMENT_OUT', TRUE, TRUE),
|
||||
('TELUR', 'USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details', 'id', 'source_product_warehouse_id', 'usage_qty', NULL, 'pending_qty', 'deleted_at IS NULL', 'STOCK_TRANSFER_OUT', TRUE, TRUE),
|
||||
('TELUR', 'USABLE', 'MARKETING_OUT', 'marketing_delivery_products', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'MARKETING_DELIVERY', TRUE, TRUE),
|
||||
|
||||
-- TELUR_GRADE STOCKABLE
|
||||
('TELUR_GRADE', 'STOCKABLE', 'ADJUSTMENT_IN', 'adjustment_stocks', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'ADJUSTMENT_IN', TRUE, TRUE),
|
||||
('TELUR_GRADE', 'STOCKABLE', 'STOCK_TRANSFER_IN', 'stock_transfer_details', 'id', 'dest_product_warehouse_id', 'total_qty', 'total_used', NULL, 'deleted_at IS NULL', 'STOCK_TRANSFER_IN', TRUE, TRUE),
|
||||
('TELUR_GRADE', 'STOCKABLE', 'RECORDING_EGG_IN', 'recording_eggs', 'id', 'product_warehouse_id', 'total_qty', 'total_used', NULL, NULL, 'RECORDING_EGG', TRUE, TRUE),
|
||||
|
||||
-- TELUR_GRADE USABLE
|
||||
('TELUR_GRADE', 'USABLE', 'ADJUSTMENT_OUT', 'adjustment_stocks', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'ADJUSTMENT_OUT', TRUE, TRUE),
|
||||
('TELUR_GRADE', 'USABLE', 'STOCK_TRANSFER_OUT', 'stock_transfer_details', 'id', 'source_product_warehouse_id', 'usage_qty', NULL, 'pending_qty', 'deleted_at IS NULL', 'STOCK_TRANSFER_OUT', TRUE, TRUE),
|
||||
('TELUR_GRADE', 'USABLE', 'MARKETING_OUT', 'marketing_delivery_products', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_qty', NULL, 'MARKETING_DELIVERY', TRUE, TRUE)
|
||||
ON CONFLICT (flag_group_code, lane, function_code, source_table) DO UPDATE
|
||||
SET
|
||||
source_id_column = EXCLUDED.source_id_column,
|
||||
product_warehouse_col = EXCLUDED.product_warehouse_col,
|
||||
quantity_col = EXCLUDED.quantity_col,
|
||||
used_quantity_col = EXCLUDED.used_quantity_col,
|
||||
pending_quantity_col = EXCLUDED.pending_quantity_col,
|
||||
scope_sql = EXCLUDED.scope_sql,
|
||||
legacy_type_key = EXCLUDED.legacy_type_key,
|
||||
allow_pending_default = EXCLUDED.allow_pending_default,
|
||||
is_active = EXCLUDED.is_active,
|
||||
updated_at = NOW();
|
||||
|
||||
INSERT INTO fifo_stock_v2_overconsume_rules(flag_group_code, function_code, lane, allow_overconsume, priority, reason, is_active)
|
||||
SELECT NULL, NULL, 'USABLE', TRUE, 999, 'fifo_v2_default_allow', TRUE
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM fifo_stock_v2_overconsume_rules
|
||||
WHERE flag_group_code IS NULL
|
||||
AND function_code IS NULL
|
||||
AND lane = 'USABLE'
|
||||
AND reason = 'fifo_v2_default_allow'
|
||||
);
|
||||
|
||||
INSERT INTO fifo_stock_v2_overconsume_rules(flag_group_code, function_code, lane, allow_overconsume, priority, reason, is_active)
|
||||
SELECT 'AYAM', 'RECORDING_DEPLETION_OUT', 'USABLE', FALSE, 10, 'fifo_v2_exception_ayam_depletion_block', TRUE
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM fifo_stock_v2_overconsume_rules
|
||||
WHERE flag_group_code = 'AYAM'
|
||||
AND function_code = 'RECORDING_DEPLETION_OUT'
|
||||
AND lane = 'USABLE'
|
||||
AND reason = 'fifo_v2_exception_ayam_depletion_block'
|
||||
);
|
||||
|
||||
INSERT INTO fifo_stock_v2_overconsume_rules(flag_group_code, function_code, lane, allow_overconsume, priority, reason, is_active)
|
||||
SELECT NULL, 'MARKETING_OUT', 'USABLE', FALSE, 20, 'fifo_v2_exception_marketing_block', TRUE
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM fifo_stock_v2_overconsume_rules
|
||||
WHERE flag_group_code IS NULL
|
||||
AND function_code = 'MARKETING_OUT'
|
||||
AND lane = 'USABLE'
|
||||
AND reason = 'fifo_v2_exception_marketing_block'
|
||||
);
|
||||
|
||||
INSERT INTO fifo_stock_v2_overconsume_rules(flag_group_code, function_code, lane, allow_overconsume, priority, reason, is_active)
|
||||
SELECT NULL, 'STOCK_TRANSFER_OUT', 'USABLE', FALSE, 30, 'fifo_v2_exception_transfer_block', TRUE
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM fifo_stock_v2_overconsume_rules
|
||||
WHERE flag_group_code IS NULL
|
||||
AND function_code = 'STOCK_TRANSFER_OUT'
|
||||
AND lane = 'USABLE'
|
||||
AND reason = 'fifo_v2_exception_transfer_block'
|
||||
);
|
||||
|
||||
INSERT INTO fifo_stock_v2_overconsume_rules(flag_group_code, function_code, lane, allow_overconsume, priority, reason, is_active)
|
||||
SELECT NULL, 'ADJUSTMENT_OUT', 'USABLE', FALSE, 40, 'fifo_v2_exception_adjustment_block', TRUE
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM fifo_stock_v2_overconsume_rules
|
||||
WHERE flag_group_code IS NULL
|
||||
AND function_code = 'ADJUSTMENT_OUT'
|
||||
AND lane = 'USABLE'
|
||||
AND reason = 'fifo_v2_exception_adjustment_block'
|
||||
);
|
||||
|
||||
INSERT INTO fifo_stock_v2_overconsume_rules(flag_group_code, function_code, lane, allow_overconsume, priority, reason, is_active)
|
||||
SELECT NULL, 'TRANSFER_TO_LAYING_OUT', 'USABLE', FALSE, 50, 'fifo_v2_exception_transfer_laying_block', TRUE
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM fifo_stock_v2_overconsume_rules
|
||||
WHERE flag_group_code IS NULL
|
||||
AND function_code = 'TRANSFER_TO_LAYING_OUT'
|
||||
AND lane = 'USABLE'
|
||||
AND reason = 'fifo_v2_exception_transfer_laying_block'
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
BEGIN;
|
||||
|
||||
DROP INDEX IF EXISTS idx_adjustment_stocks_function_code;
|
||||
DROP INDEX IF EXISTS idx_adjustment_stocks_transaction_type;
|
||||
|
||||
ALTER TABLE adjustment_stocks
|
||||
DROP COLUMN IF EXISTS grand_total,
|
||||
DROP COLUMN IF EXISTS price,
|
||||
DROP COLUMN IF EXISTS function_code,
|
||||
DROP COLUMN IF EXISTS transaction_type;
|
||||
|
||||
COMMIT;
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE adjustment_stocks
|
||||
ADD COLUMN IF NOT EXISTS transaction_type VARCHAR(100) NOT NULL DEFAULT 'LEGACY',
|
||||
ADD COLUMN IF NOT EXISTS function_code VARCHAR(64),
|
||||
ADD COLUMN IF NOT EXISTS price NUMERIC(15, 3) NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS grand_total NUMERIC(15, 3) NOT NULL DEFAULT 0;
|
||||
|
||||
UPDATE adjustment_stocks
|
||||
SET function_code = CASE
|
||||
WHEN COALESCE(total_qty, 0) > 0 THEN 'ADJUSTMENT_IN'
|
||||
WHEN COALESCE(usage_qty, 0) > 0 THEN 'ADJUSTMENT_OUT'
|
||||
ELSE 'ADJUSTMENT_IN'
|
||||
END
|
||||
WHERE function_code IS NULL OR function_code = '';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_adjustment_stocks_transaction_type
|
||||
ON adjustment_stocks(transaction_type);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_adjustment_stocks_function_code
|
||||
ON adjustment_stocks(function_code);
|
||||
|
||||
COMMIT;
|
||||
@@ -1,13 +0,0 @@
|
||||
BEGIN;
|
||||
|
||||
-- Restore CHICKIN route if rollback is required.
|
||||
-- NOTE: released PROJECT_CHICKIN allocations are not restored by this down migration.
|
||||
UPDATE fifo_stock_v2_route_rules
|
||||
SET is_active = TRUE,
|
||||
updated_at = NOW()
|
||||
WHERE flag_group_code = 'AYAM'
|
||||
AND lane = 'USABLE'
|
||||
AND function_code = 'CHICKIN_OUT'
|
||||
AND source_table = 'project_chickins';
|
||||
|
||||
COMMIT;
|
||||
@@ -1,151 +0,0 @@
|
||||
BEGIN;
|
||||
|
||||
-- Disable CHICKIN as FIFO USABLE so chick-in acts as business tagging/conversion,
|
||||
-- not physical stock consumption.
|
||||
UPDATE fifo_stock_v2_route_rules
|
||||
SET is_active = FALSE,
|
||||
updated_at = NOW()
|
||||
WHERE flag_group_code = 'AYAM'
|
||||
AND lane = 'USABLE'
|
||||
AND function_code = 'CHICKIN_OUT'
|
||||
AND source_table = 'project_chickins'
|
||||
AND is_active = TRUE;
|
||||
|
||||
-- Release existing active allocations created by PROJECT_CHICKIN
|
||||
-- and return warehouse qty back.
|
||||
WITH released AS (
|
||||
UPDATE stock_allocations
|
||||
SET status = 'RELEASED',
|
||||
released_at = COALESCE(released_at, NOW()),
|
||||
updated_at = NOW(),
|
||||
note = CASE
|
||||
WHEN COALESCE(note, '') = '' THEN 'fifo_v2_chickin_conversion_release'
|
||||
ELSE note || '; fifo_v2_chickin_conversion_release'
|
||||
END
|
||||
WHERE usable_type = 'PROJECT_CHICKIN'
|
||||
AND status = 'ACTIVE'
|
||||
RETURNING product_warehouse_id, qty
|
||||
),
|
||||
pw_delta AS (
|
||||
SELECT product_warehouse_id, COALESCE(SUM(qty), 0) AS qty_delta
|
||||
FROM released
|
||||
GROUP BY product_warehouse_id
|
||||
)
|
||||
UPDATE product_warehouses pw
|
||||
SET qty = COALESCE(pw.qty, 0) + d.qty_delta
|
||||
FROM pw_delta d
|
||||
WHERE pw.id = d.product_warehouse_id;
|
||||
|
||||
-- Resync stockable total_used columns from remaining ACTIVE allocations.
|
||||
|
||||
-- purchase_items (PURCHASE_ITEMS)
|
||||
UPDATE purchase_items pi
|
||||
SET total_used = COALESCE(a.used, 0)
|
||||
FROM (
|
||||
SELECT stockable_id, SUM(qty) AS used
|
||||
FROM stock_allocations
|
||||
WHERE status = 'ACTIVE'
|
||||
AND stockable_type = 'PURCHASE_ITEMS'
|
||||
GROUP BY stockable_id
|
||||
) a
|
||||
WHERE pi.id = a.stockable_id;
|
||||
|
||||
UPDATE purchase_items pi
|
||||
SET total_used = 0
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM stock_allocations sa
|
||||
WHERE sa.status = 'ACTIVE'
|
||||
AND sa.stockable_type = 'PURCHASE_ITEMS'
|
||||
AND sa.stockable_id = pi.id
|
||||
);
|
||||
|
||||
-- stock_transfer_details (STOCK_TRANSFER_IN)
|
||||
UPDATE stock_transfer_details std
|
||||
SET total_used = COALESCE(a.used, 0)
|
||||
FROM (
|
||||
SELECT stockable_id, SUM(qty) AS used
|
||||
FROM stock_allocations
|
||||
WHERE status = 'ACTIVE'
|
||||
AND stockable_type = 'STOCK_TRANSFER_IN'
|
||||
GROUP BY stockable_id
|
||||
) a
|
||||
WHERE std.id = a.stockable_id;
|
||||
|
||||
UPDATE stock_transfer_details std
|
||||
SET total_used = 0
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM stock_allocations sa
|
||||
WHERE sa.status = 'ACTIVE'
|
||||
AND sa.stockable_type = 'STOCK_TRANSFER_IN'
|
||||
AND sa.stockable_id = std.id
|
||||
);
|
||||
|
||||
-- adjustment_stocks (ADJUSTMENT_IN)
|
||||
UPDATE adjustment_stocks ast
|
||||
SET total_used = COALESCE(a.used, 0)
|
||||
FROM (
|
||||
SELECT stockable_id, SUM(qty) AS used
|
||||
FROM stock_allocations
|
||||
WHERE status = 'ACTIVE'
|
||||
AND stockable_type = 'ADJUSTMENT_IN'
|
||||
GROUP BY stockable_id
|
||||
) a
|
||||
WHERE ast.id = a.stockable_id;
|
||||
|
||||
UPDATE adjustment_stocks ast
|
||||
SET total_used = 0
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM stock_allocations sa
|
||||
WHERE sa.status = 'ACTIVE'
|
||||
AND sa.stockable_type = 'ADJUSTMENT_IN'
|
||||
AND sa.stockable_id = ast.id
|
||||
);
|
||||
|
||||
-- laying_transfer_targets (TRANSFERTOLAYING_IN)
|
||||
UPDATE laying_transfer_targets ltt
|
||||
SET total_used = COALESCE(a.used, 0)
|
||||
FROM (
|
||||
SELECT stockable_id, SUM(qty) AS used
|
||||
FROM stock_allocations
|
||||
WHERE status = 'ACTIVE'
|
||||
AND stockable_type = 'TRANSFERTOLAYING_IN'
|
||||
GROUP BY stockable_id
|
||||
) a
|
||||
WHERE ltt.id = a.stockable_id;
|
||||
|
||||
UPDATE laying_transfer_targets ltt
|
||||
SET total_used = 0
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM stock_allocations sa
|
||||
WHERE sa.status = 'ACTIVE'
|
||||
AND sa.stockable_type = 'TRANSFERTOLAYING_IN'
|
||||
AND sa.stockable_id = ltt.id
|
||||
);
|
||||
|
||||
-- recording_eggs (RECORDING_EGG)
|
||||
UPDATE recording_eggs re
|
||||
SET total_used = COALESCE(a.used, 0)
|
||||
FROM (
|
||||
SELECT stockable_id, SUM(qty) AS used
|
||||
FROM stock_allocations
|
||||
WHERE status = 'ACTIVE'
|
||||
AND stockable_type = 'RECORDING_EGG'
|
||||
GROUP BY stockable_id
|
||||
) a
|
||||
WHERE re.id = a.stockable_id;
|
||||
|
||||
UPDATE recording_eggs re
|
||||
SET total_used = 0
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM stock_allocations sa
|
||||
WHERE sa.status = 'ACTIVE'
|
||||
AND sa.stockable_type = 'RECORDING_EGG'
|
||||
AND sa.stockable_id = re.id
|
||||
);
|
||||
|
||||
COMMIT;
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
BEGIN;
|
||||
|
||||
DROP INDEX IF EXISTS idx_stock_allocations_purpose_stockable_active;
|
||||
DROP INDEX IF EXISTS idx_stock_allocations_purpose_usable_active;
|
||||
DROP INDEX IF EXISTS idx_stock_allocations_purpose_status;
|
||||
|
||||
ALTER TABLE stock_allocations
|
||||
DROP CONSTRAINT IF EXISTS stock_allocations_allocation_purpose_check;
|
||||
|
||||
ALTER TABLE stock_allocations
|
||||
DROP COLUMN IF EXISTS allocation_purpose;
|
||||
|
||||
COMMIT;
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE stock_allocations
|
||||
ADD COLUMN IF NOT EXISTS allocation_purpose VARCHAR(32);
|
||||
|
||||
UPDATE stock_allocations
|
||||
SET allocation_purpose = 'CONSUME'
|
||||
WHERE allocation_purpose IS NULL
|
||||
OR BTRIM(allocation_purpose) = '';
|
||||
|
||||
ALTER TABLE stock_allocations
|
||||
ALTER COLUMN allocation_purpose SET DEFAULT 'CONSUME',
|
||||
ALTER COLUMN allocation_purpose SET NOT NULL;
|
||||
|
||||
ALTER TABLE stock_allocations
|
||||
DROP CONSTRAINT IF EXISTS stock_allocations_allocation_purpose_check;
|
||||
|
||||
ALTER TABLE stock_allocations
|
||||
ADD CONSTRAINT stock_allocations_allocation_purpose_check
|
||||
CHECK (allocation_purpose IN ('CONSUME', 'TRACE_CHICKIN'));
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_stock_allocations_purpose_status
|
||||
ON stock_allocations (allocation_purpose, status);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_stock_allocations_purpose_usable_active
|
||||
ON stock_allocations (allocation_purpose, usable_type, usable_id)
|
||||
WHERE status = 'ACTIVE';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_stock_allocations_purpose_stockable_active
|
||||
ON stock_allocations (allocation_purpose, stockable_type, stockable_id)
|
||||
WHERE status = 'ACTIVE';
|
||||
|
||||
COMMIT;
|
||||
@@ -1,24 +0,0 @@
|
||||
BEGIN;
|
||||
|
||||
DROP INDEX IF EXISTS idx_stock_allocations_idempotency;
|
||||
DROP INDEX IF EXISTS idx_stock_allocations_flag_group;
|
||||
DROP INDEX IF EXISTS idx_stock_allocations_engine_version;
|
||||
|
||||
ALTER TABLE stock_allocations
|
||||
DROP COLUMN IF EXISTS idempotency_key,
|
||||
DROP COLUMN IF EXISTS reflow_run_id,
|
||||
DROP COLUMN IF EXISTS function_code,
|
||||
DROP COLUMN IF EXISTS flag_group_code,
|
||||
DROP COLUMN IF EXISTS engine_version;
|
||||
|
||||
DROP TABLE IF EXISTS fifo_stock_v2_shadow_allocations;
|
||||
DROP TABLE IF EXISTS fifo_stock_v2_reflow_checkpoints;
|
||||
DROP TABLE IF EXISTS fifo_stock_v2_reflow_runs;
|
||||
DROP TABLE IF EXISTS fifo_stock_v2_operation_log;
|
||||
DROP TABLE IF EXISTS fifo_stock_v2_overconsume_rules;
|
||||
DROP TABLE IF EXISTS fifo_stock_v2_route_rules;
|
||||
DROP TABLE IF EXISTS fifo_stock_v2_traits;
|
||||
DROP TABLE IF EXISTS fifo_stock_v2_flag_members;
|
||||
DROP TABLE IF EXISTS fifo_stock_v2_flag_groups;
|
||||
|
||||
COMMIT;
|
||||
@@ -1,151 +0,0 @@
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fifo_stock_v2_flag_groups (
|
||||
code VARCHAR(64) PRIMARY KEY,
|
||||
name VARCHAR(128) NOT NULL,
|
||||
priority INT NOT NULL DEFAULT 100,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fifo_stock_v2_flag_members (
|
||||
flag_name VARCHAR(64) PRIMARY KEY,
|
||||
flag_group_code VARCHAR(64) NOT NULL REFERENCES fifo_stock_v2_flag_groups(code),
|
||||
priority INT NOT NULL DEFAULT 100,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fifo_stock_v2_traits (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
source_table VARCHAR(64) NOT NULL,
|
||||
lane VARCHAR(16) NOT NULL CHECK (lane IN ('STOCKABLE', 'USABLE')),
|
||||
date_table VARCHAR(64) NULL,
|
||||
date_join_left_col VARCHAR(64) NULL,
|
||||
date_join_right_col VARCHAR(64) NULL,
|
||||
date_column VARCHAR(64) NOT NULL,
|
||||
fallback_date_column VARCHAR(64) NULL,
|
||||
sort_priority INT NOT NULL DEFAULT 100,
|
||||
id_column VARCHAR(64) NOT NULL DEFAULT 'id',
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
UNIQUE (source_table, lane)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fifo_stock_v2_route_rules (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
flag_group_code VARCHAR(64) NOT NULL REFERENCES fifo_stock_v2_flag_groups(code),
|
||||
lane VARCHAR(16) NOT NULL CHECK (lane IN ('STOCKABLE', 'USABLE')),
|
||||
function_code VARCHAR(64) NOT NULL,
|
||||
source_table VARCHAR(64) NOT NULL,
|
||||
source_id_column VARCHAR(64) NOT NULL DEFAULT 'id',
|
||||
product_warehouse_col VARCHAR(64) NOT NULL,
|
||||
quantity_col VARCHAR(64) NOT NULL,
|
||||
used_quantity_col VARCHAR(64) NULL,
|
||||
pending_quantity_col VARCHAR(64) NULL,
|
||||
scope_sql TEXT NULL,
|
||||
legacy_type_key VARCHAR(100) NOT NULL,
|
||||
allow_pending_default BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (flag_group_code, lane, function_code, source_table)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fifo_stock_v2_overconsume_rules (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
flag_group_code VARCHAR(64) NULL REFERENCES fifo_stock_v2_flag_groups(code),
|
||||
function_code VARCHAR(64) NULL,
|
||||
lane VARCHAR(16) NOT NULL DEFAULT 'USABLE' CHECK (lane IN ('STOCKABLE', 'USABLE')),
|
||||
allow_overconsume BOOLEAN NOT NULL,
|
||||
priority INT NOT NULL DEFAULT 100,
|
||||
reason TEXT NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fifo_stock_v2_operation_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
idempotency_key VARCHAR(128) NOT NULL,
|
||||
operation VARCHAR(16) NOT NULL CHECK (operation IN ('ALLOCATE', 'ROLLBACK', 'REFLOW', 'RECALCULATE')),
|
||||
product_warehouse_id BIGINT NOT NULL,
|
||||
flag_group_code VARCHAR(64) NOT NULL,
|
||||
usable_type VARCHAR(100) NULL,
|
||||
usable_id BIGINT NULL,
|
||||
request_hash VARCHAR(64) NOT NULL,
|
||||
status VARCHAR(16) NOT NULL CHECK (status IN ('RUNNING', 'DONE', 'FAILED')),
|
||||
result_payload JSONB NULL,
|
||||
error_text TEXT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
finished_at TIMESTAMPTZ NULL,
|
||||
UNIQUE (idempotency_key, operation)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fifo_stock_v2_reflow_runs (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
mode VARCHAR(16) NOT NULL CHECK (mode IN ('DRY_RUN', 'APPLY')),
|
||||
status VARCHAR(16) NOT NULL CHECK (status IN ('RUNNING', 'PAUSED', 'DONE', 'FAILED', 'CANCELLED')),
|
||||
as_of TIMESTAMPTZ NULL,
|
||||
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
finished_at TIMESTAMPTZ NULL,
|
||||
total_shards INT NOT NULL DEFAULT 0,
|
||||
processed_shards INT NOT NULL DEFAULT 0,
|
||||
processed_rows BIGINT NOT NULL DEFAULT 0,
|
||||
mismatch_rows BIGINT NOT NULL DEFAULT 0,
|
||||
created_by BIGINT NULL,
|
||||
note TEXT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fifo_stock_v2_reflow_checkpoints (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
run_id BIGINT NOT NULL REFERENCES fifo_stock_v2_reflow_runs(id) ON DELETE CASCADE,
|
||||
flag_group_code VARCHAR(64) NOT NULL,
|
||||
product_warehouse_id BIGINT NOT NULL,
|
||||
last_sort_at TIMESTAMPTZ NULL,
|
||||
last_source_table VARCHAR(64) NULL,
|
||||
last_source_id BIGINT NULL,
|
||||
status VARCHAR(16) NOT NULL CHECK (status IN ('PENDING', 'RUNNING', 'DONE', 'FAILED')) DEFAULT 'PENDING',
|
||||
retry_count INT NOT NULL DEFAULT 0,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (run_id, flag_group_code, product_warehouse_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS fifo_stock_v2_shadow_allocations (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
run_id BIGINT NOT NULL REFERENCES fifo_stock_v2_reflow_runs(id) ON DELETE CASCADE,
|
||||
product_warehouse_id BIGINT NOT NULL,
|
||||
stockable_type VARCHAR(100) NOT NULL,
|
||||
stockable_id BIGINT NOT NULL,
|
||||
usable_type VARCHAR(100) NOT NULL,
|
||||
usable_id BIGINT NOT NULL,
|
||||
qty NUMERIC(15,3) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
|
||||
sort_at TIMESTAMPTZ NULL,
|
||||
source_table VARCHAR(64) NULL,
|
||||
source_id BIGINT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_fifo_v2_shadow_run_usable
|
||||
ON fifo_stock_v2_shadow_allocations(run_id, usable_type, usable_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_fifo_v2_shadow_run_stockable
|
||||
ON fifo_stock_v2_shadow_allocations(run_id, stockable_type, stockable_id);
|
||||
|
||||
ALTER TABLE stock_allocations
|
||||
ADD COLUMN IF NOT EXISTS engine_version VARCHAR(8) NOT NULL DEFAULT 'v1',
|
||||
ADD COLUMN IF NOT EXISTS flag_group_code VARCHAR(64) NULL,
|
||||
ADD COLUMN IF NOT EXISTS function_code VARCHAR(64) NULL,
|
||||
ADD COLUMN IF NOT EXISTS reflow_run_id BIGINT NULL,
|
||||
ADD COLUMN IF NOT EXISTS idempotency_key VARCHAR(128) NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_stock_allocations_engine_version
|
||||
ON stock_allocations(engine_version);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_stock_allocations_flag_group
|
||||
ON stock_allocations(flag_group_code);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_stock_allocations_idempotency
|
||||
ON stock_allocations(idempotency_key);
|
||||
|
||||
COMMIT;
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
BEGIN;
|
||||
|
||||
DROP INDEX IF EXISTS idx_laying_transfers_executed_by;
|
||||
DROP INDEX IF EXISTS idx_laying_transfers_executed_at;
|
||||
DROP INDEX IF EXISTS idx_laying_transfers_effective_move_date;
|
||||
|
||||
ALTER TABLE laying_transfers
|
||||
DROP CONSTRAINT IF EXISTS fk_laying_transfers_executed_by;
|
||||
|
||||
ALTER TABLE laying_transfers
|
||||
DROP COLUMN IF EXISTS executed_by,
|
||||
DROP COLUMN IF EXISTS executed_at,
|
||||
DROP COLUMN IF EXISTS effective_move_date;
|
||||
|
||||
COMMIT;
|
||||
-50
@@ -1,50 +0,0 @@
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE laying_transfers
|
||||
ADD COLUMN IF NOT EXISTS effective_move_date DATE,
|
||||
ADD COLUMN IF NOT EXISTS executed_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS executed_by BIGINT;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'users')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
WHERE conname = 'fk_laying_transfers_executed_by'
|
||||
) THEN
|
||||
ALTER TABLE laying_transfers
|
||||
ADD CONSTRAINT fk_laying_transfers_executed_by
|
||||
FOREIGN KEY (executed_by)
|
||||
REFERENCES users(id)
|
||||
ON DELETE SET NULL
|
||||
ON UPDATE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_laying_transfers_effective_move_date
|
||||
ON laying_transfers(effective_move_date);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_laying_transfers_executed_at
|
||||
ON laying_transfers(executed_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_laying_transfers_executed_by
|
||||
ON laying_transfers(executed_by);
|
||||
|
||||
-- Backfill historical approved transfers. Before deferred execution,
|
||||
-- approved transfers were executed immediately during approval.
|
||||
UPDATE laying_transfers lt
|
||||
SET
|
||||
effective_move_date = COALESCE(lt.effective_move_date, lt.transfer_date),
|
||||
executed_at = COALESCE(lt.executed_at, lt.updated_at),
|
||||
executed_by = COALESCE(lt.executed_by, lt.created_by)
|
||||
WHERE (
|
||||
SELECT a.action
|
||||
FROM approvals a
|
||||
WHERE a.approvable_type = 'TRANSFER_TO_LAYINGS'
|
||||
AND a.approvable_id = lt.id
|
||||
ORDER BY a.id DESC
|
||||
LIMIT 1
|
||||
) = 'APPROVED';
|
||||
|
||||
COMMIT;
|
||||
@@ -1,9 +0,0 @@
|
||||
BEGIN;
|
||||
|
||||
DELETE FROM fifo_stock_v2_route_rules
|
||||
WHERE flag_group_code = 'AYAM'
|
||||
AND lane = 'USABLE'
|
||||
AND function_code = 'CHICKIN_OUT'
|
||||
AND source_table = 'project_chickins';
|
||||
|
||||
COMMIT;
|
||||
@@ -1,34 +0,0 @@
|
||||
BEGIN;
|
||||
|
||||
INSERT INTO fifo_stock_v2_route_rules(
|
||||
flag_group_code,
|
||||
lane,
|
||||
function_code,
|
||||
source_table,
|
||||
source_id_column,
|
||||
product_warehouse_col,
|
||||
quantity_col,
|
||||
used_quantity_col,
|
||||
pending_quantity_col,
|
||||
scope_sql,
|
||||
legacy_type_key,
|
||||
allow_pending_default,
|
||||
is_active
|
||||
)
|
||||
VALUES
|
||||
('AYAM', 'USABLE', 'CHICKIN_OUT', 'project_chickins', 'id', 'product_warehouse_id', 'usage_qty', NULL, 'pending_usage_qty', 'deleted_at IS NULL', 'PROJECT_CHICKIN', TRUE, TRUE)
|
||||
ON CONFLICT (flag_group_code, lane, function_code, source_table) DO UPDATE
|
||||
SET
|
||||
source_id_column = EXCLUDED.source_id_column,
|
||||
product_warehouse_col = EXCLUDED.product_warehouse_col,
|
||||
quantity_col = EXCLUDED.quantity_col,
|
||||
used_quantity_col = EXCLUDED.used_quantity_col,
|
||||
pending_quantity_col = EXCLUDED.pending_quantity_col,
|
||||
scope_sql = EXCLUDED.scope_sql,
|
||||
legacy_type_key = EXCLUDED.legacy_type_key,
|
||||
allow_pending_default = EXCLUDED.allow_pending_default,
|
||||
updated_at = NOW(),
|
||||
-- Keep existing is_active (do not override disable migration if it was intentional).
|
||||
is_active = fifo_stock_v2_route_rules.is_active;
|
||||
|
||||
COMMIT;
|
||||
@@ -74,7 +74,7 @@ func seedUsers(tx *gorm.DB) (map[string]uint, error) {
|
||||
}
|
||||
|
||||
func seedUoms(tx *gorm.DB, createdBy uint) (map[string]uint, error) {
|
||||
names := []string{"Kilogram", "Gram", "Liter", "Unit", "Ekor", "Butir"}
|
||||
names := []string{"Kilogram", "Gram", "Liter", "Unit", "Ekor"}
|
||||
result := make(map[string]uint, len(names))
|
||||
|
||||
for _, name := range names {
|
||||
@@ -235,7 +235,7 @@ func seedProducts(tx *gorm.DB, createdBy uint, uoms map[string]uint, categories
|
||||
Name: "Telur Utuh",
|
||||
Brand: "-",
|
||||
Sku: "4",
|
||||
Uom: "Butir",
|
||||
Uom: "Gram",
|
||||
Category: "Telur",
|
||||
Price: 1,
|
||||
Flags: []utils.FlagType{utils.FlagTelurUtuh},
|
||||
@@ -245,7 +245,7 @@ func seedProducts(tx *gorm.DB, createdBy uint, uoms map[string]uint, categories
|
||||
Name: "Telur Pecah",
|
||||
Brand: "-",
|
||||
Sku: "5",
|
||||
Uom: "Butir",
|
||||
Uom: "Gram",
|
||||
Category: "Telur",
|
||||
Price: 1,
|
||||
Flags: []utils.FlagType{utils.FlagTelurPecah},
|
||||
@@ -255,7 +255,7 @@ func seedProducts(tx *gorm.DB, createdBy uint, uoms map[string]uint, categories
|
||||
Name: "Telur Putih",
|
||||
Brand: "-",
|
||||
Sku: "6",
|
||||
Uom: "Butir",
|
||||
Uom: "Gram",
|
||||
Category: "Telur",
|
||||
Price: 1,
|
||||
Flags: []utils.FlagType{utils.FlagTelurPutih},
|
||||
@@ -265,32 +265,12 @@ func seedProducts(tx *gorm.DB, createdBy uint, uoms map[string]uint, categories
|
||||
Name: "Telur Retak",
|
||||
Brand: "-",
|
||||
Sku: "7",
|
||||
Uom: "Butir",
|
||||
Uom: "Gram",
|
||||
Category: "Telur",
|
||||
Price: 1,
|
||||
Flags: []utils.FlagType{utils.FlagTelurRetak},
|
||||
IsVisible: false,
|
||||
},
|
||||
{
|
||||
Name: "Telur Papacal",
|
||||
Brand: "-",
|
||||
Sku: "8",
|
||||
Uom: "Butir",
|
||||
Category: "Telur",
|
||||
Price: 1,
|
||||
Flags: []utils.FlagType{utils.FlagTelur},
|
||||
IsVisible: false,
|
||||
},
|
||||
{
|
||||
Name: "Telur Jumbo",
|
||||
Brand: "-",
|
||||
Sku: "9",
|
||||
Uom: "Butir",
|
||||
Category: "Telur",
|
||||
Price: 1,
|
||||
Flags: []utils.FlagType{utils.FlagTelur},
|
||||
IsVisible: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, seed := range seeds {
|
||||
|
||||
@@ -2,21 +2,28 @@ package entities
|
||||
|
||||
import "time"
|
||||
|
||||
// AdjustmentStock tracks FIFO allocation for stock adjustments
|
||||
// - For INCREASE adjustments (Stockable): Tracks stock added to warehouse
|
||||
// - For DECREASE adjustments (Usable): Tracks stock consumed from warehouse
|
||||
type AdjustmentStock struct {
|
||||
Id uint `gorm:"primaryKey"`
|
||||
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
|
||||
TransactionType string `gorm:"column:transaction_type;type:varchar(100);not null;default:LEGACY"`
|
||||
FunctionCode string `gorm:"column:function_code;type:varchar(64)"`
|
||||
TotalQty float64 `gorm:"column:total_qty;default:0"`
|
||||
TotalUsed float64 `gorm:"column:total_used;default:0"`
|
||||
UsageQty float64 `gorm:"column:usage_qty;default:0"`
|
||||
PendingQty float64 `gorm:"column:pending_qty;default:0"`
|
||||
Price float64 `gorm:"column:price;type:numeric(15,3);default:0"`
|
||||
GrandTotal float64 `gorm:"column:grand_total;type:numeric(15,3);default:0"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime"`
|
||||
AdjNumber string `gorm:"column:adj_number;uniqueIndex;not null"`
|
||||
Id uint `gorm:"primaryKey"`
|
||||
StockLogId uint `gorm:"column:stock_log_id;not null;index"`
|
||||
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
|
||||
|
||||
// === FIFO FIELDS FOR INCREASE ADJUSTMENT (Stockable) ===
|
||||
// Tracks stock added to warehouse via adjustment INCREASE
|
||||
TotalQty float64 `gorm:"column:total_qty;default:0"` // Total lot quantity available
|
||||
TotalUsed float64 `gorm:"column:total_used;default:0"` // Quantity already used from this lot
|
||||
|
||||
// === FIFO FIELDS FOR DECREASE ADJUSTMENT (Usable) ===
|
||||
// Tracks stock consumed from warehouse via adjustment DECREASE
|
||||
UsageQty float64 `gorm:"column:usage_qty;default:0"` // Actual quantity consumed
|
||||
PendingQty float64 `gorm:"column:pending_qty;default:0"` // Pending quantity (waiting for stock)
|
||||
|
||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime"`
|
||||
|
||||
// Relations
|
||||
StockLog *StockLog `gorm:"foreignKey:StockLogId;references:Id"`
|
||||
ProductWarehouse *ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"`
|
||||
StockLog *StockLog `gorm:"polymorphic:Loggable;polymorphicType:LoggableType;polymorphicId:LoggableId;polymorphicValue:ADJUSTMENT"`
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ type Document struct {
|
||||
DocumentableType string `gorm:"size:50;not null;index:documents_documentable_polymorphic,priority:1"`
|
||||
DocumentableId uint64 `gorm:"not null;index:documents_documentable_polymorphic,priority:2"`
|
||||
Type string `gorm:"size:50;not null"`
|
||||
Path string `gorm:"size:255;not null"`
|
||||
Path string `gorm:"size:50;not null"`
|
||||
Name string `gorm:"size:50;not null"`
|
||||
Ext string `gorm:"size:50;not null"`
|
||||
Size float64 `gorm:"type:numeric(15,3);not null"`
|
||||
|
||||
@@ -12,20 +12,16 @@ type LayingTransfer struct {
|
||||
FromProjectFlockId uint `gorm:"not null"`
|
||||
ToProjectFlockId uint `gorm:"not null"`
|
||||
TransferDate time.Time `gorm:"type:date;not null"`
|
||||
EffectiveMoveDate *time.Time `gorm:"type:date"`
|
||||
ExecutedAt *time.Time `gorm:"type:timestamptz"`
|
||||
ExecutedBy *uint `gorm:"index"`
|
||||
Notes string `gorm:"type:text"`
|
||||
CreatedBy uint `gorm:"not null"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index"`
|
||||
|
||||
FromProjectFlock *ProjectFlock `gorm:"foreignKey:FromProjectFlockId;references:Id"`
|
||||
ToProjectFlock *ProjectFlock `gorm:"foreignKey:ToProjectFlockId;references:Id"`
|
||||
CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"`
|
||||
ExecutedUser *User `gorm:"foreignKey:ExecutedBy;references:Id"`
|
||||
Sources []LayingTransferSource `gorm:"foreignKey:LayingTransferId;constraint:OnDelete:CASCADE"`
|
||||
Targets []LayingTransferTarget `gorm:"foreignKey:LayingTransferId;constraint:OnDelete:CASCADE"`
|
||||
LatestApproval *Approval `gorm:"-" json:"-"`
|
||||
FromProjectFlock *ProjectFlock `gorm:"foreignKey:FromProjectFlockId;references:Id"`
|
||||
ToProjectFlock *ProjectFlock `gorm:"foreignKey:ToProjectFlockId;references:Id"`
|
||||
CreatedUser *User `gorm:"foreignKey:CreatedBy;references:Id"`
|
||||
Sources []LayingTransferSource `gorm:"foreignKey:LayingTransferId;constraint:OnDelete:CASCADE"`
|
||||
Targets []LayingTransferTarget `gorm:"foreignKey:LayingTransferId;constraint:OnDelete:CASCADE"`
|
||||
LatestApproval *Approval `gorm:"-" json:"-"`
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ type LayingTransferSource struct {
|
||||
LayingTransferId uint `gorm:"index;not null"`
|
||||
SourceProjectFlockKandangId uint `gorm:"not null"`
|
||||
ProductWarehouseId *uint `gorm:""`
|
||||
RequestedQty float64 `gorm:"type:numeric(15,3);default:0;not null"` // Quantity requested by user
|
||||
UsageQty float64 `gorm:"type:numeric(15,3);default:0;not null"` // FIFO USABLE field
|
||||
PendingUsageQty float64 `gorm:"type:numeric(15,3);default:0;not null"` // FIFO USABLE field
|
||||
Note string `gorm:"type:text"`
|
||||
|
||||
@@ -14,7 +14,6 @@ type Marketing struct {
|
||||
SoDate time.Time `gorm:"type:date;not null"`
|
||||
SalesPersonId uint `gorm:"not null"`
|
||||
Notes string `gorm:"type:text"`
|
||||
MarketingType string `gorm:"type:varchar(50)"`
|
||||
CreatedBy uint `gorm:"not null"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
package entities
|
||||
|
||||
type MarketingProduct struct {
|
||||
Id uint `gorm:"primaryKey;autoIncrement"`
|
||||
MarketingId uint `gorm:"not null"`
|
||||
ProductWarehouseId uint `gorm:"not null"`
|
||||
Qty float64 `gorm:"type:numeric(15,3);not null"`
|
||||
ConvertionUnit *string `gorm:"type:varchar(20)"`
|
||||
WeightPerConvertion *float64 `gorm:"type:numeric(15,3)"`
|
||||
Week *int `gorm:"type:integer"`
|
||||
UnitPrice float64 `gorm:"type:numeric(15,3);not null"`
|
||||
AvgWeight float64 `gorm:"type:numeric(15,3);not null"`
|
||||
TotalWeight float64 `gorm:"type:numeric(15,3);not null"`
|
||||
TotalPrice float64 `gorm:"type:numeric(15,3);not null"`
|
||||
Id uint `gorm:"primaryKey;autoIncrement"`
|
||||
MarketingId uint `gorm:"not null"`
|
||||
ProductWarehouseId uint `gorm:"not null"`
|
||||
Qty float64 `gorm:"type:numeric(15,3);not null"`
|
||||
UnitPrice float64 `gorm:"type:numeric(15,3);not null"`
|
||||
AvgWeight float64 `gorm:"type:numeric(15,3);not null"`
|
||||
TotalWeight float64 `gorm:"type:numeric(15,3);not null"`
|
||||
TotalPrice float64 `gorm:"type:numeric(15,3);not null"`
|
||||
|
||||
Marketing Marketing `gorm:"foreignKey:MarketingId;references:Id"`
|
||||
ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"`
|
||||
|
||||
@@ -5,6 +5,7 @@ import "time"
|
||||
type NonstockSupplier struct {
|
||||
NonstockId uint `gorm:"not null"`
|
||||
SupplierId uint `gorm:"not null"`
|
||||
Price float64 `gorm:"type:numeric(15,3);not null;default:0"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||
|
||||
Nonstock Nonstock `gorm:"foreignKey:NonstockId;references:Id"`
|
||||
|
||||
@@ -11,6 +11,7 @@ type ProjectFlock struct {
|
||||
FlockName string `gorm:"type:varchar(255);not null;uniqueIndex"`
|
||||
AreaId uint `gorm:"not null"`
|
||||
Category string `gorm:"type:varchar(20);not null"`
|
||||
FcrId uint `gorm:"not null"`
|
||||
ProductionStandardId uint `gorm:"column:production_standard_id"`
|
||||
LocationId uint `gorm:"not null"`
|
||||
CreatedBy uint `gorm:"not null"`
|
||||
@@ -19,6 +20,7 @@ type ProjectFlock struct {
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
Area Area `gorm:"foreignKey:AreaId;references:Id"`
|
||||
Fcr Fcr `gorm:"foreignKey:FcrId;references:Id"`
|
||||
ProductionStandard ProductionStandard `gorm:"foreignKey:ProductionStandardId;references:Id"`
|
||||
Location Location `gorm:"foreignKey:LocationId;references:Id"`
|
||||
CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"`
|
||||
|
||||
@@ -21,7 +21,6 @@ type PurchaseItem struct {
|
||||
Price float64 `gorm:"type:numeric(15,3);default:0"`
|
||||
TotalPrice float64 `gorm:"type:numeric(15,3);default:0"`
|
||||
ExpenseNonstockId *uint64
|
||||
HasChickin bool `gorm:"-" json:"-"`
|
||||
|
||||
// Relations
|
||||
ExpenseNonstock *ExpenseNonstock `gorm:"foreignKey:ExpenseNonstockId;references:Id"`
|
||||
|
||||
@@ -12,9 +12,7 @@ type Recording struct {
|
||||
RecordDatetime time.Time `gorm:"column:record_datetime;not null"`
|
||||
Day *int `gorm:"column:day"`
|
||||
TotalDepletionQty *float64 `gorm:"column:total_depletion_qty"`
|
||||
TotalDepletionCumQty *float64 `gorm:"-"`
|
||||
CumDepletionRate *float64 `gorm:"column:cum_depletion_rate"`
|
||||
DepletionRate *float64 `gorm:"-"`
|
||||
CumIntake *int `gorm:"column:cum_intake"`
|
||||
FcrValue *float64 `gorm:"column:fcr_value"`
|
||||
TotalChickQty *float64 `gorm:"column:total_chick_qty"`
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
package entities
|
||||
|
||||
type RecordingDepletion struct {
|
||||
Id uint `gorm:"primaryKey"`
|
||||
RecordingId uint `gorm:"column:recording_id;not null;index"`
|
||||
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
|
||||
SourceProductWarehouseId *uint `gorm:"column:source_product_warehouse_id"`
|
||||
Qty float64 `gorm:"column:qty;not null"`
|
||||
UsageQty float64 `gorm:"column:usage_qty"`
|
||||
PendingQty float64 `gorm:"column:pending_qty"`
|
||||
Id uint `gorm:"primaryKey"`
|
||||
RecordingId uint `gorm:"column:recording_id;not null;index"`
|
||||
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
|
||||
Qty float64 `gorm:"column:qty;not null"`
|
||||
|
||||
Recording Recording `gorm:"foreignKey:RecordingId;references:Id"`
|
||||
ProductWarehouse ProductWarehouse `gorm:"foreignKey:ProductWarehouseId;references:Id"`
|
||||
|
||||
@@ -7,8 +7,6 @@ type RecordingEgg struct {
|
||||
RecordingId uint `gorm:"column:recording_id;not null;index"`
|
||||
ProductWarehouseId uint `gorm:"column:product_warehouse_id;not null"`
|
||||
Qty int `gorm:"column:qty;not null"`
|
||||
TotalQty float64 `gorm:"column:total_qty"`
|
||||
TotalUsed float64 `gorm:"column:total_used"`
|
||||
Weight *float64 `gorm:"column:weight"`
|
||||
CreatedBy uint `gorm:"column:created_by"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||
|
||||
@@ -10,9 +10,6 @@ const (
|
||||
StockAllocationStatusPending = "PENDING"
|
||||
StockAllocationStatusActive = "ACTIVE"
|
||||
StockAllocationStatusReleased = "RELEASED"
|
||||
|
||||
StockAllocationPurposeConsume = "CONSUME"
|
||||
StockAllocationPurposeTraceChickin = "TRACE_CHICKIN"
|
||||
)
|
||||
|
||||
// StockAllocation links a usable record (consumption) with an incoming stock record.
|
||||
@@ -25,8 +22,7 @@ type StockAllocation struct {
|
||||
UsableType string `gorm:"size:100;not null;index:stock_allocations_usage_lookup,priority:1"`
|
||||
UsableId uint `gorm:"not null;index:stock_allocations_usage_lookup,priority:2"`
|
||||
Qty float64 `gorm:"type:numeric(15,3);not null"`
|
||||
AllocationPurpose string `gorm:"size:32;not null;default:CONSUME;index:stock_allocations_purpose_status,priority:1"`
|
||||
Status string `gorm:"size:20;not null;default:ACTIVE;index:stock_allocations_purpose_status,priority:2"`
|
||||
Status string `gorm:"size:20;not null;default:ACTIVE"`
|
||||
Note *string `gorm:"type:text"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
||||
|
||||
@@ -9,7 +9,6 @@ type StockLog struct {
|
||||
|
||||
Increase float64 `gorm:"column:increase;type:numeric(15,3);default:0"`
|
||||
Decrease float64 `gorm:"column:decrease;type:numeric(15,3);default:0"`
|
||||
Stock float64 `gorm:"column:stock;type:numeric(15,3);not null;default:0"`
|
||||
|
||||
LoggableType string `gorm:"column:loggable_type;type:varchar(50);not null"`
|
||||
LoggableId uint `gorm:"column:loggable_id;not null"`
|
||||
|
||||
@@ -6,7 +6,7 @@ import "time"
|
||||
type StockTransferDelivery struct {
|
||||
Id uint64 `gorm:"primaryKey;autoIncrement"`
|
||||
StockTransferId uint64
|
||||
SupplierId *uint64
|
||||
SupplierId uint64
|
||||
VehiclePlate string
|
||||
DriverName string
|
||||
DocumentNumber string
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/config"
|
||||
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session"
|
||||
sso "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/verifier"
|
||||
service "gitlab.com/mbugroup/lti-api.git/internal/modules/users/services"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/sso"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
||||
)
|
||||
|
||||
@@ -24,10 +24,6 @@ type AuthContext struct {
|
||||
User *entity.User
|
||||
Roles []sso.Role
|
||||
Permissions map[string]struct{}
|
||||
UserAreaIDs []uint
|
||||
UserLocationIDs []uint
|
||||
UserAllArea bool
|
||||
UserAllLocation bool
|
||||
}
|
||||
|
||||
// Auth validates the incoming request against the central SSO access token and
|
||||
@@ -71,19 +67,15 @@ func Auth(userService service.UserService, requiredScopes ...string) fiber.Handl
|
||||
|
||||
var roles []sso.Role
|
||||
permissions := make(map[string]struct{})
|
||||
var profile *sso.UserProfile
|
||||
if verification.UserID != 0 {
|
||||
if p, err := sso.FetchProfile(c.Context(), token, verification); err != nil {
|
||||
if profile, err := sso.FetchProfile(c.Context(), token, verification); err != nil {
|
||||
utils.Log.WithError(err).Warn("auth: failed to fetch sso profile")
|
||||
} else {
|
||||
profile = p
|
||||
}
|
||||
}
|
||||
if profile != nil {
|
||||
roles = profile.Roles
|
||||
for _, perm := range profile.PermissionNames() {
|
||||
if perm != "" {
|
||||
permissions[perm] = struct{}{}
|
||||
} else if profile != nil {
|
||||
roles = profile.Roles
|
||||
for _, perm := range profile.PermissionNames() {
|
||||
if perm != "" {
|
||||
permissions[perm] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -94,16 +86,6 @@ func Auth(userService service.UserService, requiredScopes ...string) fiber.Handl
|
||||
User: user,
|
||||
Roles: roles,
|
||||
Permissions: permissions,
|
||||
UserAreaIDs: nil,
|
||||
UserLocationIDs: nil,
|
||||
UserAllArea: false,
|
||||
UserAllLocation: false,
|
||||
}
|
||||
if profile != nil {
|
||||
ctx.UserAreaIDs = profile.AreaIDs
|
||||
ctx.UserLocationIDs = profile.LocationIDs
|
||||
ctx.UserAllArea = profile.AllArea
|
||||
ctx.UserAllLocation = profile.AllLocation
|
||||
}
|
||||
|
||||
c.Locals(authContextLocalsKey, ctx)
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
package middleware
|
||||
|
||||
const (
|
||||
const(
|
||||
P_DashboardGetAll = "lti.dashboard.list"
|
||||
)
|
||||
|
||||
// project-flock
|
||||
const (
|
||||
P_ProjectFlockKandangsClosing = "lti.production.project_flock_kandangs.closing"
|
||||
@@ -52,7 +51,6 @@ const (
|
||||
P_ReportDebtSupplierGetAll = "lti.repport.debtsupplier.list"
|
||||
P_ReportHppPerKandangGetAll = "lti.repport.gethppperkandang.list"
|
||||
P_ReportProductionResultGetAll = "lti.repport.production_result.list"
|
||||
P_ReportCustomerPaymentGetAll = "lti.repport.customerpayment.list"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -153,7 +151,7 @@ const (
|
||||
P_ProductsCreateOne = "lti.master.products.create"
|
||||
P_ProductsUpdateOne = "lti.master.products.update"
|
||||
P_ProductsDeleteOne = "lti.master.products.delete"
|
||||
|
||||
|
||||
P_SuppliersGetAll = "lti.master.suppliers.list"
|
||||
P_SuppliersGetOne = "lti.master.suppliers.detail"
|
||||
P_SuppliersCreateOne = "lti.master.suppliers.create"
|
||||
@@ -240,15 +238,3 @@ const (
|
||||
P_UserGetAll = "lti.users.list"
|
||||
P_UserGetOne = "lti.users.detail"
|
||||
)
|
||||
|
||||
// daily-checklist
|
||||
const (
|
||||
P_DailyChecklistDashboardList = "lti.daily_checklist.dashboard.list"
|
||||
P_DailyChecklistCreateOne = "lti.daily_checklist.create"
|
||||
P_DailyChecklistGetAll = "lti.daily_checklist.list"
|
||||
P_DailyChecklistGetOne = "lti.daily_checklist.detail"
|
||||
P_DailyChecklistReports = "lti.daily_checklist.reports"
|
||||
P_DailyChecklistEmployee = "lti.daily_checklist.master_data.employee"
|
||||
P_DailyChecklistActivity = "lti.daily_checklist.master_data.activity"
|
||||
P_DailyChecklistActivityConfig = "lti.daily_checklist.master_data.configuration"
|
||||
)
|
||||
|
||||
@@ -1,636 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"gorm.io/gorm"
|
||||
|
||||
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||
)
|
||||
|
||||
type ScopeFilter struct {
|
||||
IDs []uint
|
||||
Restrict bool
|
||||
}
|
||||
|
||||
type roleScope struct {
|
||||
allArea bool
|
||||
allLocation bool
|
||||
areaIDs []uint
|
||||
locationIDs []uint
|
||||
hasAnyScopes bool
|
||||
}
|
||||
|
||||
func ResolveAreaScope(c *fiber.Ctx, db *gorm.DB) (ScopeFilter, error) {
|
||||
scope, err := collectRoleScope(c)
|
||||
if err != nil || !scope.hasAnyScopes {
|
||||
return ScopeFilter{}, err
|
||||
}
|
||||
|
||||
if scope.allArea || scope.allLocation {
|
||||
return ScopeFilter{}, nil
|
||||
}
|
||||
|
||||
allowed := uniqueUint(scope.areaIDs)
|
||||
if len(scope.locationIDs) > 0 {
|
||||
derived, err := areaIDsByLocationIDs(db, scope.locationIDs)
|
||||
if err != nil {
|
||||
return ScopeFilter{}, err
|
||||
}
|
||||
allowed = uniqueUint(append(allowed, derived...))
|
||||
}
|
||||
|
||||
if len(allowed) == 0 {
|
||||
return ScopeFilter{Restrict: true}, nil
|
||||
}
|
||||
return ScopeFilter{IDs: allowed, Restrict: true}, nil
|
||||
}
|
||||
|
||||
func ResolveLocationScope(c *fiber.Ctx, db *gorm.DB) (ScopeFilter, error) {
|
||||
scope, err := collectRoleScope(c)
|
||||
if err != nil || !scope.hasAnyScopes {
|
||||
return ScopeFilter{}, err
|
||||
}
|
||||
|
||||
if scope.allLocation || scope.allArea {
|
||||
return ScopeFilter{}, nil
|
||||
}
|
||||
|
||||
areaIDs := uniqueUint(scope.areaIDs)
|
||||
locationIDs := uniqueUint(scope.locationIDs)
|
||||
|
||||
switch {
|
||||
case len(locationIDs) > 0 && len(areaIDs) > 0:
|
||||
filtered, err := filterLocationIDsByAreaIDs(db, locationIDs, areaIDs)
|
||||
if err != nil {
|
||||
return ScopeFilter{}, err
|
||||
}
|
||||
locationIDs = filtered
|
||||
case len(locationIDs) == 0 && len(areaIDs) > 0:
|
||||
derived, err := locationIDsByAreaIDs(db, areaIDs)
|
||||
if err != nil {
|
||||
return ScopeFilter{}, err
|
||||
}
|
||||
locationIDs = derived
|
||||
}
|
||||
|
||||
locationIDs = uniqueUint(locationIDs)
|
||||
if len(locationIDs) == 0 {
|
||||
return ScopeFilter{Restrict: true}, nil
|
||||
}
|
||||
return ScopeFilter{IDs: locationIDs, Restrict: true}, nil
|
||||
}
|
||||
|
||||
func ResolveLocationAreaScopes(c *fiber.Ctx, db *gorm.DB) (ScopeFilter, ScopeFilter, error) {
|
||||
locationScope, err := ResolveLocationScope(c, db)
|
||||
if err != nil {
|
||||
return ScopeFilter{}, ScopeFilter{}, err
|
||||
}
|
||||
areaScope, err := ResolveAreaScope(c, db)
|
||||
if err != nil {
|
||||
return ScopeFilter{}, ScopeFilter{}, err
|
||||
}
|
||||
return locationScope, areaScope, nil
|
||||
}
|
||||
|
||||
func collectRoleScope(c *fiber.Ctx) (roleScope, error) {
|
||||
ctx, ok := AuthDetails(c)
|
||||
if !ok || ctx == nil {
|
||||
return roleScope{}, nil
|
||||
}
|
||||
|
||||
userAreaIDs := uniqueUint(ctx.UserAreaIDs)
|
||||
userLocationIDs := uniqueUint(ctx.UserLocationIDs)
|
||||
userScope := roleScope{
|
||||
allArea: ctx.UserAllArea,
|
||||
allLocation: ctx.UserAllLocation,
|
||||
areaIDs: userAreaIDs,
|
||||
locationIDs: userLocationIDs,
|
||||
hasAnyScopes: ctx.UserAllArea || ctx.UserAllLocation || len(userAreaIDs) > 0 || len(userLocationIDs) > 0,
|
||||
}
|
||||
if userScope.hasAnyScopes {
|
||||
return userScope, nil
|
||||
}
|
||||
|
||||
return roleScope{}, nil
|
||||
}
|
||||
|
||||
func areaIDsByLocationIDs(db *gorm.DB, locationIDs []uint) ([]uint, error) {
|
||||
if db == nil {
|
||||
return nil, errors.New("database not configured")
|
||||
}
|
||||
if len(locationIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var areaIDs []uint
|
||||
if err := db.Model(&entity.Location{}).
|
||||
Where("deleted_at IS NULL").
|
||||
Where("id IN ?", locationIDs).
|
||||
Distinct("area_id").
|
||||
Pluck("area_id", &areaIDs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return areaIDs, nil
|
||||
}
|
||||
|
||||
func locationIDsByAreaIDs(db *gorm.DB, areaIDs []uint) ([]uint, error) {
|
||||
if db == nil {
|
||||
return nil, errors.New("database not configured")
|
||||
}
|
||||
if len(areaIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var locationIDs []uint
|
||||
if err := db.Model(&entity.Location{}).
|
||||
Where("deleted_at IS NULL").
|
||||
Where("area_id IN ?", areaIDs).
|
||||
Distinct("id").
|
||||
Pluck("id", &locationIDs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return locationIDs, nil
|
||||
}
|
||||
|
||||
func filterLocationIDsByAreaIDs(db *gorm.DB, locationIDs, areaIDs []uint) ([]uint, error) {
|
||||
if db == nil {
|
||||
return nil, errors.New("database not configured")
|
||||
}
|
||||
if len(locationIDs) == 0 || len(areaIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var filtered []uint
|
||||
if err := db.Model(&entity.Location{}).
|
||||
Where("deleted_at IS NULL").
|
||||
Where("id IN ?", locationIDs).
|
||||
Where("area_id IN ?", areaIDs).
|
||||
Distinct("id").
|
||||
Pluck("id", &filtered).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
func uniqueUint(ids []uint) []uint {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[uint]struct{}, len(ids))
|
||||
result := make([]uint, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if id == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
result = append(result, id)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func ApplyScopeFilter(db *gorm.DB, scope ScopeFilter, column string) *gorm.DB {
|
||||
if db == nil || !scope.Restrict {
|
||||
return db
|
||||
}
|
||||
if len(scope.IDs) == 0 {
|
||||
return db.Where("1 = 0")
|
||||
}
|
||||
return db.Where(column+" IN ?", scope.IDs)
|
||||
}
|
||||
|
||||
func ApplyLocationScope(c *fiber.Ctx, db *gorm.DB, column string) (*gorm.DB, error) {
|
||||
scopeDB := db
|
||||
if db != nil {
|
||||
scopeDB = db.Session(&gorm.Session{NewDB: true})
|
||||
}
|
||||
scope, err := ResolveLocationScope(c, scopeDB)
|
||||
if err != nil {
|
||||
return db, err
|
||||
}
|
||||
return ApplyScopeFilter(db, scope, column), nil
|
||||
}
|
||||
|
||||
func ApplyAreaScope(c *fiber.Ctx, db *gorm.DB, column string) (*gorm.DB, error) {
|
||||
scopeDB := db
|
||||
if db != nil {
|
||||
scopeDB = db.Session(&gorm.Session{NewDB: true})
|
||||
}
|
||||
scope, err := ResolveAreaScope(c, scopeDB)
|
||||
if err != nil {
|
||||
return db, err
|
||||
}
|
||||
return ApplyScopeFilter(db, scope, column), nil
|
||||
}
|
||||
|
||||
func ApplyLocationAreaScope(c *fiber.Ctx, db *gorm.DB, locationColumn, areaColumn string) (*gorm.DB, error) {
|
||||
scopeDB := db
|
||||
if db != nil {
|
||||
scopeDB = db.Session(&gorm.Session{NewDB: true})
|
||||
}
|
||||
|
||||
if locationColumn != "" {
|
||||
locationScope, err := ResolveLocationScope(c, scopeDB)
|
||||
if err != nil {
|
||||
return db, err
|
||||
}
|
||||
db = ApplyScopeFilter(db, locationScope, locationColumn)
|
||||
}
|
||||
|
||||
if areaColumn != "" {
|
||||
areaScope, err := ResolveAreaScope(c, scopeDB)
|
||||
if err != nil {
|
||||
return db, err
|
||||
}
|
||||
db = ApplyScopeFilter(db, areaScope, areaColumn)
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func EnsureWarehouseAccess(c *fiber.Ctx, db *gorm.DB, warehouseID uint) error {
|
||||
if warehouseID == 0 {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "Invalid warehouse id")
|
||||
}
|
||||
if db == nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Database not configured")
|
||||
}
|
||||
|
||||
scope, err := ResolveLocationScope(c, db)
|
||||
if err != nil || !scope.Restrict {
|
||||
return err
|
||||
}
|
||||
if len(scope.IDs) == 0 {
|
||||
return fiber.NewError(fiber.StatusNotFound, "Warehouse not found")
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := ApplyScopeFilter(
|
||||
db.WithContext(c.Context()).
|
||||
Model(&entity.Warehouse{}).
|
||||
Where("id = ?", warehouseID),
|
||||
scope,
|
||||
"warehouses.location_id",
|
||||
).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return fiber.NewError(fiber.StatusNotFound, "Warehouse not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func EnsureAreaAccess(c *fiber.Ctx, db *gorm.DB, areaID uint) error {
|
||||
if areaID == 0 {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "Invalid area id")
|
||||
}
|
||||
if db == nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Database not configured")
|
||||
}
|
||||
|
||||
scope, err := ResolveAreaScope(c, db)
|
||||
if err != nil || !scope.Restrict {
|
||||
return err
|
||||
}
|
||||
if len(scope.IDs) == 0 {
|
||||
return fiber.NewError(fiber.StatusNotFound, "Area not found")
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := ApplyScopeFilter(
|
||||
db.WithContext(c.Context()).
|
||||
Model(&entity.Area{}).
|
||||
Where("id = ?", areaID),
|
||||
scope,
|
||||
"areas.id",
|
||||
).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return fiber.NewError(fiber.StatusNotFound, "Area not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func EnsureLocationAccess(c *fiber.Ctx, db *gorm.DB, locationID uint) error {
|
||||
if locationID == 0 {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "Invalid location id")
|
||||
}
|
||||
if db == nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Database not configured")
|
||||
}
|
||||
|
||||
scope, err := ResolveLocationScope(c, db)
|
||||
if err != nil || !scope.Restrict {
|
||||
return err
|
||||
}
|
||||
if len(scope.IDs) == 0 {
|
||||
return fiber.NewError(fiber.StatusNotFound, "Location not found")
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := ApplyScopeFilter(
|
||||
db.WithContext(c.Context()).
|
||||
Model(&entity.Location{}).
|
||||
Where("id = ?", locationID),
|
||||
scope,
|
||||
"locations.id",
|
||||
).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return fiber.NewError(fiber.StatusNotFound, "Location not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func EnsureKandangAccess(c *fiber.Ctx, db *gorm.DB, kandangID uint) error {
|
||||
if kandangID == 0 {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "Invalid kandang id")
|
||||
}
|
||||
if db == nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Database not configured")
|
||||
}
|
||||
|
||||
scope, err := ResolveLocationScope(c, db)
|
||||
if err != nil || !scope.Restrict {
|
||||
return err
|
||||
}
|
||||
if len(scope.IDs) == 0 {
|
||||
return fiber.NewError(fiber.StatusNotFound, "Kandang not found")
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := ApplyScopeFilter(
|
||||
db.WithContext(c.Context()).
|
||||
Model(&entity.Kandang{}).
|
||||
Where("id = ?", kandangID),
|
||||
scope,
|
||||
"kandangs.location_id",
|
||||
).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return fiber.NewError(fiber.StatusNotFound, "Kandang not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func EnsureProductWarehouseAccess(c *fiber.Ctx, db *gorm.DB, productWarehouseID uint) error {
|
||||
if productWarehouseID == 0 {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "Invalid product warehouse id")
|
||||
}
|
||||
if db == nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Database not configured")
|
||||
}
|
||||
|
||||
scope, err := ResolveLocationScope(c, db)
|
||||
if err != nil || !scope.Restrict {
|
||||
return err
|
||||
}
|
||||
if len(scope.IDs) == 0 {
|
||||
return fiber.NewError(fiber.StatusNotFound, "Product warehouse not found")
|
||||
}
|
||||
|
||||
var count int64
|
||||
q := db.WithContext(c.Context()).
|
||||
Table("product_warehouses pw").
|
||||
Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
|
||||
Where("pw.id = ?", productWarehouseID)
|
||||
q = ApplyScopeFilter(q, scope, "w.location_id")
|
||||
if err := q.Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return fiber.NewError(fiber.StatusNotFound, "Product warehouse not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func EnsureStockLogAccess(c *fiber.Ctx, db *gorm.DB, stockLogID uint) error {
|
||||
if stockLogID == 0 {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "Invalid stock log id")
|
||||
}
|
||||
if db == nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Database not configured")
|
||||
}
|
||||
|
||||
scope, err := ResolveLocationScope(c, db)
|
||||
if err != nil || !scope.Restrict {
|
||||
return err
|
||||
}
|
||||
if len(scope.IDs) == 0 {
|
||||
return fiber.NewError(fiber.StatusNotFound, "Stock log not found")
|
||||
}
|
||||
|
||||
var count int64
|
||||
q := db.WithContext(c.Context()).
|
||||
Table("stock_logs sl").
|
||||
Joins("JOIN product_warehouses pw ON pw.id = sl.product_warehouse_id").
|
||||
Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
|
||||
Where("sl.id = ?", stockLogID)
|
||||
q = ApplyScopeFilter(q, scope, "w.location_id")
|
||||
if err := q.Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return fiber.NewError(fiber.StatusNotFound, "Stock log not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func EnsureMarketingAccess(c *fiber.Ctx, db *gorm.DB, marketingID uint) error {
|
||||
if marketingID == 0 {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "Invalid marketing id")
|
||||
}
|
||||
if db == nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Database not configured")
|
||||
}
|
||||
|
||||
scope, err := ResolveLocationScope(c, db)
|
||||
if err != nil || !scope.Restrict {
|
||||
return err
|
||||
}
|
||||
if len(scope.IDs) == 0 {
|
||||
return fiber.NewError(fiber.StatusNotFound, "Marketing not found")
|
||||
}
|
||||
|
||||
var count int64
|
||||
q := db.WithContext(c.Context()).
|
||||
Table("marketings m").
|
||||
Joins("JOIN marketing_products mp ON mp.marketing_id = m.id").
|
||||
Joins("JOIN product_warehouses pw ON pw.id = mp.product_warehouse_id").
|
||||
Joins("JOIN warehouses w ON w.id = pw.warehouse_id").
|
||||
Where("m.id = ?", marketingID)
|
||||
q = ApplyScopeFilter(q, scope, "w.location_id")
|
||||
if err := q.Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return fiber.NewError(fiber.StatusNotFound, "Marketing not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func EnsureRecordingAccess(c *fiber.Ctx, db *gorm.DB, recordingID uint) error {
|
||||
if recordingID == 0 {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "Invalid recording id")
|
||||
}
|
||||
if db == nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Database not configured")
|
||||
}
|
||||
|
||||
scope, err := ResolveLocationScope(c, db)
|
||||
if err != nil || !scope.Restrict {
|
||||
return err
|
||||
}
|
||||
if len(scope.IDs) == 0 {
|
||||
return fiber.NewError(fiber.StatusNotFound, "Recording not found")
|
||||
}
|
||||
|
||||
var count int64
|
||||
q := db.WithContext(c.Context()).
|
||||
Table("recordings r").
|
||||
Joins("JOIN project_flock_kandangs pfk ON pfk.id = r.project_flock_kandangs_id").
|
||||
Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id").
|
||||
Where("r.id = ?", recordingID)
|
||||
q = ApplyScopeFilter(q, scope, "pf.location_id")
|
||||
if err := q.Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return fiber.NewError(fiber.StatusNotFound, "Recording not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func EnsureUniformityAccess(c *fiber.Ctx, db *gorm.DB, uniformityID uint) error {
|
||||
if uniformityID == 0 {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "Invalid uniformity id")
|
||||
}
|
||||
if db == nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Database not configured")
|
||||
}
|
||||
|
||||
scope, err := ResolveLocationScope(c, db)
|
||||
if err != nil || !scope.Restrict {
|
||||
return err
|
||||
}
|
||||
if len(scope.IDs) == 0 {
|
||||
return fiber.NewError(fiber.StatusNotFound, "Uniformity not found")
|
||||
}
|
||||
|
||||
var count int64
|
||||
q := db.WithContext(c.Context()).
|
||||
Table("project_flock_kandang_uniformity u").
|
||||
Joins("JOIN project_flock_kandangs pfk ON pfk.id = u.project_flock_kandang_id").
|
||||
Joins("JOIN project_flocks pf ON pf.id = pfk.project_flock_id").
|
||||
Where("u.id = ?", uniformityID)
|
||||
q = ApplyScopeFilter(q, scope, "pf.location_id")
|
||||
if err := q.Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return fiber.NewError(fiber.StatusNotFound, "Uniformity not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func EnsureLayingTransferAccess(c *fiber.Ctx, db *gorm.DB, transferID uint) error {
|
||||
if transferID == 0 {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "Invalid transfer id")
|
||||
}
|
||||
if db == nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Database not configured")
|
||||
}
|
||||
|
||||
scope, err := ResolveLocationScope(c, db)
|
||||
if err != nil || !scope.Restrict {
|
||||
return err
|
||||
}
|
||||
if len(scope.IDs) == 0 {
|
||||
return fiber.NewError(fiber.StatusNotFound, "Transfer not found")
|
||||
}
|
||||
|
||||
var count int64
|
||||
q := db.WithContext(c.Context()).
|
||||
Table("laying_transfers lt").
|
||||
Joins("JOIN project_flocks pf_from ON pf_from.id = lt.from_project_flock_id").
|
||||
Joins("JOIN project_flocks pf_to ON pf_to.id = lt.to_project_flock_id").
|
||||
Where("lt.id = ?", transferID).
|
||||
Where("(pf_from.location_id IN ? OR pf_to.location_id IN ?)", scope.IDs, scope.IDs)
|
||||
if err := q.Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return fiber.NewError(fiber.StatusNotFound, "Transfer not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func EnsureProjectFlockAccess(c *fiber.Ctx, db *gorm.DB, projectFlockID uint) error {
|
||||
if projectFlockID == 0 {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "Invalid project flock id")
|
||||
}
|
||||
if db == nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Database not configured")
|
||||
}
|
||||
|
||||
scope, err := ResolveLocationScope(c, db)
|
||||
if err != nil || !scope.Restrict {
|
||||
return err
|
||||
}
|
||||
if len(scope.IDs) == 0 {
|
||||
return fiber.NewError(fiber.StatusNotFound, "Project Flock not found")
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := ApplyScopeFilter(
|
||||
db.WithContext(c.Context()).
|
||||
Model(&entity.ProjectFlock{}).
|
||||
Where("id = ?", projectFlockID),
|
||||
scope,
|
||||
"project_flocks.location_id",
|
||||
).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return fiber.NewError(fiber.StatusNotFound, "Project Flock not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func EnsureProjectFlockKandangAccess(c *fiber.Ctx, db *gorm.DB, projectFlockID, projectFlockKandangID uint) error {
|
||||
if projectFlockKandangID == 0 {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "Invalid project flock kandang id")
|
||||
}
|
||||
if db == nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Database not configured")
|
||||
}
|
||||
|
||||
scope, err := ResolveLocationScope(c, db)
|
||||
if err != nil || !scope.Restrict {
|
||||
return err
|
||||
}
|
||||
if len(scope.IDs) == 0 {
|
||||
return fiber.NewError(fiber.StatusNotFound, "Project Flock Kandang not found")
|
||||
}
|
||||
|
||||
var count int64
|
||||
q := db.WithContext(c.Context()).
|
||||
Table("project_flock_kandangs").
|
||||
Joins("JOIN project_flocks ON project_flocks.id = project_flock_kandangs.project_flock_id").
|
||||
Where("project_flock_kandangs.id = ?", projectFlockKandangID)
|
||||
if projectFlockID > 0 {
|
||||
q = q.Where("project_flock_kandangs.project_flock_id = ?", projectFlockID)
|
||||
}
|
||||
q = ApplyScopeFilter(q, scope, "project_flocks.location_id")
|
||||
if err := q.Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return fiber.NewError(fiber.StatusNotFound, "Project Flock Kandang not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -44,15 +44,6 @@ func (u *ApprovalController) GetAll(c *fiber.Ctx) error {
|
||||
page := c.QueryInt("page", 1)
|
||||
limit := c.QueryInt("limit", 10)
|
||||
search := strings.TrimSpace(c.Query("search", ""))
|
||||
orderByDate := strings.TrimSpace(c.Query("order_by_date", ""))
|
||||
if orderByDate == "" {
|
||||
orderByDate = "DESC"
|
||||
} else {
|
||||
orderByDate = strings.ToUpper(orderByDate)
|
||||
if orderByDate != "ASC" && orderByDate != "DESC" {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "order_by_date must be either ASC or DESC")
|
||||
}
|
||||
}
|
||||
|
||||
query := &validation.Query{
|
||||
ModuleName: moduleName,
|
||||
@@ -61,7 +52,6 @@ func (u *ApprovalController) GetAll(c *fiber.Ctx) error {
|
||||
Page: page,
|
||||
Limit: limit,
|
||||
Search: search,
|
||||
OrderByDate: orderByDate,
|
||||
}
|
||||
|
||||
records, totalResults, err := u.ApprovalService.List(
|
||||
@@ -71,7 +61,6 @@ func (u *ApprovalController) GetAll(c *fiber.Ctx) error {
|
||||
query.Page,
|
||||
query.Limit,
|
||||
query.Search,
|
||||
query.OrderByDate,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -7,5 +7,4 @@ type Query struct {
|
||||
Page int `query:"page" validate:"omitempty,number,min=1"`
|
||||
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"`
|
||||
Search string `query:"search" validate:"omitempty,max=50"`
|
||||
OrderByDate string `query:"order_by_date" validate:"omitempty,oneof=ASC DESC"`
|
||||
}
|
||||
|
||||
@@ -14,45 +14,22 @@ import (
|
||||
)
|
||||
|
||||
type ClosingController struct {
|
||||
ClosingService service.ClosingService
|
||||
SapronakService service.SapronakService
|
||||
ClosingKeuanganService service.ClosingKeuanganService
|
||||
ClosingService service.ClosingService
|
||||
SapronakService service.SapronakService
|
||||
}
|
||||
|
||||
func NewClosingController(closingService service.ClosingService, sapronakService service.SapronakService, closingKeuanganService service.ClosingKeuanganService) *ClosingController {
|
||||
func NewClosingController(closingService service.ClosingService, sapronakService service.SapronakService) *ClosingController {
|
||||
return &ClosingController{
|
||||
ClosingService: closingService,
|
||||
SapronakService: sapronakService,
|
||||
ClosingKeuanganService: closingKeuanganService,
|
||||
ClosingService: closingService,
|
||||
SapronakService: sapronakService,
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ClosingController) GetAll(c *fiber.Ctx) error {
|
||||
var projectStatus *int
|
||||
if raw := c.Query("project_status"); raw != "" {
|
||||
statusValue, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "Invalid project_status")
|
||||
}
|
||||
projectStatus = &statusValue
|
||||
}
|
||||
|
||||
var locationID *uint
|
||||
if raw := c.Query("location_id"); raw != "" {
|
||||
locationValue, err := strconv.Atoi(raw)
|
||||
if err != nil || locationValue <= 0 {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "Invalid location_id")
|
||||
}
|
||||
locationUint := uint(locationValue)
|
||||
locationID = &locationUint
|
||||
}
|
||||
|
||||
query := &validation.Query{
|
||||
Page: c.QueryInt("page", 1),
|
||||
Limit: c.QueryInt("limit", 10),
|
||||
Search: c.Query("search", ""),
|
||||
ProjectStatus: projectStatus,
|
||||
LocationID: locationID,
|
||||
Page: c.QueryInt("page", 1),
|
||||
Limit: c.QueryInt("limit", 10),
|
||||
Search: c.Query("search", ""),
|
||||
}
|
||||
|
||||
if query.Page < 1 || query.Limit < 1 {
|
||||
@@ -181,7 +158,7 @@ func (u *ClosingController) GetPenjualan(c *fiber.Ctx) error {
|
||||
Code: fiber.StatusOK,
|
||||
Status: "success",
|
||||
Message: "Get closing penjualan successfully",
|
||||
Data: dto.ToPenjualanRealisasiResponseDTO(result),
|
||||
Data: dto.ToPenjualanRealisasiResponseDTO(uint(projectFlockID), result),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -211,7 +188,7 @@ func (u *ClosingController) GetPenjualanByProjectFlockKandang(c *fiber.Ctx) erro
|
||||
Code: fiber.StatusOK,
|
||||
Status: "success",
|
||||
Message: "Get closing penjualan by project flock kandang successfully",
|
||||
Data: dto.ToPenjualanRealisasiResponseDTO(result),
|
||||
Data: dto.ToPenjualanRealisasiResponseDTO(uint(projectFlockID), result),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -257,10 +234,9 @@ func (u *ClosingController) GetClosingSapronak(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
query := &validation.ClosingSapronakQuery{
|
||||
Type: strings.ToLower(c.Query("type")),
|
||||
Page: c.QueryInt("page", 1),
|
||||
Limit: c.QueryInt("limit", 10),
|
||||
Search: c.Query("search"),
|
||||
Type: strings.ToLower(c.Query("type")),
|
||||
Page: c.QueryInt("page", 1),
|
||||
Limit: c.QueryInt("limit", 10),
|
||||
}
|
||||
if raw := c.Query("kandang_id"); raw != "" {
|
||||
kandangInt, convErr := strconv.Atoi(raw)
|
||||
@@ -299,45 +275,6 @@ func (u *ClosingController) GetClosingSapronak(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
func (u *ClosingController) GetClosingSapronakSummary(c *fiber.Ctx) error {
|
||||
param := c.Params("projectFlockId")
|
||||
|
||||
id, err := strconv.Atoi(param)
|
||||
if err != nil || id <= 0 {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "Invalid projectFlockId")
|
||||
}
|
||||
|
||||
query := &validation.ClosingSapronakQuery{
|
||||
Type: strings.ToLower(c.Query("type")),
|
||||
Search: c.Query("search"),
|
||||
}
|
||||
if raw := c.Query("kandang_id"); raw != "" {
|
||||
kandangInt, convErr := strconv.Atoi(raw)
|
||||
if convErr != nil || kandangInt <= 0 {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "Invalid kandang_id")
|
||||
}
|
||||
kandangUint := uint(kandangInt)
|
||||
query.KandangID = &kandangUint
|
||||
}
|
||||
|
||||
if query.Type != validation.SapronakTypeIncoming && query.Type != validation.SapronakTypeOutgoing {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "type must be either incoming or outgoing")
|
||||
}
|
||||
|
||||
result, err := u.ClosingService.GetClosingSapronakSummary(c, uint(id), query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusOK).
|
||||
JSON(response.Success{
|
||||
Code: fiber.StatusOK,
|
||||
Status: "success",
|
||||
Message: "Retrieved closing report (sapronak summary) successfully",
|
||||
Data: result,
|
||||
})
|
||||
}
|
||||
|
||||
func (u *ClosingController) GetSapronakByProject(c *fiber.Ctx) error {
|
||||
param := c.Params("project_flock_id")
|
||||
flag := c.Query("flag", "")
|
||||
@@ -347,12 +284,12 @@ func (u *ClosingController) GetSapronakByProject(c *fiber.Ctx) error {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_id")
|
||||
}
|
||||
|
||||
result, productFlags, err := u.SapronakService.GetSapronakByProject(c, uint(projectID), flag)
|
||||
result, err := u.SapronakService.GetSapronakByProject(c, uint(projectID), flag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
payload := dto.ToSapronakProjectAggregatedFromReports(result, flag, productFlags)
|
||||
payload := dto.ToSapronakProjectAggregatedFromReports(result, flag)
|
||||
|
||||
return c.Status(fiber.StatusOK).
|
||||
JSON(response.Success{
|
||||
@@ -377,12 +314,12 @@ func (u *ClosingController) GetSapronakByKandang(c *fiber.Ctx) error {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_kandang_id")
|
||||
}
|
||||
|
||||
result, productFlags, err := u.SapronakService.GetSapronakByKandang(c, uint(projectID), uint(pfkID), flag)
|
||||
result, err := u.SapronakService.GetSapronakByKandang(c, uint(projectID), uint(pfkID), flag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
payload := dto.ToSapronakProjectAggregatedFromReport(result, flag, productFlags)
|
||||
payload := dto.ToSapronakProjectAggregatedFromReport(result, flag)
|
||||
|
||||
return c.Status(fiber.StatusOK).
|
||||
JSON(response.Success{
|
||||
@@ -401,7 +338,7 @@ func (u *ClosingController) GetClosingKeuangan(c *fiber.Ctx) error {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "Invalid Project Flock Id")
|
||||
}
|
||||
|
||||
result, err := u.ClosingKeuanganService.GetClosingKeuangan(c, uint(projectFlockID))
|
||||
result, err := u.ClosingService.GetClosingKeuangan(c, uint(projectFlockID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -415,34 +352,6 @@ func (u *ClosingController) GetClosingKeuangan(c *fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
func (u *ClosingController) GetClosingKeuanganByKandang(c *fiber.Ctx) error {
|
||||
projectParam := c.Params("project_flock_id")
|
||||
kandangParam := c.Params("project_flock_kandang_id")
|
||||
|
||||
projectFlockID, err := strconv.Atoi(projectParam)
|
||||
if err != nil || projectFlockID <= 0 {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_id")
|
||||
}
|
||||
|
||||
pfkID, err := strconv.Atoi(kandangParam)
|
||||
if err != nil || pfkID <= 0 {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "Invalid project_flock_kandang_id")
|
||||
}
|
||||
|
||||
result, err := u.ClosingKeuanganService.GetClosingKeuanganByKandang(c, uint(projectFlockID), uint(pfkID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusOK).
|
||||
JSON(response.Success{
|
||||
Code: fiber.StatusOK,
|
||||
Status: "success",
|
||||
Message: "Get closing keuangan by kandang successfully",
|
||||
Data: result,
|
||||
})
|
||||
}
|
||||
|
||||
func (u *ClosingController) GetExpeditionHPP(c *fiber.Ctx) error {
|
||||
param := c.Params("project_flock_id")
|
||||
|
||||
|
||||
@@ -98,26 +98,26 @@ type ClosingEggSalesDTO struct {
|
||||
}
|
||||
|
||||
type ClosingPerformanceDTO struct {
|
||||
Depletion float64 `json:"depletion"`
|
||||
Age float64 `json:"age_day"`
|
||||
MortalityStd float64 `json:"mor_std"`
|
||||
MortalityAct float64 `json:"mor_act"`
|
||||
DeffMortality float64 `json:"mor_diff"`
|
||||
FcrStd float64 `json:"fcr_std"`
|
||||
FcrAct float64 `json:"fcr_act"`
|
||||
DeffFcr float64 `json:"fcr_diff"`
|
||||
AwgAct float64 `json:"awg_act"`
|
||||
AwgStd float64 `json:"awg_std"`
|
||||
FeedIntake float64 `json:"feed_intake"`
|
||||
FeedIntakeStd float64 `json:"feed_intake_std"`
|
||||
HenDayAct float64 `json:"hen_day_act,omitempty"`
|
||||
HendayStd float64 `json:"hen_day_std"`
|
||||
EggMass float64 `json:"egg_mass,omitempty"`
|
||||
EggMassStd float64 `json:"egg_mass_std"`
|
||||
EggWeight float64 `json:"egg_weight,omitempty"`
|
||||
EggWeightStd float64 `json:"egg_weight_std"`
|
||||
HenHouseAct float64 `json:"hen_housed_act,omitempty"`
|
||||
HenHouseStd float64 `json:"hen_housed_std"`
|
||||
Depletion float64 `json:"depletion"`
|
||||
Age float64 `json:"age_day"`
|
||||
MortalityStd float64 `json:"mor_std"`
|
||||
MortalityAct float64 `json:"mor_act"`
|
||||
DeffMortality float64 `json:"mor_diff"`
|
||||
FcrStd float64 `json:"fcr_std"`
|
||||
FcrAct float64 `json:"fcr_act"`
|
||||
DeffFcr float64 `json:"fcr_diff"`
|
||||
AwgAct float64 `json:"awg_act"`
|
||||
AwgStd float64 `json:"awg_std"`
|
||||
FeedIntake float64 `json:"feed_intake"`
|
||||
FeedIntakeStd float64 `json:"feed_intake_std"`
|
||||
HenDayAct *float64 `json:"hen_day_act,omitempty"`
|
||||
HendayStd *float64 `json:"hen_day_std,omitempty"`
|
||||
EggMass *float64 `json:"egg_mass,omitempty"`
|
||||
EggMassStd *float64 `json:"egg_mass_std,omitempty"`
|
||||
EggWeight *float64 `json:"egg_weight,omitempty"`
|
||||
EggWeightStd *float64 `json:"egg_weight_std,omitempty"`
|
||||
HenHouseAct *float64 `json:"hen_housed_act,omitempty"`
|
||||
HenHouseStd *float64 `json:"hen_housed_std,omitempty"`
|
||||
}
|
||||
|
||||
type ClosingSalesGroupDTO struct {
|
||||
|
||||
@@ -1,31 +1,59 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
||||
)
|
||||
|
||||
type ClosingHPPCode string
|
||||
|
||||
// === CONSTANTS ===
|
||||
const (
|
||||
HPPCodePakan ClosingHPPCode = "PAKAN"
|
||||
HPPCodeOVK ClosingHPPCode = "OVK"
|
||||
HPPCodeDOC ClosingHPPCode = "DOC"
|
||||
HPPCodeDepresiasi ClosingHPPCode = "DEPRESIASI"
|
||||
HPPCodeOverhead ClosingHPPCode = "OVERHEAD"
|
||||
HPPCodeEkspedisi ClosingHPPCode = "EKSPEDISI"
|
||||
HPPGroupPengeluaran = "HPP dan Pengeluaran"
|
||||
HPPGroupBahanBaku = "HPP dan Bahan Baku"
|
||||
HPPLabelOverhead = "Pengeluaran Overhead"
|
||||
HPPLabelEkspedisi = "Beban Ekspedisi"
|
||||
HPPSummaryLabel = "HPP"
|
||||
|
||||
PLSalesTypeChicken = "Penjualan Ayam Besar"
|
||||
PLSalesTypeEgg = "Penjualan Telur"
|
||||
|
||||
PLItemTypeSapronak = "Pembelian Sapronak"
|
||||
PLItemTypeOverhead = "Pengeluaran Overhead"
|
||||
PLItemTypeEkspedisi = "Beban Ekspedisi"
|
||||
|
||||
PLSummaryLabelGrossProfit = "LABA RUGI BRUTTO"
|
||||
PLSummaryLabelSubTotal = "SUB TOTAL"
|
||||
PLSummaryLabelNetProfit = "LABA RUGI NETTO"
|
||||
|
||||
PurchaseLabelPrefix = "Pembelian "
|
||||
)
|
||||
|
||||
type ClosingProfitLossCode string
|
||||
// === CONTEXT STRUCTS ===
|
||||
|
||||
const (
|
||||
PLCodeSales ClosingProfitLossCode = "SALES"
|
||||
PLCodeSapronak ClosingProfitLossCode = "SAPRONAK"
|
||||
PLCodeOverhead ClosingProfitLossCode = "OVERHEAD"
|
||||
PLCodeEkspedisi ClosingProfitLossCode = "EKSPEDISI"
|
||||
)
|
||||
type CalculationContext struct {
|
||||
TotalPopulation float64
|
||||
TotalWeightProduced float64
|
||||
TotalEggWeightKg float64
|
||||
TotalDepletion float64
|
||||
TotalWeightSold float64
|
||||
ActualPopulation float64
|
||||
}
|
||||
|
||||
type ClosingKeuanganInput struct {
|
||||
ProjectFlockCategory string
|
||||
PurchaseItems []entities.PurchaseItem
|
||||
Budgets []entities.ProjectBudget
|
||||
Realizations []entities.ExpenseRealization
|
||||
DeliveryProducts []entities.MarketingDeliveryProduct
|
||||
Chickins []entities.ProjectChickin
|
||||
TotalWeightProduced float64
|
||||
TotalEggWeightKg float64
|
||||
TotalDepletion float64
|
||||
}
|
||||
|
||||
// === BASE METRICS ===
|
||||
|
||||
type FinancialMetrics struct {
|
||||
RpPerBird float64 `json:"rp_per_bird"`
|
||||
@@ -33,58 +61,75 @@ type FinancialMetrics struct {
|
||||
Amount float64 `json:"amount"`
|
||||
}
|
||||
|
||||
type HPPItem struct {
|
||||
ID uint `json:"id"`
|
||||
Category string `json:"category"`
|
||||
Code string `json:"code"`
|
||||
Label string `json:"label"`
|
||||
type Comparison struct {
|
||||
Budgeting FinancialMetrics `json:"budgeting"`
|
||||
Realization FinancialMetrics `json:"realization"`
|
||||
}
|
||||
|
||||
type HPPSummary struct {
|
||||
Label string `json:"label"`
|
||||
Budgeting FinancialMetrics `json:"budgeting"`
|
||||
Realization FinancialMetrics `json:"realization"`
|
||||
EggBudgeting *FinancialMetrics `json:"egg_budgeting,omitempty"`
|
||||
EggRealization *FinancialMetrics `json:"egg_realization,omitempty"`
|
||||
// === HPP PURCHASES PACKAGE ===
|
||||
|
||||
type HppItem struct {
|
||||
Type string `json:"type"`
|
||||
Comparison
|
||||
}
|
||||
|
||||
type HPPSection struct {
|
||||
Items []HPPItem `json:"items"`
|
||||
Summary HPPSummary `json:"summary"`
|
||||
type HppGroup struct {
|
||||
GroupName string `json:"group_name"`
|
||||
Data []HppItem `json:"data"`
|
||||
}
|
||||
|
||||
type ProfitLossItem struct {
|
||||
Code string `json:"code"`
|
||||
Label string `json:"label"`
|
||||
Type string `json:"type"`
|
||||
RpPerBird float64 `json:"rp_per_bird"`
|
||||
RpPerKg float64 `json:"rp_per_kg"`
|
||||
Amount float64 `json:"amount"`
|
||||
type SummaryHpp struct {
|
||||
Label string `json:"label"`
|
||||
Budgeting FinancialMetrics `json:"budgeting"`
|
||||
Realization FinancialMetrics `json:"realization"`
|
||||
EggBudgeting *FinancialMetrics `json:"egg_budgeting,omitempty"`
|
||||
EggRealization *FinancialMetrics `json:"egg_realization,omitempty"`
|
||||
}
|
||||
|
||||
type ProfitLossSummary struct {
|
||||
GrossProfit FinancialMetrics `json:"gross_profit"`
|
||||
SubTotal FinancialMetrics `json:"sub_total"`
|
||||
NetProfit FinancialMetrics `json:"net_profit"`
|
||||
type HppPurchasesSection struct {
|
||||
Hpp []HppGroup `json:"hpp"`
|
||||
SummaryHpp SummaryHpp `json:"summary_hpp"`
|
||||
}
|
||||
|
||||
// === PROFIT LOSS PACKAGE ===
|
||||
|
||||
type PLItem struct {
|
||||
Type string `json:"type"`
|
||||
FinancialMetrics
|
||||
}
|
||||
|
||||
type PLSummaryItem struct {
|
||||
Label string `json:"label"`
|
||||
FinancialMetrics
|
||||
}
|
||||
|
||||
type PLSummaryGroup struct {
|
||||
GrossProfit PLSummaryItem `json:"gross_profit"`
|
||||
SubTotal PLSummaryItem `json:"sub_total"`
|
||||
NetProfit PLSummaryItem `json:"net_profit"`
|
||||
}
|
||||
|
||||
type ProfitLossData struct {
|
||||
Penjualan []PLItem `json:"penjualan"`
|
||||
Pembelian []PLItem `json:"pembelian"`
|
||||
Overhead PLItem `json:"overhead"`
|
||||
Ekspedisi PLItem `json:"ekspedisi"`
|
||||
Summary PLSummaryGroup `json:"summary"`
|
||||
}
|
||||
|
||||
type ProfitLossSection struct {
|
||||
Items []ProfitLossItem `json:"items"`
|
||||
Summary ProfitLossSummary `json:"summary"`
|
||||
Data ProfitLossData `json:"data"`
|
||||
}
|
||||
|
||||
type ClosingKeuanganData struct {
|
||||
HPP HPPSection `json:"hpp"`
|
||||
ProfitLoss ProfitLossSection `json:"profit_loss"`
|
||||
}
|
||||
type MetricsCalculator struct {
|
||||
TotalPopulation float64
|
||||
ActualPopulation float64
|
||||
TotalWeightProduced float64
|
||||
// === RESPONSE DTO (ROOT) ===
|
||||
|
||||
type ReportResponse struct {
|
||||
HppPurchases HppPurchasesSection `json:"hpp_purchases"`
|
||||
ProfitLoss ProfitLossSection `json:"profit_loss"`
|
||||
}
|
||||
|
||||
// === MAPPER FUNCTIONS ===
|
||||
|
||||
func ToFinancialMetrics(rpPerBird, rpPerKg, amount float64) FinancialMetrics {
|
||||
return FinancialMetrics{
|
||||
RpPerBird: rpPerBird,
|
||||
@@ -93,133 +138,451 @@ func ToFinancialMetrics(rpPerBird, rpPerKg, amount float64) FinancialMetrics {
|
||||
}
|
||||
}
|
||||
|
||||
func ToHPPItem(id uint, category, code, label string, budgeting, realization FinancialMetrics) HPPItem {
|
||||
return HPPItem{
|
||||
ID: id,
|
||||
Category: category,
|
||||
Code: code,
|
||||
Label: label,
|
||||
func ToComparison(budgeting, realization FinancialMetrics) Comparison {
|
||||
return Comparison{
|
||||
Budgeting: budgeting,
|
||||
Realization: realization,
|
||||
}
|
||||
}
|
||||
|
||||
func ToHPPSummary(label string, budgeting, realization FinancialMetrics, eggBudgeting, eggRealization *FinancialMetrics) HPPSummary {
|
||||
return HPPSummary{
|
||||
Label: label,
|
||||
Budgeting: budgeting,
|
||||
Realization: realization,
|
||||
EggBudgeting: eggBudgeting,
|
||||
EggRealization: eggRealization,
|
||||
}
|
||||
// === HPP PENGELUARAN (from Purchase Items) ===
|
||||
|
||||
func getFlagLabel(flagType utils.FlagType) string {
|
||||
return PurchaseLabelPrefix + string(flagType)
|
||||
}
|
||||
|
||||
func ToHPPSection(items []HPPItem, summary HPPSummary) HPPSection {
|
||||
return HPPSection{
|
||||
Items: items,
|
||||
Summary: summary,
|
||||
func buildHppItemsByPurchaseFlags(purchaseItems []entities.PurchaseItem, ctx CalculationContext) []HppItem {
|
||||
flags := []utils.FlagType{
|
||||
utils.FlagDOC, utils.FlagPullet, utils.FlagLayer, utils.FlagPakan,
|
||||
utils.FlagPreStarter, utils.FlagStarter, utils.FlagFinisher,
|
||||
utils.FlagOVK, utils.FlagObat, utils.FlagVitamin, utils.FlagKimia,
|
||||
}
|
||||
}
|
||||
|
||||
func ToProfitLossItem(code, label, itemType string, rpPerBird, rpPerKg, amount float64) ProfitLossItem {
|
||||
return ProfitLossItem{
|
||||
Code: code,
|
||||
Label: label,
|
||||
Type: itemType,
|
||||
RpPerBird: rpPerBird,
|
||||
RpPerKg: rpPerKg,
|
||||
Amount: amount,
|
||||
}
|
||||
}
|
||||
items := []HppItem{}
|
||||
seenFlags := make(map[utils.FlagType]bool)
|
||||
|
||||
func ToProfitLossSummary(grossProfit, subTotal, netProfit FinancialMetrics) ProfitLossSummary {
|
||||
return ProfitLossSummary{
|
||||
GrossProfit: grossProfit,
|
||||
SubTotal: subTotal,
|
||||
NetProfit: netProfit,
|
||||
}
|
||||
}
|
||||
|
||||
func ToProfitLossSection(items []ProfitLossItem, summary ProfitLossSummary) ProfitLossSection {
|
||||
return ProfitLossSection{
|
||||
Items: items,
|
||||
Summary: summary,
|
||||
}
|
||||
}
|
||||
|
||||
func ToClosingKeuanganData(hpp HPPSection, profitLoss ProfitLossSection) ClosingKeuanganData {
|
||||
return ClosingKeuanganData{
|
||||
HPP: hpp,
|
||||
ProfitLoss: profitLoss,
|
||||
}
|
||||
}
|
||||
|
||||
func (mc *MetricsCalculator) CalculateMetrics(amount float64) (rpPerBird, rpPerKg float64) {
|
||||
if mc.ActualPopulation > 0 {
|
||||
rpPerBird = amount / mc.ActualPopulation
|
||||
}
|
||||
if mc.TotalWeightProduced > 0 {
|
||||
rpPerKg = amount / mc.TotalWeightProduced
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (mc *MetricsCalculator) CalculateProfitLossMetrics(amount float64) (rpPerBird, rpPerKg float64) {
|
||||
if mc.TotalPopulation > 0 {
|
||||
rpPerBird = amount / mc.TotalPopulation
|
||||
}
|
||||
if mc.TotalWeightProduced > 0 {
|
||||
rpPerKg = amount / mc.TotalWeightProduced
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
type ProductFilter struct {
|
||||
ProjectFlockCategory string
|
||||
}
|
||||
|
||||
func (pf *ProductFilter) IsEggProduct(product entity.Product) bool {
|
||||
for _, flag := range product.Flags {
|
||||
flagName := strings.ToUpper(flag.Name)
|
||||
if flagName == string(utils.FlagTelur) ||
|
||||
flagName == string(utils.FlagTelurUtuh) ||
|
||||
flagName == string(utils.FlagTelurPecah) ||
|
||||
flagName == string(utils.FlagTelurPutih) ||
|
||||
flagName == string(utils.FlagTelurRetak) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (pf *ProductFilter) IsChickenProduct(product entity.Product) bool {
|
||||
for _, flag := range product.Flags {
|
||||
flagName := strings.ToUpper(flag.Name)
|
||||
if flagName == string(utils.FlagAyamAfkir) ||
|
||||
flagName == string(utils.FlagAyamCulling) ||
|
||||
flagName == string(utils.FlagAyamMati) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (pf *ProductFilter) ShouldIncludeProduct(product entity.Product) bool {
|
||||
if pf.ProjectFlockCategory == string(utils.ProjectFlockCategoryLaying) {
|
||||
return pf.IsEggProduct(product)
|
||||
}
|
||||
return pf.IsChickenProduct(product) || (!pf.IsEggProduct(product) && !pf.IsChickenProduct(product))
|
||||
}
|
||||
|
||||
func (pf *ProductFilter) FilterDeliveryProducts(deliveries []entity.MarketingDeliveryProduct) []entity.MarketingDeliveryProduct {
|
||||
filtered := make([]entity.MarketingDeliveryProduct, 0)
|
||||
for _, delivery := range deliveries {
|
||||
if delivery.MarketingProduct.ProductWarehouse.Product.Id == 0 {
|
||||
for _, item := range purchaseItems {
|
||||
if item.Product == nil || len(item.Product.Flags) == 0 {
|
||||
continue
|
||||
}
|
||||
if pf.ShouldIncludeProduct(delivery.MarketingProduct.ProductWarehouse.Product) {
|
||||
filtered = append(filtered, delivery)
|
||||
|
||||
for _, flag := range item.Product.Flags {
|
||||
flagType := utils.FlagType(flag.Name)
|
||||
|
||||
if slices.Contains(flags, flagType) && !seenFlags[flagType] {
|
||||
amount := sumPurchasesByFlag(purchaseItems, flagType)
|
||||
rpPerBird, rpPerKg := calculatePerUnitMetrics(amount, ctx.TotalPopulation, ctx.TotalWeightProduced)
|
||||
|
||||
items = append(items, HppItem{
|
||||
Type: getFlagLabel(flagType),
|
||||
Comparison: ToComparison(
|
||||
ToFinancialMetrics(rpPerBird, rpPerKg, amount),
|
||||
ToFinancialMetrics(rpPerBird, rpPerKg, amount),
|
||||
),
|
||||
})
|
||||
seenFlags[flagType] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
// === HPP BAHAN BAKU (from ProjectBudget + ExpenseRealization) ===
|
||||
|
||||
func createHppOverheadItem(budgetAmount, realizationAmount float64, ctx CalculationContext) HppItem {
|
||||
budgetRpPerBird, budgetRpPerKg := calculatePerUnitMetrics(budgetAmount, ctx.TotalPopulation, ctx.TotalWeightProduced)
|
||||
realizationRpPerBird, realizationRpPerKg := calculatePerUnitMetrics(realizationAmount, ctx.TotalPopulation, ctx.TotalWeightProduced)
|
||||
|
||||
return HppItem{
|
||||
Type: HPPLabelOverhead,
|
||||
Comparison: ToComparison(
|
||||
ToFinancialMetrics(budgetRpPerBird, budgetRpPerKg, budgetAmount),
|
||||
ToFinancialMetrics(realizationRpPerBird, realizationRpPerKg, realizationAmount),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
func createHppEkspedisiItem(ekspedisiAmount float64, ctx CalculationContext) HppItem {
|
||||
ekspedisiRpPerBird, ekspedisiRpPerKg := calculatePerUnitMetrics(ekspedisiAmount, ctx.TotalPopulation, ctx.TotalWeightProduced)
|
||||
|
||||
return HppItem{
|
||||
Type: HPPLabelEkspedisi,
|
||||
Comparison: ToComparison(
|
||||
ToFinancialMetrics(ekspedisiRpPerBird, ekspedisiRpPerKg, ekspedisiAmount),
|
||||
ToFinancialMetrics(ekspedisiRpPerBird, ekspedisiRpPerKg, ekspedisiAmount),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
func ToHppBahanBakuGroup(budgets []entities.ProjectBudget, realizations []entities.ExpenseRealization, ctx CalculationContext) HppGroup {
|
||||
items := []HppItem{}
|
||||
|
||||
budgetAmount := sumBudgetsByFilter(budgets, func(*entities.ProjectBudget) bool { return true })
|
||||
realizationAmount := getOperationalExpenses(realizations)
|
||||
|
||||
if budgetAmount > 0 || realizationAmount > 0 {
|
||||
items = append(items, createHppOverheadItem(budgetAmount, realizationAmount, ctx))
|
||||
}
|
||||
|
||||
ekspedisiAmount := sumRealizationsByFilter(realizations, filterRealizationByNonstockFlag(utils.FlagEkspedisi))
|
||||
items = append(items, createHppEkspedisiItem(ekspedisiAmount, ctx))
|
||||
|
||||
return HppGroup{
|
||||
GroupName: HPPGroupBahanBaku,
|
||||
Data: items,
|
||||
}
|
||||
}
|
||||
|
||||
// === HPP SUMMARY ===
|
||||
|
||||
func ToSummaryHpp(label string, purchaseItems []entities.PurchaseItem, budgets []entities.ProjectBudget, realizations []entities.ExpenseRealization, projectFlockCategory string, ctx CalculationContext) SummaryHpp {
|
||||
purchaseTotal := sumPurchaseTotal(purchaseItems)
|
||||
budgetTotal := sumBudgetsByFilter(budgets, func(*entities.ProjectBudget) bool { return true })
|
||||
totalBudget := purchaseTotal + budgetTotal
|
||||
|
||||
totalRealization := sumRealizationsByFilter(realizations, func(*entities.ExpenseRealization) bool { return true })
|
||||
|
||||
budgetRpPerBird, budgetRpPerKg := calculatePerUnitMetrics(totalBudget, ctx.TotalPopulation, ctx.TotalWeightProduced)
|
||||
realizationRpPerBird, realizationRpPerKg := calculatePerUnitMetrics(totalRealization, ctx.TotalPopulation, ctx.TotalWeightProduced)
|
||||
|
||||
summary := SummaryHpp{
|
||||
Label: label,
|
||||
Budgeting: ToFinancialMetrics(budgetRpPerBird, budgetRpPerKg, totalBudget),
|
||||
Realization: ToFinancialMetrics(realizationRpPerBird, realizationRpPerKg, totalRealization),
|
||||
}
|
||||
|
||||
if projectFlockCategory == string(utils.ProjectFlockCategoryLaying) && ctx.TotalEggWeightKg > 0 {
|
||||
budgetEggRpPerKg, _ := calculatePerUnitMetrics(totalBudget, 0, ctx.TotalEggWeightKg)
|
||||
realizationEggRpPerKg, _ := calculatePerUnitMetrics(totalRealization, 0, ctx.TotalEggWeightKg)
|
||||
|
||||
summary.EggBudgeting = &FinancialMetrics{
|
||||
RpPerBird: 0,
|
||||
RpPerKg: budgetEggRpPerKg,
|
||||
Amount: totalBudget,
|
||||
}
|
||||
summary.EggRealization = &FinancialMetrics{
|
||||
RpPerBird: 0,
|
||||
RpPerKg: realizationEggRpPerKg,
|
||||
Amount: totalRealization,
|
||||
}
|
||||
}
|
||||
|
||||
return summary
|
||||
}
|
||||
|
||||
func ToHppPurchasesSection(purchaseItems []entities.PurchaseItem, budgets []entities.ProjectBudget, realizations []entities.ExpenseRealization, projectFlockCategory string, ctx CalculationContext) HppPurchasesSection {
|
||||
hppGroups := []HppGroup{
|
||||
{
|
||||
GroupName: HPPGroupPengeluaran,
|
||||
Data: buildHppItemsByPurchaseFlags(purchaseItems, ctx),
|
||||
},
|
||||
ToHppBahanBakuGroup(budgets, realizations, ctx),
|
||||
}
|
||||
|
||||
summaryHpp := ToSummaryHpp(HPPSummaryLabel, purchaseItems, budgets, realizations, projectFlockCategory, ctx)
|
||||
|
||||
return HppPurchasesSection{
|
||||
Hpp: hppGroups,
|
||||
SummaryHpp: summaryHpp,
|
||||
}
|
||||
}
|
||||
|
||||
// === PROFIT & LOSS ===
|
||||
|
||||
func ToPLItem(itemType string, metrics FinancialMetrics) PLItem {
|
||||
return PLItem{
|
||||
Type: itemType,
|
||||
FinancialMetrics: metrics,
|
||||
}
|
||||
}
|
||||
|
||||
func ToPLSummaryItem(label string, metrics FinancialMetrics) PLSummaryItem {
|
||||
return PLSummaryItem{
|
||||
Label: label,
|
||||
FinancialMetrics: metrics,
|
||||
}
|
||||
}
|
||||
|
||||
func createPLItemWithMetrics(itemType string, amount float64, ctx CalculationContext) PLItem {
|
||||
rpPerBird, rpPerKg := calculatePerUnitMetrics(amount, ctx.ActualPopulation, ctx.TotalWeightProduced)
|
||||
return ToPLItem(itemType, ToFinancialMetrics(rpPerBird, rpPerKg, amount))
|
||||
}
|
||||
|
||||
func sumPLItems(items []PLItem) (totalAmount, totalPerBird float64) {
|
||||
for _, item := range items {
|
||||
totalAmount += item.Amount
|
||||
totalPerBird += item.RpPerBird
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func createPenjualanItem(salesType string, amount float64, ctx CalculationContext) PLItem {
|
||||
rpPerBird, rpPerKg := calculatePerUnitMetrics(amount, ctx.ActualPopulation, ctx.TotalWeightSold)
|
||||
return ToPLItem(salesType, ToFinancialMetrics(rpPerBird, rpPerKg, amount))
|
||||
}
|
||||
|
||||
func ToPenjualanItems(projectFlockCategory string, deliveryProducts []entities.MarketingDeliveryProduct, ctx CalculationContext) []PLItem {
|
||||
items := []PLItem{}
|
||||
|
||||
categorized := categorizeDeliveriesBySalesType(deliveryProducts)
|
||||
|
||||
if projectFlockCategory == string(utils.ProjectFlockCategoryLaying) {
|
||||
ayamAmount := sumDeliveriesByCategory(categorized[PLSalesTypeChicken])
|
||||
telurAmount := sumDeliveriesByCategory(categorized[PLSalesTypeEgg])
|
||||
|
||||
items = append(items, createPenjualanItem(PLSalesTypeChicken, ayamAmount, ctx))
|
||||
items = append(items, createPenjualanItem(PLSalesTypeEgg, telurAmount, ctx))
|
||||
} else {
|
||||
ayamAmount := sumDeliveriesByCategory(categorized[PLSalesTypeChicken])
|
||||
items = append(items, createPenjualanItem(PLSalesTypeChicken, ayamAmount, ctx))
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
func ToPembelianItems(purchases []entities.PurchaseItem, realizations []entities.ExpenseRealization, ctx CalculationContext) []PLItem {
|
||||
purchaseAmount := sumPurchaseTotal(purchases)
|
||||
|
||||
return []PLItem{
|
||||
createPLItemWithMetrics(PLItemTypeSapronak, purchaseAmount, ctx),
|
||||
}
|
||||
}
|
||||
|
||||
func ToOverheadItems(realizations []entities.ExpenseRealization, ctx CalculationContext) []PLItem {
|
||||
realizationAmount := getOperationalExpenses(realizations)
|
||||
return []PLItem{
|
||||
createPLItemWithMetrics(PLItemTypeOverhead, realizationAmount, ctx),
|
||||
}
|
||||
}
|
||||
|
||||
func ToEkspedisiItems(realizations []entities.ExpenseRealization, ctx CalculationContext) []PLItem {
|
||||
amount := sumRealizationsByFilter(realizations, filterRealizationByNonstockFlag(utils.FlagEkspedisi))
|
||||
return []PLItem{
|
||||
createPLItemWithMetrics(PLItemTypeEkspedisi, amount, ctx),
|
||||
}
|
||||
}
|
||||
|
||||
func ToPLSummaryGroup(penjualanItems, pembelianItems, overheadItems, ekspedisiItems []PLItem) PLSummaryGroup {
|
||||
totalPenjualan, totalPenjualanPerBird := sumPLItems(penjualanItems)
|
||||
totalPembelian, totalPembelianPerBird := sumPLItems(pembelianItems)
|
||||
totalOverhead, totalOverheadPerBird := sumPLItems(overheadItems)
|
||||
totalEkspedisi, totalEkspedisiPerBird := sumPLItems(ekspedisiItems)
|
||||
|
||||
grossProfit := totalPenjualan - totalPembelian
|
||||
grossProfitPerBird := totalPenjualanPerBird - totalPembelianPerBird
|
||||
|
||||
totalOtherExpenses := totalOverhead + totalEkspedisi
|
||||
totalOtherExpensesPerBird := totalOverheadPerBird + totalEkspedisiPerBird
|
||||
|
||||
netProfit := grossProfit - totalOtherExpenses
|
||||
netProfitPerBird := grossProfitPerBird - totalOtherExpensesPerBird
|
||||
|
||||
return PLSummaryGroup{
|
||||
GrossProfit: ToPLSummaryItem(PLSummaryLabelGrossProfit, ToFinancialMetrics(grossProfitPerBird, 0, grossProfit)),
|
||||
SubTotal: ToPLSummaryItem(PLSummaryLabelSubTotal, ToFinancialMetrics(totalOtherExpensesPerBird, 0, totalOtherExpenses)),
|
||||
NetProfit: ToPLSummaryItem(PLSummaryLabelNetProfit, ToFinancialMetrics(netProfitPerBird, 0, netProfit)),
|
||||
}
|
||||
}
|
||||
|
||||
func ToProfitLossData(penjualanItems, pembelianItems, overheadItems, ekspedisiItems []PLItem) ProfitLossData {
|
||||
summary := ToPLSummaryGroup(penjualanItems, pembelianItems, overheadItems, ekspedisiItems)
|
||||
|
||||
totalOverhead := aggregatePLItems(overheadItems, PLItemTypeOverhead)
|
||||
totalEkspedisi := aggregatePLItems(ekspedisiItems, PLItemTypeEkspedisi)
|
||||
|
||||
return ProfitLossData{
|
||||
Penjualan: penjualanItems,
|
||||
Pembelian: pembelianItems,
|
||||
Overhead: totalOverhead,
|
||||
Ekspedisi: totalEkspedisi,
|
||||
Summary: summary,
|
||||
}
|
||||
}
|
||||
|
||||
func ToProfitLossSection(penjualanItems, pembelianItems, overheadItems, ekspedisiItems []PLItem) ProfitLossSection {
|
||||
return ProfitLossSection{
|
||||
Data: ToProfitLossData(penjualanItems, pembelianItems, overheadItems, ekspedisiItems),
|
||||
}
|
||||
}
|
||||
|
||||
func aggregatePLItems(items []PLItem, label string) PLItem {
|
||||
totalAmount, totalPerBird := sumPLItems(items)
|
||||
return ToPLItem(label, ToFinancialMetrics(totalPerBird, 0, totalAmount))
|
||||
}
|
||||
|
||||
func ToReportResponse(hppPurchases HppPurchasesSection, profitLoss ProfitLossSection) ReportResponse {
|
||||
return ReportResponse{
|
||||
HppPurchases: hppPurchases,
|
||||
ProfitLoss: profitLoss,
|
||||
}
|
||||
}
|
||||
|
||||
func ToClosingKeuanganReport(input ClosingKeuanganInput) ReportResponse {
|
||||
var totalPopulation float64
|
||||
var totalWeightSold float64
|
||||
|
||||
for _, chickin := range input.Chickins {
|
||||
totalPopulation += chickin.UsageQty
|
||||
}
|
||||
|
||||
for _, delivery := range input.DeliveryProducts {
|
||||
totalWeightSold += delivery.TotalWeight
|
||||
}
|
||||
|
||||
ctx := CalculationContext{
|
||||
TotalPopulation: totalPopulation,
|
||||
TotalWeightProduced: input.TotalWeightProduced,
|
||||
TotalEggWeightKg: input.TotalEggWeightKg,
|
||||
TotalDepletion: input.TotalDepletion,
|
||||
TotalWeightSold: totalWeightSold,
|
||||
ActualPopulation: totalPopulation - input.TotalDepletion,
|
||||
}
|
||||
|
||||
hppSection := ToHppPurchasesSection(input.PurchaseItems, input.Budgets, input.Realizations, input.ProjectFlockCategory, ctx)
|
||||
penjualanItems := ToPenjualanItems(input.ProjectFlockCategory, input.DeliveryProducts, ctx)
|
||||
pembelianItems := ToPembelianItems(input.PurchaseItems, input.Realizations, ctx)
|
||||
overheadItems := ToOverheadItems(input.Realizations, ctx)
|
||||
ekspedisiItems := ToEkspedisiItems(input.Realizations, ctx)
|
||||
plSection := ToProfitLossSection(penjualanItems, pembelianItems, overheadItems, ekspedisiItems)
|
||||
|
||||
return ToReportResponse(hppSection, plSection)
|
||||
}
|
||||
|
||||
// === HELPER FUNCTIONS ===
|
||||
|
||||
func calculatePerUnitMetrics(amount, totalPopulation, totalWeightSold float64) (rpPerBird, rpPerKg float64) {
|
||||
if totalPopulation > 0 {
|
||||
rpPerBird = amount / totalPopulation
|
||||
}
|
||||
if totalWeightSold > 0 {
|
||||
rpPerKg = amount / totalWeightSold
|
||||
}
|
||||
return rpPerBird, rpPerKg
|
||||
}
|
||||
|
||||
func hasProductFlag(flags []entities.Flag, flagType utils.FlagType) bool {
|
||||
for _, flag := range flags {
|
||||
if strings.ToUpper(flag.Name) == string(flagType) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func filterByPurchaseFlag(flagType utils.FlagType) func(*entities.PurchaseItem) bool {
|
||||
return func(item *entities.PurchaseItem) bool {
|
||||
if item.Product == nil || len(item.Product.Flags) == 0 {
|
||||
return false
|
||||
}
|
||||
return hasProductFlag(item.Product.Flags, flagType)
|
||||
}
|
||||
}
|
||||
|
||||
func filterRealizationByNonstockFlag(flagType utils.FlagType) func(*entities.ExpenseRealization) bool {
|
||||
return func(realization *entities.ExpenseRealization) bool {
|
||||
if realization.ExpenseNonstock == nil || realization.ExpenseNonstock.Nonstock == nil {
|
||||
return false
|
||||
}
|
||||
return hasProductFlag(realization.ExpenseNonstock.Nonstock.Flags, flagType)
|
||||
}
|
||||
}
|
||||
|
||||
func filterRealizationExceptFlag(flagType utils.FlagType) func(*entities.ExpenseRealization) bool {
|
||||
hasFlag := filterRealizationByNonstockFlag(flagType)
|
||||
return func(realization *entities.ExpenseRealization) bool {
|
||||
return !hasFlag(realization)
|
||||
}
|
||||
}
|
||||
|
||||
func sumByFilter[T any](items []T, extractor func(*T) float64, filter func(*T) bool) float64 {
|
||||
amount := 0.0
|
||||
for i := range items {
|
||||
if filter(&items[i]) {
|
||||
amount += extractor(&items[i])
|
||||
}
|
||||
}
|
||||
return amount
|
||||
}
|
||||
|
||||
func sumPurchasesByFilter(purchases []entities.PurchaseItem, filter func(*entities.PurchaseItem) bool) float64 {
|
||||
return sumByFilter(purchases, func(p *entities.PurchaseItem) float64 { return p.TotalPrice }, filter)
|
||||
}
|
||||
|
||||
func sumPurchasesByFlag(purchases []entities.PurchaseItem, flagType utils.FlagType) float64 {
|
||||
return sumPurchasesByFilter(purchases, filterByPurchaseFlag(flagType))
|
||||
}
|
||||
|
||||
func sumPurchaseTotal(purchases []entities.PurchaseItem) float64 {
|
||||
return sumByFilter(purchases, func(p *entities.PurchaseItem) float64 { return p.TotalPrice }, func(*entities.PurchaseItem) bool { return true })
|
||||
}
|
||||
|
||||
func sumBudgetsByFilter(budgets []entities.ProjectBudget, filter func(*entities.ProjectBudget) bool) float64 {
|
||||
return sumByFilter(budgets, func(b *entities.ProjectBudget) float64 { return b.Price * b.Qty }, filter)
|
||||
}
|
||||
|
||||
func sumRealizationsByFilter(realizations []entities.ExpenseRealization, filter func(*entities.ExpenseRealization) bool) float64 {
|
||||
return sumByFilter(realizations, func(r *entities.ExpenseRealization) float64 { return r.Price * r.Qty }, filter)
|
||||
}
|
||||
|
||||
func getOperationalExpenses(realizations []entities.ExpenseRealization) float64 {
|
||||
return sumRealizationsByFilter(realizations, filterRealizationExceptFlag(utils.FlagEkspedisi))
|
||||
}
|
||||
|
||||
func isChickenProductFlag(flagType utils.FlagType) bool {
|
||||
switch flagType {
|
||||
case utils.FlagDOC, utils.FlagPullet, utils.FlagLayer,
|
||||
utils.FlagAyamAfkir, utils.FlagAyamCulling, utils.FlagAyamMati:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isEggProductFlag(flagType utils.FlagType) bool {
|
||||
switch flagType {
|
||||
case utils.FlagTelur, utils.FlagTelurUtuh, utils.FlagTelurPecah,
|
||||
utils.FlagTelurPutih, utils.FlagTelurRetak:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func getSalesTypeFromProductFlags(product *entities.Product) string {
|
||||
if product == nil || len(product.Flags) == 0 {
|
||||
return PLSalesTypeChicken
|
||||
}
|
||||
|
||||
for _, flag := range product.Flags {
|
||||
flagType := utils.FlagType(strings.ToUpper(flag.Name))
|
||||
|
||||
if isEggProductFlag(flagType) {
|
||||
return PLSalesTypeEgg
|
||||
}
|
||||
if isChickenProductFlag(flagType) {
|
||||
return PLSalesTypeChicken
|
||||
}
|
||||
}
|
||||
|
||||
return PLSalesTypeChicken
|
||||
}
|
||||
|
||||
func categorizeDeliveriesBySalesType(deliveries []entities.MarketingDeliveryProduct) map[string][]entities.MarketingDeliveryProduct {
|
||||
categorized := make(map[string][]entities.MarketingDeliveryProduct)
|
||||
|
||||
for _, delivery := range deliveries {
|
||||
product := delivery.MarketingProduct.ProductWarehouse.Product
|
||||
salesType := getSalesTypeFromProductFlags(&product)
|
||||
|
||||
categorized[salesType] = append(categorized[salesType], delivery)
|
||||
}
|
||||
|
||||
return categorized
|
||||
}
|
||||
|
||||
func sumDeliveriesByCategory(deliveries []entities.MarketingDeliveryProduct) float64 {
|
||||
amount := 0.0
|
||||
for _, delivery := range deliveries {
|
||||
amount += delivery.TotalPrice
|
||||
}
|
||||
return amount
|
||||
}
|
||||
|
||||
@@ -8,54 +8,34 @@ import (
|
||||
customerDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/customers/dto"
|
||||
kandangDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/kandangs/dto"
|
||||
productDTO "gitlab.com/mbugroup/lti-api.git/internal/modules/master/products/dto"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
||||
)
|
||||
|
||||
// === Response DTO ===
|
||||
type SalesDTO struct {
|
||||
Id uint `json:"id"`
|
||||
RealizationDate time.Time `json:"realization_date"`
|
||||
Age int `json:"age"`
|
||||
Week int `json:"week"`
|
||||
DoNumber string `json:"do_number"`
|
||||
Product *productDTO.ProductRelationDTO `json:"product,omitempty"`
|
||||
Customer *customerDTO.CustomerRelationDTO `json:"customer,omitempty"`
|
||||
Qty float64 `json:"qty"`
|
||||
Weight float64 `json:"weight"`
|
||||
AvgWeight float64 `json:"avg_weight"`
|
||||
SalesPrice float64 `json:"sales_price"`
|
||||
TotalSalesPrice float64 `json:"total_sales_price"`
|
||||
ActualPrice float64 `json:"actual_price"`
|
||||
TotalActualPrice float64 `json:"total_actual_price"`
|
||||
Kandang *kandangDTO.KandangRelationDTO `json:"kandang,omitempty"`
|
||||
}
|
||||
type SummaryDTO struct {
|
||||
TotalSalesPrice float64 `json:"total_sales_price"`
|
||||
AvgSalesPrice float64 `json:"avg_sales_price"`
|
||||
TotalActualPrice float64 `json:"total_actual_price"`
|
||||
AvgActualPrice float64 `json:"avg_actual_price"`
|
||||
Id uint `json:"id"`
|
||||
RealizationDate time.Time `json:"realization_date"`
|
||||
Age int `json:"age"`
|
||||
DoNumber string `json:"do_number"`
|
||||
Product *productDTO.ProductRelationDTO `json:"product,omitempty"`
|
||||
Customer *customerDTO.CustomerRelationDTO `json:"customer,omitempty"`
|
||||
Qty float64 `json:"qty"`
|
||||
Weight float64 `json:"weight"`
|
||||
AvgWeight float64 `json:"avg_weight"`
|
||||
Price float64 `json:"price"`
|
||||
TotalPrice float64 `json:"total_price"`
|
||||
Kandang *kandangDTO.KandangRelationDTO `json:"kandang,omitempty"`
|
||||
PaymentStatus string `json:"payment_status"`
|
||||
}
|
||||
|
||||
type PenjualanRealisasiResponseDTO struct {
|
||||
Sales []SalesDTO `json:"sales"`
|
||||
Summary SummaryDTO `json:"summary"`
|
||||
Sales []SalesDTO `json:"sales"`
|
||||
}
|
||||
|
||||
// === Mapper Functions ===
|
||||
|
||||
func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO {
|
||||
|
||||
productFlags := make([]string, len(e.MarketingProduct.ProductWarehouse.Product.Flags))
|
||||
for i, f := range e.MarketingProduct.ProductWarehouse.Product.Flags {
|
||||
productFlags[i] = f.Name
|
||||
}
|
||||
|
||||
var category string
|
||||
if e.MarketingProduct.ProductWarehouse.ProjectFlockKandang != nil {
|
||||
category = e.MarketingProduct.ProductWarehouse.ProjectFlockKandang.ProjectFlock.Category
|
||||
}
|
||||
|
||||
ageInDay, ageInWeeks := calculateAgeFromChickin(e.MarketingProduct.ProductWarehouse.ProjectFlockKandang, e.DeliveryDate, productFlags, category)
|
||||
age := calculateAgeFromChickin(e.MarketingProduct.ProductWarehouse.ProjectFlockKandang, e.DeliveryDate)
|
||||
|
||||
var product *productDTO.ProductRelationDTO
|
||||
if e.MarketingProduct.ProductWarehouse.Product.Id != 0 {
|
||||
@@ -83,69 +63,19 @@ func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO {
|
||||
doNumber := deliveryOrdersDTO.GenerateDeliveryOrderNumber(e.MarketingProduct.Marketing.SoNumber, e.DeliveryDate, e.MarketingProduct.ProductWarehouse.Warehouse.Id)
|
||||
|
||||
return SalesDTO{
|
||||
Id: e.Id,
|
||||
RealizationDate: realizationDate,
|
||||
Age: ageInDay,
|
||||
Week: ageInWeeks,
|
||||
DoNumber: doNumber,
|
||||
Product: product,
|
||||
Customer: customer,
|
||||
Qty: e.UsageQty,
|
||||
Weight: e.TotalWeight,
|
||||
AvgWeight: e.AvgWeight,
|
||||
SalesPrice: e.MarketingProduct.UnitPrice,
|
||||
TotalSalesPrice: e.MarketingProduct.TotalPrice,
|
||||
ActualPrice: e.UnitPrice,
|
||||
TotalActualPrice: e.TotalPrice,
|
||||
Kandang: kandang,
|
||||
}
|
||||
}
|
||||
|
||||
func ToSalesAgeDTO(e entity.MarketingDeliveryProduct) SalesDTO {
|
||||
|
||||
productFlags := make([]string, len(e.MarketingProduct.ProductWarehouse.Product.Flags))
|
||||
for i, f := range e.MarketingProduct.ProductWarehouse.Product.Flags {
|
||||
productFlags[i] = f.Name
|
||||
}
|
||||
|
||||
var category string
|
||||
if e.MarketingProduct.ProductWarehouse.ProjectFlockKandang != nil {
|
||||
category = e.MarketingProduct.ProductWarehouse.ProjectFlockKandang.ProjectFlock.Category
|
||||
}
|
||||
|
||||
ageInDay, _ := calculateAgeFromChickin(e.MarketingProduct.ProductWarehouse.ProjectFlockKandang, e.DeliveryDate, productFlags, category)
|
||||
|
||||
return SalesDTO{
|
||||
Age: ageInDay,
|
||||
Qty: e.UsageQty,
|
||||
}
|
||||
}
|
||||
|
||||
func ToSummaryDto(e []entity.MarketingDeliveryProduct) SummaryDTO {
|
||||
var totalSalesPrice, totalActualPrice, sumSales, sumActual float64
|
||||
count := len(e)
|
||||
|
||||
if count == 0 {
|
||||
return SummaryDTO{
|
||||
TotalSalesPrice: 0,
|
||||
TotalActualPrice: 0,
|
||||
AvgSalesPrice: 0,
|
||||
AvgActualPrice: 0,
|
||||
}
|
||||
}
|
||||
|
||||
for _, item := range e {
|
||||
totalSalesPrice += item.MarketingProduct.TotalPrice
|
||||
totalActualPrice += item.TotalPrice
|
||||
sumSales += item.MarketingProduct.UnitPrice
|
||||
sumActual += item.UnitPrice
|
||||
}
|
||||
|
||||
return SummaryDTO{
|
||||
TotalSalesPrice: totalSalesPrice,
|
||||
TotalActualPrice: totalActualPrice,
|
||||
AvgSalesPrice: sumSales / float64(count),
|
||||
AvgActualPrice: sumActual / float64(count),
|
||||
Id: e.Id,
|
||||
RealizationDate: realizationDate,
|
||||
Age: age,
|
||||
DoNumber: doNumber,
|
||||
Product: product,
|
||||
Customer: customer,
|
||||
Qty: e.UsageQty,
|
||||
Weight: e.TotalWeight,
|
||||
AvgWeight: e.AvgWeight,
|
||||
Price: e.UnitPrice,
|
||||
TotalPrice: e.TotalPrice,
|
||||
Kandang: kandang,
|
||||
PaymentStatus: "Paid",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,36 +87,29 @@ func ToSalesDTOs(e []entity.MarketingDeliveryProduct) []SalesDTO {
|
||||
return result
|
||||
}
|
||||
|
||||
func ToPenjualanRealisasiResponseDTO(e []entity.MarketingDeliveryProduct) PenjualanRealisasiResponseDTO {
|
||||
func ToPenjualanRealisasiResponseDTO(projectFlockID uint, e []entity.MarketingDeliveryProduct) PenjualanRealisasiResponseDTO {
|
||||
|
||||
return PenjualanRealisasiResponseDTO{
|
||||
Sales: ToSalesDTOs(e),
|
||||
Summary: ToSummaryDto(e),
|
||||
|
||||
Sales: ToSalesDTOs(e),
|
||||
}
|
||||
}
|
||||
|
||||
func calculateAgeFromChickin(projectFlockKandang *entity.ProjectFlockKandang, deliveryDate *time.Time, productFlags []string, category string) (int, int) {
|
||||
if projectFlockKandang == nil || deliveryDate == nil || len(projectFlockKandang.Chickins) == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
for _, flag := range productFlags {
|
||||
if flag == string(utils.FlagOVK) ||
|
||||
flag == string(utils.FlagPakan) ||
|
||||
flag == string(utils.FlagPreStarter) ||
|
||||
flag == string(utils.FlagStarter) ||
|
||||
flag == string(utils.FlagFinisher) ||
|
||||
flag == string(utils.FlagObat) ||
|
||||
flag == string(utils.FlagVitamin) ||
|
||||
flag == string(utils.FlagKimia) ||
|
||||
flag == string(utils.FlagEkspedisi) ||
|
||||
flag == string(utils.FlagTelur) ||
|
||||
flag == string(utils.FlagTelurUtuh) ||
|
||||
flag == string(utils.FlagTelurPecah) ||
|
||||
flag == string(utils.FlagTelurPutih) ||
|
||||
flag == string(utils.FlagTelurRetak) {
|
||||
return 0, 0
|
||||
func extractPeriodFromRealisasi(realisasi []entity.MarketingDeliveryProduct) int {
|
||||
if len(realisasi) > 0 {
|
||||
for _, item := range realisasi {
|
||||
if item.MarketingProduct.ProductWarehouse.ProjectFlockKandang != nil {
|
||||
return item.MarketingProduct.ProductWarehouse.ProjectFlockKandang.Period
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func calculateAgeFromChickin(projectFlockKandang *entity.ProjectFlockKandang, deliveryDate *time.Time) int {
|
||||
if projectFlockKandang == nil || deliveryDate == nil || len(projectFlockKandang.Chickins) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
earliestChickinDate := projectFlockKandang.Chickins[0].ChickInDate
|
||||
for _, chickin := range projectFlockKandang.Chickins {
|
||||
@@ -195,20 +118,7 @@ func calculateAgeFromChickin(projectFlockKandang *entity.ProjectFlockKandang, de
|
||||
}
|
||||
}
|
||||
|
||||
diff := deliveryDate.Sub(earliestChickinDate)
|
||||
ageInDays := int(diff.Hours() / 24)
|
||||
|
||||
var ageInWeeks int
|
||||
if ageInDays <= 0 {
|
||||
ageInWeeks = 0
|
||||
} else {
|
||||
if category == string(utils.ProjectFlockCategoryLaying) {
|
||||
ageInDays = ageInDays + 119
|
||||
ageInWeeks = ((ageInDays - 1) / 7) + 1
|
||||
} else {
|
||||
ageInWeeks = ((ageInDays - 1) / 7) + 1
|
||||
}
|
||||
}
|
||||
|
||||
return ageInDays, ageInWeeks
|
||||
ageInDays := int(deliveryDate.Sub(earliestChickinDate).Hours() / 24)
|
||||
ageInWeeks := ageInDays / 7
|
||||
return ageInWeeks
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -65,7 +64,7 @@ type SapronakCategoryRowDTO struct {
|
||||
QtyOut float64 `json:"qty_out"`
|
||||
QtyUsed float64 `json:"qty_used"`
|
||||
Description string `json:"description"`
|
||||
ProductCategory []string `json:"product_category"`
|
||||
ProductCategory string `json:"product_category"`
|
||||
UnitPrice float64 `json:"unit_price"`
|
||||
TotalAmount float64 `json:"total_amount"`
|
||||
Notes string `json:"notes"`
|
||||
@@ -115,20 +114,9 @@ type ClosingSapronakDTO struct {
|
||||
OutgoingSapronak []ClosingSapronakItemDTO `json:"outgoing_sapronak"`
|
||||
}
|
||||
|
||||
type ClosingSapronakSummaryItemDTO struct {
|
||||
Category string `json:"category"`
|
||||
TotalQty int64 `json:"total_qty"`
|
||||
Uom UomSummaryDTO `json:"uom"`
|
||||
}
|
||||
|
||||
type UomSummaryDTO struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// === Mapper Functions for Aggregated Sapronak Response ===
|
||||
|
||||
func ToSapronakProjectAggregatedFromReports(reports []SapronakReportDTO, flag string, productFlags map[uint][]string) SapronakProjectAggregatedDTO {
|
||||
func ToSapronakProjectAggregatedFromReports(reports []SapronakReportDTO, flag string) SapronakProjectAggregatedDTO {
|
||||
result := SapronakProjectAggregatedDTO{}
|
||||
|
||||
if len(reports) == 0 {
|
||||
@@ -136,10 +124,10 @@ func ToSapronakProjectAggregatedFromReports(reports []SapronakReportDTO, flag st
|
||||
}
|
||||
|
||||
rep := reports[0]
|
||||
return ToSapronakProjectAggregatedFromReport(&rep, flag, productFlags)
|
||||
return ToSapronakProjectAggregatedFromReport(&rep, flag)
|
||||
}
|
||||
|
||||
func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag string, productFlags map[uint][]string) SapronakProjectAggregatedDTO {
|
||||
func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag string) SapronakProjectAggregatedDTO {
|
||||
result := SapronakProjectAggregatedDTO{}
|
||||
|
||||
if report == nil {
|
||||
@@ -176,53 +164,6 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
|
||||
return t.Format("02-Jan-2006")
|
||||
}
|
||||
|
||||
flagOrder := map[string]int{
|
||||
"DOC": 0,
|
||||
"PAKAN": 0,
|
||||
"OVK": 0,
|
||||
"PULLET": 0,
|
||||
}
|
||||
|
||||
buildFlagList := func(productID uint, fallback string) []string {
|
||||
rawFlags := productFlags[productID]
|
||||
if len(rawFlags) == 0 {
|
||||
if fallback == "" {
|
||||
return []string{}
|
||||
}
|
||||
return []string{fallback}
|
||||
}
|
||||
seen := make(map[string]struct{}, len(rawFlags))
|
||||
ordered := make([]string, 0, len(rawFlags))
|
||||
for _, f := range rawFlags {
|
||||
flagName := strings.ToUpper(strings.TrimSpace(f))
|
||||
if flagName == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[flagName]; ok {
|
||||
continue
|
||||
}
|
||||
seen[flagName] = struct{}{}
|
||||
ordered = append(ordered, flagName)
|
||||
}
|
||||
sort.SliceStable(ordered, func(i, j int) bool {
|
||||
li := ordered[i]
|
||||
lj := ordered[j]
|
||||
ri, iok := flagOrder[li]
|
||||
rj, jok := flagOrder[lj]
|
||||
if iok != jok {
|
||||
if iok {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
if iok && jok && ri != rj {
|
||||
return ri < rj
|
||||
}
|
||||
return li < lj
|
||||
})
|
||||
return ordered
|
||||
}
|
||||
|
||||
for _, group := range report.Groups {
|
||||
flagKey := normalizeFlag(group.Flag)
|
||||
ptr := byFlag[flagKey]
|
||||
@@ -244,17 +185,13 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
|
||||
}
|
||||
|
||||
for idx, item := range group.Items {
|
||||
refKey := strings.TrimSpace(item.NoReferensi)
|
||||
productKey := strings.ToUpper(flagKey + "|" + item.ProductName + "|" + refKey)
|
||||
if refKey == "" {
|
||||
productKey = strings.ToUpper(flagKey + "|" + item.ProductName + "|" + formatDate(item.Tanggal))
|
||||
}
|
||||
productKey := strings.ToUpper(flagKey + "|" + item.ProductName)
|
||||
baseRow := SapronakCategoryRowDTO{
|
||||
ID: idx + 1,
|
||||
Date: formatDate(item.Tanggal),
|
||||
ReferenceNumber: item.NoReferensi,
|
||||
Description: item.ProductName,
|
||||
ProductCategory: buildFlagList(item.ProductID, flagKey),
|
||||
ProductCategory: item.ProductName,
|
||||
UnitPrice: item.Harga,
|
||||
Notes: "-",
|
||||
}
|
||||
@@ -264,51 +201,18 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
|
||||
switch strings.ToLower(item.JenisTransaksi) {
|
||||
case "pembelian", "adjustment masuk", "mutasi masuk":
|
||||
row.QtyIn += item.QtyMasuk
|
||||
if item.Tanggal != nil {
|
||||
row.Date = formatDate(item.Tanggal)
|
||||
}
|
||||
if row.UnitPrice == 0 {
|
||||
if item.QtyMasuk > 0 && item.Nilai > 0 {
|
||||
row.UnitPrice = item.Nilai / item.QtyMasuk
|
||||
} else if item.Harga > 0 {
|
||||
row.UnitPrice = item.Harga
|
||||
}
|
||||
}
|
||||
if strings.ToLower(item.JenisTransaksi) == "mutasi masuk" {
|
||||
ref := strings.ToUpper(strings.TrimSpace(item.NoReferensi))
|
||||
if strings.HasPrefix(ref, "TL-") {
|
||||
row.Notes = "TRANSFER LAYING"
|
||||
} else if strings.HasPrefix(ref, "ST-") {
|
||||
row.Notes = "TRANSFER STOCK"
|
||||
}
|
||||
}
|
||||
case "pemakaian":
|
||||
price := row.UnitPrice
|
||||
if price == 0 {
|
||||
price = item.Harga
|
||||
}
|
||||
row.TotalAmount += item.Nilai
|
||||
case "pemakaian", "adjustment keluar":
|
||||
row.QtyUsed += item.QtyKeluar
|
||||
row.TotalAmount += item.QtyKeluar * price
|
||||
case "adjustment keluar", "mutasi keluar", "penjualan":
|
||||
price := row.UnitPrice
|
||||
if price == 0 {
|
||||
price = item.Harga
|
||||
}
|
||||
case "mutasi keluar":
|
||||
row.QtyOut += item.QtyKeluar
|
||||
if strings.ToLower(item.JenisTransaksi) == "mutasi keluar" {
|
||||
ref := strings.ToUpper(strings.TrimSpace(item.NoReferensi))
|
||||
if strings.HasPrefix(ref, "TL-") {
|
||||
row.Notes = "TRANSFER LAYING"
|
||||
} else if strings.HasPrefix(ref, "ST-") {
|
||||
row.Notes = "TRANSFER STOCK"
|
||||
}
|
||||
}
|
||||
default:
|
||||
row.QtyIn += item.QtyMasuk
|
||||
row.TotalAmount += item.Nilai
|
||||
if row.QtyIn > 0 {
|
||||
row.UnitPrice = row.TotalAmount / row.QtyIn
|
||||
}
|
||||
}
|
||||
|
||||
if row.QtyIn > 0 {
|
||||
row.UnitPrice = row.TotalAmount / row.QtyIn
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,8 +233,8 @@ func ToSapronakProjectAggregatedFromReport(report *SapronakReportDTO, flag strin
|
||||
total += r.TotalAmount
|
||||
}
|
||||
avg := 0.0
|
||||
if qtyUsed > 0 {
|
||||
avg = total / qtyUsed
|
||||
if qtyIn > 0 {
|
||||
avg = total / qtyIn
|
||||
}
|
||||
cat.Total = SapronakCategoryTotalDTO{
|
||||
Label: label,
|
||||
|
||||
@@ -39,13 +39,10 @@ func (ClosingModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *
|
||||
purchaseRepo := rPurchase.NewPurchaseRepository(db)
|
||||
approvalRepo := commonRepo.NewApprovalRepository(db)
|
||||
approvalService := commonSvc.NewApprovalService(approvalRepo)
|
||||
hppCostRepo := commonRepo.NewHppCostRepository(db)
|
||||
hppService := commonSvc.NewHppService(hppCostRepo)
|
||||
|
||||
closingService := sClosing.NewClosingService(closingRepo, projectFlockRepo, projectFlockKandangRepo, marketingRepo, marketingDeliveryProductRepo, approvalService, expenseRealizationRepo, projectBudgetRepo, chickinRepo, purchaseRepo, recordingRepo, standardGrowthDetailRepo, productionStandardDetailRepo, validate)
|
||||
closingKeuanganService := sClosing.NewClosingKeuanganService(projectFlockRepo, projectFlockKandangRepo, marketingDeliveryProductRepo, expenseRealizationRepo, projectBudgetRepo, chickinRepo, recordingRepo, hppService, hppCostRepo)
|
||||
sapronakService := sClosing.NewSapronakService(closingRepo, projectFlockKandangRepo, validate)
|
||||
userService := sUser.NewUserService(userRepo, validate)
|
||||
|
||||
ClosingRoutes(router, userService, closingService, sapronakService, closingKeuanganService)
|
||||
ClosingRoutes(router, userService, closingService, sapronakService)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user