Compare commits

..

12 Commits

Author SHA1 Message Date
kris 7a6b3121f6 Update .gitlab-ci.yml file 2025-12-01 04:37:25 +00:00
kris 92901bc60c Update .gitlab-ci.yml file 2025-12-01 04:35:16 +00:00
GitLab Deploy Bot 2ea2e1ddf3 Merge remote-tracking branch 'origin/development' into devops-ec2 2025-12-01 11:26:27 +07:00
kris 8f4548971e Update .gitlab-ci.yml file 2025-11-11 09:23:32 +00:00
kris d940580152 Update .gitlab-ci.yml file 2025-11-11 09:18:02 +00:00
kris c8052f4cb5 Update .gitlab-ci.yml file 2025-11-11 09:09:50 +00:00
kris b8dca3c25e Update .gitlab-ci.yml file 2025-11-11 09:07:45 +00:00
kris c885fba4ef Update .gitlab-ci.yml file 2025-11-11 08:53:59 +00:00
kris 4837ed4255 Update .gitlab-ci.yml file 2025-11-11 08:45:25 +00:00
kris d740a3e26e Update .gitlab-ci.yml file 2025-11-11 08:36:36 +00:00
kris 0766cfeeb2 Update .gitlab-ci.yml file 2025-11-11 08:28:54 +00:00
kris 8e89f9fad0 Update .gitlab-ci.yml file 2025-11-11 08:26:37 +00:00
549 changed files with 6588 additions and 54428 deletions
Vendored
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -3,7 +3,7 @@ root = "."
tmp_dir = "tmp"
[build]
cmd = "go build -buildvcs=false -o ./tmp/main ./cmd/api"
cmd = "go build -o ./tmp/main ./cmd/api"
bin = "tmp/main"
full_bin = "APP_ENV=dev ./tmp/main"
include_ext = ["go", "tpl", "tmpl", "html"]
+1 -4
View File
@@ -9,13 +9,11 @@ main
bin/
*.exe
*.out
.air.toml
Makefile
docker-compose.local.yml
docker-compose.yaml
Dockerfile
Dockerfile.local
.gitlab-ci.yml
# Go build cache
.gocache/
vendor
@@ -29,4 +27,3 @@ coverage/
.vscode/
.idea/
*.swp
.DS_Store
+85 -29
View File
@@ -1,35 +1,91 @@
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
- devops-ec2
environment:
name: development
+11 -29
View File
@@ -1,38 +1,20 @@
# =========================
# Builder stage
# =========================
FROM golang:1.23-alpine AS builder
FROM golang:1.23-alpine
RUN apk add --no-cache git ca-certificates tzdata
WORKDIR /app
# Install dependensi dasar
RUN apk add --no-cache git curl bash build-base
# Install Air (pakai repo baru air-verse)
RUN go install github.com/air-verse/air@v1.52.3
WORKDIR /lti-api
# Cache dependencies
COPY go.mod go.sum ./
RUN go mod download
# Copy source code
COPY . .
# Build API binary
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath -ldflags="-s -w" -o lti-api ./cmd/api
# Build SEED binary (pastikan cmd/seed ada)
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath -ldflags="-s -w" -o lti-seed ./cmd/seed
# =========================
# Runtime stage
# =========================
FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata curl bash postgresql-client \
&& adduser -D -H -u 10001 appuser
WORKDIR /app
COPY --from=builder /app/lti-api /app/lti-api
COPY --from=builder /app/lti-seed /app/lti-seed
USER appuser
EXPOSE 8081
CMD ["/app/lti-api"]
CMD ["air", "-c", ".air.toml"]
+1 -1
View File
@@ -110,4 +110,4 @@ IT Development PT Mitra Berlian Unggas Group
## 📃 License
> This project is private. All rights reserved.
This project is private. All rights reserved.
-91
View File
@@ -1,91 +0,0 @@
stages:
- deploy
deploy-dev:
stage: deploy
image: alpine:3.20
rules:
- if: '$CI_COMMIT_BRANCH == "development"'
when: on_success
- when: never
variables:
DEPLOY_APP: "LTI-MBUGROUP"
GIT_SUBMODULE_STRATEGY: recursive
GIT_DEPTH: "1"
before_script:
- echo "🧰 Installing dependencies..."
- apk update && apk add --no-cache openssh git curl bash
# Setup SSH di runner
- mkdir -p ~/.ssh
- echo "$SSH_PRIVATE_KEY" | tr -d '\r' > ~/.ssh/id_rsa
- chmod 600 ~/.ssh/id_rsa
- eval "$(ssh-agent -s)"
- ssh-add ~/.ssh/id_rsa
# Trust host keys (server + gitlab) biar SSH gak nanya interaktif
- ssh-keyscan -H "$SERVER_IP" >> ~/.ssh/known_hosts
- ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts
script:
- echo "🚀 Deploying latest code to $SERVER_USER@$SERVER_IP"
- >
if ssh -o StrictHostKeyChecking=no "$SERVER_USER@$SERVER_IP" "
set -e
cd /home/devops/docker/deployment/development/lti-api
# Pastikan remote origin SSH (antisipasi kalau pernah ke-set HTTPS)
git remote set-url origin git@gitlab.com:mbugroup/lti-api.git
# Pastikan server percaya gitlab.com juga (untuk git fetch via SSH)
mkdir -p ~/.ssh
ssh-keyscan -H gitlab.com >> ~/.ssh/known_hosts
# Fetch/reset pakai SSH
GIT_SSH_COMMAND='ssh -o StrictHostKeyChecking=no' git fetch origin development
git reset --hard origin/development
docker compose restart dev-api-lti || docker compose up -d dev-api-lti
"; then
STATUS='success';
else
STATUS='failed';
fi;
RUN_URL="${CI_PROJECT_URL}/-/pipelines/${CI_PIPELINE_ID}";
if [ "$STATUS" = "success" ]; then
COLOR=3066993;
TITLE="✅ Deployment API Succeeded";
DESC="Deployment job on branch \`${CI_COMMIT_REF_NAME}\` completed successfully.";
else
COLOR=15158332;
TITLE="❌ Deployment API Failed Gaes";
DESC="Deployment job on branch \`${CI_COMMIT_REF_NAME}\` failed.";
fi;
echo "{
\"username\": \"CI Bot\",
\"embeds\": [{
\"title\": \"$TITLE\",
\"description\": \"$DESC\",
\"color\": $COLOR,
\"fields\": [
{\"name\": \"Repository\", \"value\": \"${CI_PROJECT_PATH}\", \"inline\": true},
{\"name\": \"Actor\", \"value\": \"${GITLAB_USER_LOGIN}\", \"inline\": true},
{\"name\": \"Commit\", \"value\": \"${CI_COMMIT_SHA}\", \"inline\": false},
{\"name\": \"Pipeline\", \"value\": \"[Open run](${RUN_URL})\", \"inline\": false}
]
}]
}" > payload.json;
echo "📡 Sending notification to Discord...";
curl -sS -H "Content-Type: application/json" \
-d @payload.json "$DISCORD_WEBHOOK_URL";
environment:
name: development
-48
View File
@@ -1,48 +0,0 @@
stages:
- notify
notify_discord_on_mr_request_main_dev:
stage: notify
image: alpine:3.20
rules:
# hanya MR yang target ke main atau development
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" && ($CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main" || $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "development")'
when: on_success
- when: never
script:
- apk add --no-cache curl jq coreutils
- |
TIME_HUMAN="$(date '+%d/%m/%y, %H.%M')"
TIME_ISO="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
TITLE="${CI_MERGE_REQUEST_TITLE}"
IID="!${CI_MERGE_REQUEST_IID}"
USER_LINE="${GITLAB_USER_NAME} (${GITLAB_USER_LOGIN})"
PROJECT_PATH="${CI_PROJECT_PATH}"
USERNAME="${GITLAB_USER_LOGIN}"
MR_URL="${CI_PROJECT_URL}/-/merge_requests/${CI_MERGE_REQUEST_IID}"
DESC="$(printf "**%s**\n\n%s opened merge request %s %s\n%s" \
"$USERNAME" "$USER_LINE" "$IID" "$TITLE" "$TIME_HUMAN")"
payload=$(jq -n \
--arg desc "$DESC" \
--arg project "$PROJECT_PATH" \
--arg timeiso "$TIME_ISO" \
--arg mrurl "$MR_URL" \
'{
"username": "Mock-api - Merge Requests",
"embeds": [
{
"description": ($desc + "\n" + $mrurl),
"color": 15105570,
"footer": { "text": $project },
"timestamp": $timeiso
}
]
}')
curl -sS -H "Content-Type: application/json" \
-d "$payload" \
"$DISCORD_WEBHOOK_URL"
-155
View File
@@ -1,155 +0,0 @@
stages:
- build
- migrate
- deploy
- seed
default:
tags:
- self-hosted-prod
variables:
DOCKER_BUILDKIT: "1"
IMAGE_TAG: "production_${CI_COMMIT_SHORT_SHA}"
IMAGE_NAME: "${CI_REGISTRY_IMAGE}:${IMAGE_TAG}"
IMAGE_LATEST: "${CI_REGISTRY_IMAGE}:production_latest"
DEPLOY_DIR: "/opt/deploy/lti"
COMPOSE_FILE: "docker-compose.yaml"
# =========================
# BUILD (AUTO)
# =========================
build_production:
stage: build
rules:
- if: '$CI_COMMIT_BRANCH == "production"'
when: on_success
- when: never
script: |
set -e
docker info
echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
echo "✅ Build image: $IMAGE_NAME"
docker build -t "$IMAGE_NAME" -f Dockerfile .
echo "✅ Push image: $IMAGE_NAME"
docker push "$IMAGE_NAME"
echo "✅ Tag latest: $IMAGE_LATEST"
docker tag "$IMAGE_NAME" "$IMAGE_LATEST"
docker push "$IMAGE_LATEST"
# =========================
# MIGRATE (PRODUCTION)
# =========================
migrate_production:
stage: migrate
rules:
- if: '$CI_COMMIT_BRANCH == "production"'
when: on_success
- when: never
needs:
- job: build_production
artifacts: false
script: |
set -e
echo "✅ Running migrations (production) ..."
cd "$DEPLOY_DIR"
test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found in $DEPLOY_DIR" && exit 1)
test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1)
set -a
. ./.env
set +a
test -n "$DB_HOST" || (echo "❌ DB_HOST empty" && exit 1)
test -n "$DB_PORT" || (echo "❌ DB_PORT empty" && exit 1)
test -n "$DB_USER" || (echo "❌ DB_USER empty" && exit 1)
test -n "$DB_PASSWORD" || (echo "❌ DB_PASSWORD empty" && exit 1)
test -n "$DB_NAME" || (echo "❌ DB_NAME empty" && exit 1)
export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE:-disable}"
echo "✅ DATABASE_URL=$DATABASE_URL"
# NOTE: pastikan nama servicenya benar untuk production (ini sebelumnya masih stg-*)
docker compose -f "$COMPOSE_FILE" up -d stg-postgres-lti stg-redis-lti || true
COMPOSE_NETWORK_KEY="$(docker compose -f "$COMPOSE_FILE" config | awk '/networks:/ {getline; print $1}' | tr -d ':')"
NETWORK_NAME="$(docker network ls --format '{{.Name}}' | grep "_${COMPOSE_NETWORK_KEY}$" | head -n 1)"
test -n "$NETWORK_NAME" || (echo "❌ Cannot find docker network for compose ($COMPOSE_NETWORK_KEY)" && exit 1)
echo "✅ Checking migrations from repo..."
ls -lah "$CI_PROJECT_DIR/internal/database/migrations"
echo "✅ Running migrations via migrate/migrate container"
set +e
out=$(docker run --rm \
--network "$NETWORK_NAME" \
-v "$CI_PROJECT_DIR/internal/database/migrations:/migrations:ro" \
migrate/migrate:v4.15.2 \
-path=/migrations -database "$DATABASE_URL" up 2>&1)
code=$?
set -e
echo "$out"
if echo "$out" | grep -qi "no change"; then
echo "✅ No change (already up to date)"
exit 0
fi
if [ $code -ne 0 ]; then
echo "❌ Migration failed with exit code $code"
exit $code
fi
echo "✅ Migration applied successfully"
# =========================
# DEPLOY (AUTO)
# =========================
deploy_production:
stage: deploy
rules:
- if: '$CI_COMMIT_BRANCH == "production"'
when: on_success
- when: never
needs:
- job: build_production
artifacts: false
script: |
set -e
docker info
echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
cd "$DEPLOY_DIR"
test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found in $DEPLOY_DIR" && exit 1)
test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1)
docker compose -f "$COMPOSE_FILE" pull
docker compose -f "$COMPOSE_FILE" up -d --force-recreate
docker image prune -f
# =========================
# SEED (MANUAL)
# =========================
seed_production:
stage: seed
rules:
- if: '$CI_COMMIT_BRANCH == "production"'
when: manual
- when: never
script: |
set -e
cd "$DEPLOY_DIR"
test -f .env || (echo "❌ .env not found" && exit 1)
echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
docker compose --env-file .env pull seed
docker compose --env-file .env run --rm seed
-164
View File
@@ -1,164 +0,0 @@
stages:
- build
- migrate
- deploy
- seed
default:
tags:
- self-hosted-stg
variables:
DOCKER_BUILDKIT: "1"
IMAGE_TAG: "staging_${CI_COMMIT_SHORT_SHA}"
IMAGE_NAME: "${CI_REGISTRY_IMAGE}:${IMAGE_TAG}"
IMAGE_LATEST: "${CI_REGISTRY_IMAGE}:staging_latest"
DEPLOY_DIR: "/opt/deploy/stg-lti-api"
COMPOSE_FILE: "docker-compose.yaml"
# =========================
# BUILD (AUTO)
# =========================
build_staging:
stage: build
rules:
- if: '$CI_COMMIT_BRANCH == "staging"'
when: on_success
- when: never
script: |
set -e
docker info
echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
echo "✅ Build image: $IMAGE_NAME"
docker build -t "$IMAGE_NAME" -f Dockerfile .
echo "✅ Push image: $IMAGE_NAME"
docker push "$IMAGE_NAME"
echo "✅ Tag latest: $IMAGE_LATEST"
docker tag "$IMAGE_NAME" "$IMAGE_LATEST"
docker push "$IMAGE_LATEST"
# =========================
# MIGRATE (AUTO)
# =========================
migrate_staging:
stage: migrate
rules:
- if: '$CI_COMMIT_BRANCH == "staging"'
when: on_success
- when: never
needs:
- job: build_staging
artifacts: false
script: |
set -e
echo "✅ Running migrations (staging) ..."
cd "$DEPLOY_DIR"
test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found in $DEPLOY_DIR" && exit 1)
test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1)
set -a
. ./.env
set +a
test -n "$DB_HOST" || (echo "❌ DB_HOST empty" && exit 1)
test -n "$DB_PORT" || (echo "❌ DB_PORT empty" && exit 1)
test -n "$DB_USER" || (echo "❌ DB_USER empty" && exit 1)
test -n "$DB_PASSWORD" || (echo "❌ DB_PASSWORD empty" && exit 1)
test -n "$DB_NAME" || (echo "❌ DB_NAME empty" && exit 1)
export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE:-disable}"
echo "✅ DATABASE_URL=$DATABASE_URL"
echo "✅ Ensuring postgres & redis running ..."
docker compose -f "$COMPOSE_FILE" up -d stg-postgres-lti stg-redis-lti || true
COMPOSE_NETWORK_KEY="$(docker compose -f "$COMPOSE_FILE" config | awk '/networks:/ {getline; print $1}' | tr -d ':')"
echo "✅ Compose network key: $COMPOSE_NETWORK_KEY"
NETWORK_NAME="$(docker network ls --format '{{.Name}}' | grep "_${COMPOSE_NETWORK_KEY}$" | head -n 1)"
test -n "$NETWORK_NAME" || (echo "❌ Cannot find docker network for compose ($COMPOSE_NETWORK_KEY)" && exit 1)
echo "✅ Docker network detected: $NETWORK_NAME"
echo "✅ Checking migrations from repo..."
ls -lah "$CI_PROJECT_DIR/internal/database/migrations"
echo "✅ Running migrations via migrate/migrate container"
set +e
out=$(docker run --rm \
--network "$NETWORK_NAME" \
-v "$CI_PROJECT_DIR/internal/database/migrations:/migrations:ro" \
migrate/migrate:v4.15.2 \
-path=/migrations -database "$DATABASE_URL" up 2>&1)
code=$?
set -e
echo "$out"
if echo "$out" | grep -qi "no change"; then
echo "✅ No change (already up to date)"
exit 0
fi
if [ $code -ne 0 ]; then
echo "❌ Migration failed with exit code $code"
exit $code
fi
echo "✅ Migration applied successfully"
# =========================
# DEPLOY (AUTO)
# =========================
deploy_staging:
stage: deploy
rules:
- if: '$CI_COMMIT_BRANCH == "staging"'
when: on_success
- when: never
needs:
- job: migrate_staging
artifacts: false
- job: build_staging
artifacts: false
script: |
set -e
docker info
echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
cd "$DEPLOY_DIR"
test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found in $DEPLOY_DIR" && exit 1)
test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1)
docker compose -f "$COMPOSE_FILE" pull
docker compose -f "$COMPOSE_FILE" up -d --force-recreate
docker image prune -f
# =========================
# SEED (MANUAL)
# =========================
seed_staging:
stage: seed
rules:
- if: '$CI_COMMIT_BRANCH == "staging"'
when: manual
- when: never
needs:
- job: deploy_staging
artifacts: false
allow_failure: false
script: |
set -e
cd "$DEPLOY_DIR"
test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found" && exit 1)
test -f .env || (echo "❌ .env not found" && exit 1)
docker compose -f "$COMPOSE_FILE" pull seed || true
docker compose -f "$COMPOSE_FILE" run --rm seed
+1 -1
View File
@@ -14,8 +14,8 @@ import (
"gitlab.com/mbugroup/lti-api.git/internal/database"
"gitlab.com/mbugroup/lti-api.git/internal/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"
-442
View File
@@ -1,442 +0,0 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"sort"
"strconv"
"strings"
"time"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
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"
rProductWarehouse "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
"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)
productWarehouseRepo := rProductWarehouse.NewProductWarehouseRepository(db)
stockAllocRepo := commonRepo.NewStockAllocationRepository(db)
fifoSvc := commonSvc.NewFifoService(db, stockAllocRepo, productWarehouseRepo, nil)
fifoStockV2Svc := commonSvc.NewFifoStockV2Service(db, nil)
if err := registerAdjustmentFIFO(fifoSvc); err != nil {
log.Fatalf("failed to register adjustment fifo config: %v", err)
}
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,
Usable: commonSvc.FifoStockV2Ref{
ID: adj.ID,
LegacyTypeKey: fifo.UsableKeyAdjustmentOut.String(),
FunctionCode: route.FunctionCode,
},
DesiredQty: 0,
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=reverse_stock+delete\n",
adj.ID,
route.FunctionCode,
adj.TotalQty,
removeQty,
)
if !apply {
skipped++
continue
}
err = db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if removeQty > 0 {
if err := fifoSvc.AdjustStockableQuantity(ctx, commonSvc.StockAdjustRequest{
StockableKey: fifo.StockableKeyAdjustmentIn,
StockableID: adj.ID,
ProductWarehouseID: adj.ProductWarehouseID,
Quantity: -removeQty,
Tx: tx,
}); err != nil {
return fmt.Errorf("reverse stockable quantity: %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 registerAdjustmentFIFO(fifoSvc commonSvc.FifoService) error {
if err := fifoSvc.RegisterStockable(fifo.StockableConfig{
Key: fifo.StockableKeyAdjustmentIn,
Table: "adjustment_stocks",
Columns: fifo.StockableColumns{
ID: "id",
ProductWarehouseID: "product_warehouse_id",
TotalQuantity: "total_qty",
TotalUsedQuantity: "total_used",
CreatedAt: "created_at",
},
OrderBy: []string{"created_at ASC", "id ASC"},
}); err != nil && !strings.Contains(strings.ToLower(err.Error()), "already registered") {
return err
}
if err := fifoSvc.RegisterUsable(fifo.UsableConfig{
Key: fifo.UsableKeyAdjustmentOut,
Table: "adjustment_stocks",
Columns: fifo.UsableColumns{
ID: "id",
ProductWarehouseID: "product_warehouse_id",
UsageQuantity: "usage_qty",
PendingQuantity: "pending_qty",
CreatedAt: "created_at",
},
OrderBy: []string{"created_at ASC", "id ASC"},
}); err != nil && !strings.Contains(strings.ToLower(err.Error()), "already registered") {
return err
}
return nil
}
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).
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).
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 = ?", usableType, usableID).
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 = ?", stockableType, stockableID).
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
}
-343
View File
@@ -1,343 +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
}
usableType := fifo.UsableKeyAdjustmentOut.String()
if route.SourceTable == "adjustment_stocks" && strings.TrimSpace(route.LegacyTypeKey) != "" {
usableType = strings.TrimSpace(route.LegacyTypeKey)
}
activeAllocationCount, err := countActiveAllocations(ctx, db, usableType, 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,
Usable: commonSvc.FifoStockV2Ref{
ID: adj.ID,
LegacyTypeKey: usableType,
FunctionCode: route.FunctionCode,
},
DesiredQty: desiredQty,
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).
Count(&count).Error
if err != nil {
return 0, err
}
return count, nil
}
+77
View File
@@ -0,0 +1,77 @@
services:
postgresdb:
image: postgres:alpine
restart: always
ports:
- "${DB_PORT_HOST:-5542}:5432"
environment:
POSTGRES_USER: ${DB_USER:-postgres}
POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres}
POSTGRES_DB: ${DB_NAME:-db_lti_erp}
volumes:
- dbdata:/var/lib/postgresql/data
- ./internal/database/init:/docker-entrypoint-initdb.d
networks: [go-network]
healthcheck:
test:
[
"CMD-SHELL",
"pg_isready -U ${DB_USER:-postgres} -d ${DB_NAME:-db_lti_erp}",
]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
restart: unless-stopped
ports:
- "${REDIS_PORT_HOST:-6381}:6379"
healthcheck:
test: ["CMD-SHELL", "redis-cli ping | grep PONG"]
interval: 5s
timeout: 3s
retries: 10
networks: [go-network]
app:
build:
context: .
dockerfile: Dockerfile.local
image: cosmtrek/air:v1.52.3
working_dir: /lti-api
volumes:
- .:/lti-api
- ./internal/config/jwtRS256.key:/run/keys/jwtRS256.key
- ./internal/config/jwtRS256.key.pub:/run/keys/jwtRS256.key.pub
command: air -c .air.toml
env_file:
- .env
environment:
DB_HOST: postgresdb
DB_PORT: 5432
DB_USER: ${DB_USER:-postgres}
DB_PASSWORD: ${DB_PASSWORD:-postgres}
DB_NAME: ${DB_NAME:-db_lti_erp}
REDIS_URL: ${REDIS_URL:-redis://redis:6379/0}
ports:
- "${APP_PORT:-8081}:8081"
depends_on:
postgresdb:
condition: service_healthy
networks: [go-network]
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8081/healthz || exit 1"]
interval: 10s
timeout: 3s
retries: 10
start_period: 10s
volumes:
dbdata:
go-mod-cache:
go-build-cache:
networks:
go-network:
name: lti-api_go-network
driver: bridge
+98
View File
@@ -0,0 +1,98 @@
services:
dev-api-lti:
build:
context: .
dockerfile: Dockerfile
container_name: dev-api-lti
working_dir: /lti-api
command: ["/bin/sh", "scripts/entrypoint.sh"]
ports:
- "8081:8081"
env_file:
- .env
environment:
# override agar koneksi ke container internal
DB_HOST: dev-postgres-lti
DB_PORT: 5432
REDIS_URL: redis://dev-redis-lti:6379/0
volumes:
- .:/lti-api
- ./.air.toml:/lti-api/.air.toml:ro
- ./internal/config/jwtRS256.key:/run/keys/jwtRS256.key
- ./internal/config/jwtRS256.key.pub:/run/keys/jwtRS256.key.pub
depends_on:
- dev-postgres-lti
- dev-redis-lti
networks:
- lti-network
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:8081/healthz || exit 1"]
interval: 10s
timeout: 3s
retries: 10
start_period: 10s
deploy:
resources:
limits:
cpus: "2.0"
memory: 2G
reservations:
cpus: "1.0"
memory: 512M
dev-postgres-lti:
image: postgres:15-alpine
container_name: dev-postgres-lti
restart: always
env_file:
- credential/.env.db
ports:
- "5433:5432"
volumes:
- dev-postgres-lti-data:/var/lib/postgresql/data
- ./credential:/docker-entrypoint-initdb.d:ro
networks:
- lti-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-postgres} -d ${DB_NAME:-db_lti_erp}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 5s
deploy:
resources:
limits:
cpus: "1.0"
memory: 2G
reservations:
cpus: "0.5"
memory: 512M
dev-redis-lti:
image: redis:7-alpine
container_name: dev-redis-lti
restart: always
ports:
- "6380:6379"
networks:
- lti-network
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 10
deploy:
resources:
limits:
cpus: "0.5"
memory: 512M
reservations:
cpus: "0.2"
memory: 256M
networks:
lti-network:
driver: bridge
volumes:
dev-postgres-lti-data:
+2 -36
View File
@@ -4,23 +4,15 @@ go 1.23
require (
github.com/MicahParks/keyfunc/v2 v2.1.0
github.com/aws/aws-sdk-go-v2 v1.40.0
github.com/aws/aws-sdk-go-v2/config v1.32.2
github.com/aws/aws-sdk-go-v2/credentials v1.19.2
github.com/aws/aws-sdk-go-v2/service/s3 v1.92.1
github.com/bytedance/sonic v1.12.1
github.com/glebarez/sqlite v1.11.0
github.com/go-playground/validator/v10 v10.27.0
github.com/gofiber/contrib/jwt v1.0.10
github.com/gofiber/fiber/v2 v2.52.5
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.6.0
github.com/jackc/pgconn v1.14.1
github.com/jackc/pgx/v5 v5.5.5
github.com/redis/go-redis/v9 v9.14.0
github.com/sirupsen/logrus v1.9.3
github.com/spf13/viper v1.19.0
github.com/xuri/excelize/v2 v2.9.0
golang.org/x/crypto v0.33.0
gorm.io/driver/postgres v1.5.9
gorm.io/gorm v1.25.11
@@ -28,39 +20,24 @@ require (
require (
github.com/andybalholm/brotli v1.1.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.14 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.5 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.14 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.2 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.5 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.10 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.2 // indirect
github.com/aws/smithy-go v1.23.2 // indirect
github.com/bytedance/sonic/loader v0.2.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
github.com/jackc/pgio v1.0.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgproto3/v2 v2.3.2 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/pgx/v5 v5.5.5 // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
@@ -72,12 +49,8 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/philhofer/fwd v1.1.2 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/richardlehane/mscfb v1.0.4 // indirect
github.com/richardlehane/msoleps v1.0.4 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rogpeppe/go-internal v1.11.0 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
@@ -86,15 +59,12 @@ require (
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tinylib/msgp v1.1.8 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.55.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
github.com/xuri/efp v0.0.0-20240408161823-9ad904a10d6d // indirect
github.com/xuri/nfp v0.0.0-20240318013403-ab9948c2c4a7 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect
@@ -105,8 +75,4 @@ require (
golang.org/x/text v0.22.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.23.1 // indirect
)
+1 -74
View File
@@ -2,44 +2,6 @@ github.com/MicahParks/keyfunc/v2 v2.1.0 h1:6ZXKb9Rp6qp1bDbJefnG7cTH8yMN1IC/4nf+G
github.com/MicahParks/keyfunc/v2 v2.1.0/go.mod h1:rW42fi+xgLJ2FRRXAfNx9ZA8WpD4OeE/yHVMteCkw9k=
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/aws/aws-sdk-go-v2 v1.40.0 h1:/WMUA0kjhZExjOQN2z3oLALDREea1A7TobfuiBrKlwc=
github.com/aws/aws-sdk-go-v2 v1.40.0/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 h1:DHctwEM8P8iTXFxC/QK0MRjwEpWQeM9yzidCRjldUz0=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3/go.mod h1:xdCzcZEtnSTKVDOmUZs4l/j3pSV6rpo1WXl5ugNsL8Y=
github.com/aws/aws-sdk-go-v2/config v1.32.2 h1:4liUsdEpUUPZs5WVapsJLx5NPmQhQdez7nYFcovrytk=
github.com/aws/aws-sdk-go-v2/config v1.32.2/go.mod h1:l0hs06IFz1eCT+jTacU/qZtC33nvcnLADAPL/XyrkZI=
github.com/aws/aws-sdk-go-v2/credentials v1.19.2 h1:qZry8VUyTK4VIo5aEdUcBjPZHL2v4FyQ3QEOaWcFLu4=
github.com/aws/aws-sdk-go-v2/credentials v1.19.2/go.mod h1:YUqm5a1/kBnoK+/NY5WEiMocZihKSo15/tJdmdXnM5g=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14 h1:WZVR5DbDgxzA0BJeudId89Kmgy6DIU4ORpxwsVHz0qA=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14/go.mod h1:Dadl9QO0kHgbrH1GRqGiZdYtW5w+IXXaBNCHTIaheM4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 h1:PZHqQACxYb8mYgms4RZbhZG0a7dPW06xOjmaH0EJC/I=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14/go.mod h1:VymhrMJUWs69D8u0/lZ7jSB6WgaG/NqHi3gX0aYf6U0=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 h1:bOS19y6zlJwagBfHxs0ESzr1XCOU2KXJCWcq3E2vfjY=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14/go.mod h1:1ipeGBMAxZ0xcTm6y6paC2C/J6f6OO7LBODV9afuAyM=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.14 h1:ITi7qiDSv/mSGDSWNpZ4k4Ve0DQR6Ug2SJQ8zEHoDXg=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.14/go.mod h1:k1xtME53H1b6YpZt74YmwlONMWf4ecM+lut1WQLAF/U=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 h1:x2Ibm/Af8Fi+BH+Hsn9TXGdT+hKbDd5XOTZxTMxDk7o=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3/go.mod h1:IW1jwyrQgMdhisceG8fQLmQIydcT/jWY21rFhzgaKwo=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.5 h1:Hjkh7kE6D81PgrHlE/m9gx+4TyyeLHuY8xJs7yXN5C4=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.5/go.mod h1:nPRXgyCfAurhyaTMoBMwRBYBhaHI4lNPAnJmjM0Tslc=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14 h1:FIouAnCE46kyYqyhs0XEBDFFSREtdnr8HQuLPQPLCrY=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14/go.mod h1:UTwDc5COa5+guonQU8qBikJo1ZJ4ln2r1MkF7Dqag1E=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.14 h1:FzQE21lNtUor0Fb7QNgnEyiRCBlolLTX/Z1j65S7teM=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.14/go.mod h1:s1ydyWG9pm3ZwmmYN21HKyG9WzAZhYVW85wMHs5FV6w=
github.com/aws/aws-sdk-go-v2/service/s3 v1.92.1 h1:OgQy/+0+Kc3khtqiEOk23xQAglXi3Tj0y5doOxbi5tg=
github.com/aws/aws-sdk-go-v2/service/s3 v1.92.1/go.mod h1:wYNqY3L02Z3IgRYxOBPH9I1zD9Cjh9hI5QOy/eOjQvw=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.2 h1:MxMBdKTYBjPQChlJhi4qlEueqB1p1KcbTEa7tD5aqPs=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.2/go.mod h1:iS6EPmNeqCsGo+xQmXv0jIMjyYtQfnwg36zl2FwEouk=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.5 h1:ksUT5KtgpZd3SAiFJNJ0AFEJVva3gjBmN7eXUZjzUwQ=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.5/go.mod h1:av+ArJpoYf3pgyrj6tcehSFW+y9/QvAY8kMooR9bZCw=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.10 h1:GtsxyiF3Nd3JahRBJbxLCCdYW9ltGQYrFWg8XdkGDd8=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.10/go.mod h1:/j67Z5XBVDx8nZVp9EuFM9/BS5dvBznbqILGuu73hug=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.2 h1:a5UTtD4mHBU3t0o6aHQZFJTNKVfxFWfPX7J0Lr7G+uY=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.2/go.mod h1:6TxbXoDSgBQ225Qd8Q+MbxUxUh6TtNKwbRt/EPS9xso=
github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM=
github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
@@ -65,18 +27,12 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -94,8 +50,6 @@ github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17w
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
@@ -182,8 +136,6 @@ github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6T
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/philhofer/fwd v1.1.2 h1:bnDivRJ1EWPjUIRXV5KfORO897HTbpFAQddBdE8t7Gw=
@@ -194,14 +146,6 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.14.0 h1:u4tNCjXOyzfgeLN+vAZaW1xUooqWDqVEsZN0U01jfAE=
github.com/redis/go-redis/v9 v9.14.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM=
github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk=
github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00=
github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
@@ -245,9 +189,8 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tinylib/msgp v1.1.8 h1:FCXC1xanKO4I8plpHGH2P7koL/RzZs12l/+r7vakfm0=
@@ -260,12 +203,6 @@ github.com/valyala/fasthttp v1.55.0 h1:Zkefzgt6a7+bVKHnu/YaYSOPfNYNisSVBo/unVCf8
github.com/valyala/fasthttp v1.55.0/go.mod h1:NkY9JtkrpPKmgwV3HTaS2HWaJss9RSIsRVfcxxoHiOM=
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
github.com/xuri/efp v0.0.0-20240408161823-9ad904a10d6d h1:llb0neMWDQe87IzJLS4Ci7psK/lVsjIS2otl+1WyRyY=
github.com/xuri/efp v0.0.0-20240408161823-9ad904a10d6d/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
github.com/xuri/excelize/v2 v2.9.0 h1:1tgOaEq92IOEumR1/JfYS/eR0KHOCsRv/rYXXh6YJQE=
github.com/xuri/excelize/v2 v2.9.0/go.mod h1:uqey4QBZ9gdMeWApPLdhm9x+9o2lq4iVmjiLfBS5hdE=
github.com/xuri/nfp v0.0.0-20240318013403-ab9948c2c4a7 h1:hPVCafDV85blFTabnqKgNhDCkJX25eik94Si9cTER4A=
github.com/xuri/nfp v0.0.0-20240318013403-ab9948c2c4a7/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
@@ -292,8 +229,6 @@ golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
@@ -371,12 +306,4 @@ gorm.io/driver/postgres v1.5.9 h1:DkegyItji119OlcaLjqN11kHoUgZ/j13E0jkJZgD6A8=
gorm.io/driver/postgres v1.5.9/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
gorm.io/gorm v1.25.11 h1:/Wfyg1B/je1hnDx3sMkX+gAlxrlZpn6X0BXRlwXlvHg=
gorm.io/gorm v1.25.11/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
+44
View File
@@ -0,0 +1,44 @@
package capabilities
import (
"strings"
recordings "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings"
)
// FromPermissions returns a filtered map of capabilities that the frontend can use
// to toggle features. Only permissions recognized by the application are exposed.
func FromPermissions(perms []string) map[string]bool {
if len(perms) == 0 {
return nil
}
out := make(map[string]bool)
for _, perm := range perms {
if key, ok := normalizeAndAllow(perm); ok {
out[key] = true
}
}
if len(out) == 0 {
return nil
}
return out
}
func normalizeAndAllow(perm string) (string, bool) {
perm = strings.ToLower(strings.TrimSpace(perm))
if perm == "" {
return "", false
}
if _, ok := allowed[perm]; !ok {
return "", false
}
return perm, true
}
var allowed = map[string]struct{}{
recordings.PermissionRecordingRead: {},
recordings.PermissionRecordingCreate: {},
recordings.PermissionRecordingUpdate: {},
recordings.PermissionRecordingDelete: {},
}
@@ -84,9 +84,8 @@ func (r *approvalRepositoryImpl) LatestByTargets(
result := make(map[uint]entity.Approval, len(approvableIDs))
q := r.DB().WithContext(ctx).
Select("DISTINCT ON (approvable_id) *").
Where("approvable_type = ? AND approvable_id IN ?", workflow, approvableIDs).
Order("approvable_id, action_at DESC")
Order("action_at DESC")
if modifier != nil {
q = modifier(q)
@@ -187,11 +187,10 @@ func (r *BaseRepositoryImpl[T]) PatchOne(
updates map[string]any,
modifier func(*gorm.DB) *gorm.DB,
) error {
q := r.db.WithContext(ctx)
q := r.db.WithContext(ctx).Model(new(T)).Where("id = ?", id)
if modifier != nil {
q = modifier(q)
}
q = q.Model(new(T)).Where("id = ?", id)
result := q.Updates(updates)
if result.Error != nil {
@@ -1,62 +0,0 @@
package repository
import (
"context"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gorm.io/gorm"
)
type DocumentRepository interface {
BaseRepository[entity.Document]
ListByTarget(ctx context.Context, documentableType string, documentableID uint64, modifier func(*gorm.DB) *gorm.DB) ([]entity.Document, error)
DeleteByTarget(ctx context.Context, documentableType string, documentableID uint64, modifier func(*gorm.DB) *gorm.DB) error
}
type documentRepositoryImpl struct {
*BaseRepositoryImpl[entity.Document]
}
func NewDocumentRepository(db *gorm.DB) DocumentRepository {
return &documentRepositoryImpl{
BaseRepositoryImpl: NewBaseRepository[entity.Document](db),
}
}
func (r *documentRepositoryImpl) ListByTarget(
ctx context.Context,
documentableType string,
documentableID uint64,
modifier func(*gorm.DB) *gorm.DB,
) ([]entity.Document, error) {
var documents []entity.Document
q := r.DB().WithContext(ctx).
Where("documentable_type = ? AND documentable_id = ?", documentableType, documentableID)
if modifier != nil {
q = modifier(q)
}
if err := q.Order("created_at ASC").Find(&documents).Error; err != nil {
return nil, err
}
return documents, nil
}
func (r *documentRepositoryImpl) DeleteByTarget(
ctx context.Context,
documentableType string,
documentableID uint64,
modifier func(*gorm.DB) *gorm.DB,
) error {
q := r.DB().WithContext(ctx).
Where("documentable_type = ? AND documentable_id = ?", documentableType, documentableID)
if modifier != nil {
q = modifier(q)
}
return q.Delete(&entity.Document{}).Error
}
@@ -2,7 +2,6 @@ package repository
import (
"context"
"errors"
"fmt"
"gorm.io/gorm"
@@ -10,59 +9,45 @@ import (
// Exists reports whether a record with the given ID exists for type T.
func Exists[T any](ctx context.Context, db *gorm.DB, id uint) (bool, error) {
var marker int
err := db.WithContext(ctx).
var count int64
if err := db.WithContext(ctx).
Model(new(T)).
Select("1").
Where("id = ?", id).
Limit(1).
Take(&marker).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
if err != nil {
Count(&count).Error; err != nil {
return false, err
}
return true, nil
return count > 0, nil
}
func ExistsByName[T any](ctx context.Context, db *gorm.DB, name string, excludeID *uint) (bool, error) {
var count int64
q := db.WithContext(ctx).
Model(new(T)).
Select("1").
Where("name = ?", name).
Where("deleted_at IS NULL")
if excludeID != nil {
q = q.Where("id <> ?", *excludeID)
}
var marker int
if err := q.Limit(1).Take(&marker).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
if err := q.Count(&count).Error; err != nil {
return false, err
}
return true, nil
return count > 0, nil
}
func ExistsByField[T any](ctx context.Context, db *gorm.DB, field string, value any, excludeID *uint) (bool, error) {
if field == "" {
return false, fmt.Errorf("field is required")
}
var count int64
q := db.WithContext(ctx).
Model(new(T)).
Select("1").
Where(fmt.Sprintf("%s = ?", field), value).
Where("deleted_at IS NULL")
if excludeID != nil {
q = q.Where("id <> ?", *excludeID)
}
var marker int
if err := q.Limit(1).Take(&marker).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
if err := q.Count(&count).Error; err != nil {
return false, err
}
return true, nil
return count > 0, nil
}
@@ -1,311 +0,0 @@
package repository
import (
"context"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"gorm.io/gorm"
)
type HppCostRepository interface {
GetProjectFlockKandangIDs(ctx context.Context, projectFlockId uint) ([]uint, error)
GetDocCost(ctx context.Context, projectFlockKandangIDs []uint) (float64, error)
GetBudgetCostByProjectFlockId(ctx context.Context, projectFlockId uint) (float64, error)
GetExpedisionCost(ctx context.Context, projectFlockKandangIDs []uint) (float64, error)
GetFeedUsageCost(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, error)
GetOvkUsageCost(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, error)
GetTotalPopulation(ctx context.Context, projectFlockKandangIDs []uint) (float64, error)
GetPulletCost(ctx context.Context, projectFlockKandangId uint) (float64, error)
GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, float64, error)
GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, startDate *time.Time, endDate *time.Time) (float64, float64, error)
GetProjectFlockIDByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (uint, error)
GetTransferSourceSummary(ctx context.Context, projectFlockKandangId uint) (uint, float64, error)
}
type HppRepositoryImpl struct {
db *gorm.DB
}
func NewHppCostRepository(db *gorm.DB) HppCostRepository {
return &HppRepositoryImpl{db: db}
}
func (r *HppRepositoryImpl) GetProjectFlockKandangIDs(ctx context.Context, projectFlockId uint) ([]uint, error) {
var ids []uint
err := r.db.WithContext(ctx).
Table("project_flock_kandangs").
Select("id").
Where("project_flock_id = ?", projectFlockId).
Scan(&ids).Error
if err != nil {
return nil, err
}
return ids, nil
}
func (r *HppRepositoryImpl) GetDocCost(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) {
var total float64
err := r.db.WithContext(ctx).
Table("project_chickins AS pc").
Select("COALESCE(SUM(pc.usage_qty * COALESCE(pi.price, 0)), 0)").
Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id AND sa.stockable_type = ?", fifo.UsableKeyProjectChickin.String(), fifo.StockableKeyPurchaseItems.String()).
Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id").
Where("pc.project_flock_kandang_id IN (?)", projectFlockKandangIDs).
Scan(&total).Error
if err != nil {
return 0, err
}
return total, nil
}
func (r *HppRepositoryImpl) GetBudgetCostByProjectFlockId(ctx context.Context, projectFlockId uint) (float64, error) {
var total float64
err := r.db.WithContext(ctx).
Table("project_budgets AS pb").
Select("COALESCE(SUM(pb.qty * pb.price), 0)").
Where("pb.project_flock_id = ?", projectFlockId).
Scan(&total).Error
if err != nil {
return 0, err
}
return total, nil
}
func (r *HppRepositoryImpl) GetExpedisionCost(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) {
var total float64
err := r.db.WithContext(ctx).
Table("expense_nonstocks AS en").
Select("COALESCE(SUM(er.qty * er.price), 0)").
Joins("JOIN expense_realizations AS er ON er.expense_nonstock_id = en.id").
Joins("JOIN flags AS f ON f.flagable_id = en.nonstock_id AND f.flagable_type = ?", entity.FlagableTypeNonstock).
Where("en.project_flock_kandang_id IN (?)", projectFlockKandangIDs).
Where("f.name = ?", utils.FlagEkspedisi).
Scan(&total).Error
if err != nil {
return 0, err
}
return total, nil
}
func (r *HppRepositoryImpl) GetFeedUsageCost(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, error) {
if date == nil {
now := time.Now()
date = &now
}
var total float64
err := r.db.WithContext(ctx).
Table("recordings AS r").
Select("COALESCE(SUM(rs.usage_qty * COALESCE(pi.price, 0)), 0)").
Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id").
Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id").
Joins("JOIN flags AS f ON f.flagable_id = pw.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct).
Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String()).
Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id").
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
Where("r.record_datetime <= ?", *date).
Where("f.name = ?", utils.FlagPakan).
Scan(&total).Error
if err != nil {
return 0, err
}
return total, nil
}
func (r *HppRepositoryImpl) GetOvkUsageCost(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, error) {
if date == nil {
now := time.Now()
date = &now
}
flags := []utils.FlagType{
utils.FlagOVK,
utils.FlagObat,
utils.FlagVitamin,
utils.FlagKimia,
}
var total float64
err := r.db.WithContext(ctx).
Table("recordings AS r").
Select("COALESCE(SUM(rs.usage_qty * COALESCE(pi.price, 0)), 0)").
Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id").
Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id").
Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String()).
Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id").
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
Where("r.record_datetime <= ?", *date).
Where("EXISTS (SELECT 1 FROM flags f WHERE f.flagable_id = pw.product_id AND f.flagable_type = ? AND f.name IN ?)", entity.FlagableTypeProduct, flags).
Scan(&total).Error
if err != nil {
return 0, err
}
return total, nil
}
func (r *HppRepositoryImpl) GetTotalPopulation(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) {
var total float64
err := r.db.WithContext(ctx).
Table("project_chickins AS pc").
Select("COALESCE(SUM(pc.usage_qty), 0)").
Where("pc.project_flock_kandang_id IN (?)", projectFlockKandangIDs).
Scan(&total).Error
if err != nil {
return 0, err
}
return total, nil
}
func (r *HppRepositoryImpl) GetPulletCost(ctx context.Context, projectFlockKandangId uint) (float64, error) {
stockablePurchase := fifo.StockableKeyPurchaseItems.String()
stockableTransferIn := fifo.StockableKeyStockTransferIn.String()
usableProjectChickin := fifo.UsableKeyProjectChickin.String()
var total float64
err := r.db.WithContext(ctx).
Table("project_chickins AS pc").
Select(`
COALESCE(SUM(pc.usage_qty * CASE
WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0)
WHEN sa.stockable_type = ? THEN COALESCE(tpi.price, 0)
ELSE 0
END), 0)`,
stockablePurchase, stockableTransferIn).
Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id", usableProjectChickin).
Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase).
Joins("LEFT JOIN stock_allocations AS tsa ON tsa.usable_type = ? AND tsa.usable_id = sa.stockable_id AND sa.stockable_type = ? AND tsa.stockable_type = ?", stockableTransferIn, stockableTransferIn, stockablePurchase).
Joins("LEFT JOIN purchase_items AS tpi ON tpi.id = tsa.stockable_id").
Where("pc.project_flock_kandang_id = ?", projectFlockKandangId).
Scan(&total).Error
if err != nil {
return 0, err
}
return total, nil
}
func (r *HppRepositoryImpl) GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, float64, error) {
if date == nil {
now := time.Now()
date = &now
}
var totals struct {
TotalPieces float64
TotalWeightKg float64
}
err := r.db.WithContext(ctx).
Table("recordings AS r").
Select("COALESCE(SUM(re.qty), 0) AS total_pieces, COALESCE(SUM(re.weight), 0)AS total_weight_kg").
Joins("JOIN recording_eggs AS re ON re.recording_id = r.id").
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
Where("r.record_datetime <= ?", *date).
Scan(&totals).Error
if err != nil {
return 0, 0, err
}
return totals.TotalPieces, totals.TotalWeightKg, nil
}
func (r *HppRepositoryImpl) GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(
ctx context.Context,
projectFlockKandangIDs []uint,
startDate *time.Time,
endDate *time.Time,
) (float64, float64, error) {
if endDate == nil {
now := time.Now()
endDate = &now
}
type subResult struct {
UsableID uint
MdpUsageQty float64
MdpWeight float64
}
subQuery := r.db.WithContext(ctx).
Table("recordings AS r").
Select(`
DISTINCT sa.usable_id,
mdp.usage_qty AS mdp_usage_qty,
mdp.total_weight AS mdp_weight
`).
Joins("JOIN recording_eggs re ON re.recording_id = r.id").
Joins(
"JOIN stock_allocations sa ON sa.stockable_type = ? AND sa.stockable_id = re.id AND sa.usable_type = ?",
fifo.StockableKeyRecordingEgg.String(),
fifo.UsableKeyMarketingDelivery.String(),
).
Joins("JOIN marketing_delivery_products mdp ON mdp.id = sa.usable_id").
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
Where("r.record_datetime <= ?", *endDate).
Where("mdp.delivery_date <= ?", *startDate)
var totals struct {
TotalPieces float64
TotalWeight float64
}
err := r.db.WithContext(ctx).
Table("(?) AS x", subQuery).
Select(`
COALESCE(SUM(x.mdp_usage_qty), 0) AS total_pieces,
COALESCE(SUM(x.mdp_weight), 0) AS total_weight
`).
Scan(&totals).Error
if err != nil {
return 0, 0, err
}
return totals.TotalPieces, totals.TotalWeight, nil
}
func (r *HppRepositoryImpl) GetProjectFlockIDByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (uint, error) {
var projectFlockID uint
err := r.db.WithContext(ctx).
Table("project_flock_kandangs").
Select("project_flock_id").
Where("id = ?", projectFlockKandangId).
Scan(&projectFlockID).Error
if err != nil {
return 0, err
}
return projectFlockID, nil
}
func (r *HppRepositoryImpl) GetTransferSourceSummary(ctx context.Context, projectFlockKandangId uint) (uint, float64, error) {
var summary struct {
ProjectFlockID uint
TotalQty float64
}
err := r.db.WithContext(ctx).
Table("laying_transfer_targets AS ltt").
Select("lt.from_project_flock_id AS project_flock_id, COALESCE(SUM(ltt.total_qty), 0) AS total_qty").
Joins("JOIN laying_transfers AS lt ON lt.id = ltt.laying_transfer_id").
Where("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
}
@@ -1,76 +0,0 @@
package repository
import (
"context"
"time"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gorm.io/gorm"
)
type StockAllocationRepository interface {
BaseRepository[entity.StockAllocation]
FindActiveByUsable(ctx context.Context, usableType string, usableID uint, modifier func(*gorm.DB) *gorm.DB) ([]entity.StockAllocation, error)
ReleaseByUsable(ctx context.Context, usableType string, usableID uint, note *string, modifier func(*gorm.DB) *gorm.DB) error
}
type StockAllocationRepositoryImpl struct {
*BaseRepositoryImpl[entity.StockAllocation]
}
func NewStockAllocationRepository(db *gorm.DB) StockAllocationRepository {
return &StockAllocationRepositoryImpl{
BaseRepositoryImpl: NewBaseRepository[entity.StockAllocation](db),
}
}
func (r *StockAllocationRepositoryImpl) FindActiveByUsable(
ctx context.Context,
usableType string,
usableID uint,
modifier func(*gorm.DB) *gorm.DB,
) ([]entity.StockAllocation, error) {
var allocations []entity.StockAllocation
q := r.DB().WithContext(ctx).
Where("usable_type = ? AND usable_id = ? AND status = ?", usableType, usableID, entity.StockAllocationStatusActive)
if modifier != nil {
q = modifier(q)
}
if err := q.Order("created_at ASC").Find(&allocations).Error; err != nil {
return nil, err
}
return allocations, nil
}
func (r *StockAllocationRepositoryImpl) ReleaseByUsable(
ctx context.Context,
usableType string,
usableID uint,
note *string,
modifier func(*gorm.DB) *gorm.DB,
) error {
now := time.Now()
updates := map[string]any{
"status": entity.StockAllocationStatusReleased,
"released_at": now,
}
if note != nil {
updates["note"] = *note
}
baseDB := r.DB()
if modifier != nil {
baseDB = modifier(baseDB)
}
q := baseDB.WithContext(ctx).
Model(&entity.StockAllocation{}).
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 {
@@ -1,120 +0,0 @@
package service
import (
"context"
"errors"
"fmt"
productWarehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
warehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/master/warehouses/repositories"
projectFlockKandangRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/project_flocks/repositories"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
// Dipakai untuk semua module yang butuh cek:
// "PW ini → warehouse → kandang → project_flock_kandang sudah closing atau belum"
func EnsureProjectFlockNotClosedForProductWarehouses(
ctx context.Context,
db *gorm.DB,
productWarehouseIDs []uint,
) error {
if len(productWarehouseIDs) == 0 {
return nil
}
pwRepo := productWarehouseRepo.NewProductWarehouseRepository(db)
wRepo := warehouseRepo.NewWarehouseRepository(db)
pfkRepo := projectFlockKandangRepo.NewProjectFlockKandangRepository(db)
seenPW := make(map[uint]struct{})
seenKandang := make(map[uint]struct{})
for _, pwID := range productWarehouseIDs {
if pwID == 0 {
continue
}
if _, ok := seenPW[pwID]; ok {
continue
}
seenPW[pwID] = struct{}{}
pw, err := pwRepo.GetByID(ctx, pwID, nil)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusBadRequest,
fmt.Sprintf("Product warehouse %d tidak ditemukan", pwID))
}
return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate product warehouse")
}
wh, err := wRepo.GetByID(ctx, uint(pw.WarehouseId), nil)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusBadRequest,
fmt.Sprintf("Warehouse %d tidak ditemukan", pw.WarehouseId))
}
return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate warehouse")
}
// Warehouse tanpa kandang → bukan kandang produksi → skip
if wh.KandangId == nil || *wh.KandangId == 0 {
continue
}
kandangID := uint(*wh.KandangId)
if _, ok := seenKandang[kandangID]; ok {
continue
}
seenKandang[kandangID] = struct{}{}
pfk, err := pfkRepo.GetActiveByKandangID(ctx, kandangID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
// nggak ada project aktif untuk kandang ini → aman
continue
}
return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate project flock")
}
// INTI RULE: kalau aktif tapi sudah punya ClosedAt → anggap "project sudah closing"
if pfk != nil && pfk.ClosedAt != nil {
return fiber.NewError(fiber.StatusBadRequest, "Project sudah closing")
}
}
return nil
}
func EnsureProjectFlockNotClosedByProjectFlockKandangID(
ctx context.Context,
db *gorm.DB,
pfkIDs []uint,
) error {
pfkRepo := projectFlockKandangRepo.NewProjectFlockKandangRepository(db)
seen := make(map[uint]struct{})
for _, id := range pfkIDs {
if id == 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
pfk, err := pfkRepo.GetByID(ctx, id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return fiber.NewError(fiber.StatusBadRequest,
fmt.Sprintf("Project flock kandang %d tidak ditemukan", id))
}
return fiber.NewError(fiber.StatusInternalServerError, "Failed to validate project flock")
}
if pfk.ClosedAt != nil {
return fiber.NewError(fiber.StatusBadRequest, "Project sudah closing")
}
}
return nil
}
@@ -1,480 +0,0 @@
package service
import (
"context"
"errors"
"fmt"
"mime"
"mime/multipart"
"net/url"
"path/filepath"
"strings"
"time"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
"gitlab.com/mbugroup/lti-api.git/internal/config"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
"gitlab.com/mbugroup/lti-api.git/internal/utils"
"github.com/google/uuid"
)
const (
defaultDocumentPathLimit = 255
defaultDocumentKeyPrefix = "docs"
maxDocumentNameLength = 50
)
type DocumentService interface {
UploadDocuments(ctx context.Context, req DocumentUploadRequest) ([]DocumentUploadResult, error)
ListByTarget(ctx context.Context, documentableType string, documentableID uint64) ([]entity.Document, error)
DeleteDocuments(ctx context.Context, ids []uint, removeFromStorage bool) error
DeleteByTarget(ctx context.Context, documentableType string, documentableID uint64, removeFromStorage bool) error
PublicURL(document entity.Document) string
PresignURL(ctx context.Context, document entity.Document, expires time.Duration) (string, error)
}
type DocumentUploadRequest struct {
DocumentableType string
DocumentableID uint64
CreatedBy *uint
Files []DocumentFile
}
type DocumentFile struct {
File *multipart.FileHeader
Type string
Index *int
}
type DocumentUploadResult struct {
Document entity.Document
URL string
Index *int
}
type DocumentServiceOption func(*documentService)
type documentService struct {
repo commonRepo.DocumentRepository
storage DocumentStorage
keyPrefix string
maxPathLength int
}
func NewDocumentService(repo commonRepo.DocumentRepository, storage DocumentStorage, opts ...DocumentServiceOption) DocumentService {
svc := &documentService{
repo: repo,
storage: storage,
keyPrefix: defaultDocumentKeyPrefix,
maxPathLength: defaultDocumentPathLimit,
}
for _, opt := range opts {
opt(svc)
}
return svc
}
func NewDocumentServiceFromConfig(ctx context.Context, repo commonRepo.DocumentRepository) (DocumentService, error) {
if repo == nil {
return nil, errors.New("document repository is required")
}
if strings.TrimSpace(config.S3Bucket) == "" {
return nil, errors.New("S3_BUCKET is not configured")
}
storage, err := NewS3DocumentStorage(ctx, S3DocumentStorageConfig{
Region: config.S3Region,
Bucket: config.S3Bucket,
AccessKey: config.S3AccessKey,
SecretKey: config.S3SecretKey,
Endpoint: config.S3Endpoint,
BaseURL: config.S3PublicBaseURL,
ForcePathStyle: config.S3ForcePathStyle,
})
if err != nil {
return nil, err
}
prefix := config.S3DocumentKeyPrefix
if prefix == "" {
prefix = defaultDocumentKeyPrefix
}
return NewDocumentService(
repo,
storage,
WithDocumentKeyPrefix(prefix),
WithDocumentPathLimit(defaultDocumentPathLimit),
), nil
}
func WithDocumentKeyPrefix(prefix string) DocumentServiceOption {
return func(svc *documentService) {
prefix = strings.Trim(prefix, "/")
if prefix == "" {
prefix = defaultDocumentKeyPrefix
}
svc.keyPrefix = prefix
}
}
func WithDocumentPathLimit(limit int) DocumentServiceOption {
return func(svc *documentService) {
if limit > 0 {
svc.maxPathLength = limit
}
}
}
func (s *documentService) UploadDocuments(ctx context.Context, req DocumentUploadRequest) ([]DocumentUploadResult, error) {
if s.repo == nil {
return nil, errors.New("document repository not configured")
}
if s.storage == nil {
return nil, errors.New("document storage not configured")
}
documentableType := strings.ToUpper(strings.TrimSpace(req.DocumentableType))
if documentableType == "" {
return nil, errors.New("documentable type is required")
}
if req.DocumentableID == 0 {
return nil, errors.New("documentable id is required")
}
if len(req.Files) == 0 {
return nil, errors.New("no files to upload")
}
var createdBy *uint
if req.CreatedBy != nil && *req.CreatedBy != 0 {
idCopy := *req.CreatedBy
createdBy = &idCopy
}
results := make([]DocumentUploadResult, 0, len(req.Files))
createdDocs := make([]entity.Document, 0, len(req.Files))
for _, file := range req.Files {
if file.File == nil {
return nil, errors.New("file header is required")
}
originalName := sanitizeDocumentName(file.File.Filename)
contentType := detectContentType(file.File, originalName)
ext := detectExtension(file.File.Filename, contentType)
key, err := s.generateObjectKey(ext)
if err != nil {
s.rollbackDocuments(ctx, createdDocs)
return nil, err
}
reader, err := file.File.Open()
if err != nil {
s.rollbackDocuments(ctx, createdDocs)
return nil, err
}
uploadRes, err := s.storage.Upload(ctx, key, reader, file.File.Size, contentType)
_ = reader.Close()
if err != nil {
s.rollbackDocuments(ctx, createdDocs)
return nil, err
}
docType := resolveDocumentType(file.Type, documentableType)
doc := entity.Document{
DocumentableType: documentableType,
DocumentableId: req.DocumentableID,
Type: docType,
Path: uploadRes.Key,
Name: originalName,
Ext: strings.TrimPrefix(ext, "."),
Size: float64(file.File.Size),
CreatedBy: createdBy,
}
if err := s.repo.CreateOne(ctx, &doc, nil); err != nil {
_ = s.storage.Delete(ctx, uploadRes.Key)
s.rollbackDocuments(ctx, createdDocs)
return nil, err
}
createdDocs = append(createdDocs, doc)
results = append(results, DocumentUploadResult{
Document: doc,
URL: uploadRes.URL,
Index: cloneIndex(file.Index),
})
}
return results, nil
}
func (s *documentService) ListByTarget(ctx context.Context, documentableType string, documentableID uint64) ([]entity.Document, error) {
if s.repo == nil {
return nil, errors.New("document repository not configured")
}
documentableType = strings.ToUpper(strings.TrimSpace(documentableType))
if documentableType == "" {
return nil, errors.New("documentable type is required")
}
if documentableID == 0 {
return nil, errors.New("documentable id is required")
}
return s.repo.ListByTarget(ctx, documentableType, documentableID, nil)
}
func (s *documentService) DeleteDocuments(ctx context.Context, ids []uint, removeFromStorage bool) error {
if s.repo == nil {
return errors.New("document repository not configured")
}
if len(ids) == 0 {
return nil
}
docs, err := s.repo.GetByIDs(ctx, ids, nil)
if err != nil {
return err
}
for _, doc := range docs {
if err := s.repo.DeleteOne(ctx, doc.Id); err != nil {
return err
}
if removeFromStorage && s.storage != nil {
if err := s.storage.Delete(ctx, doc.Path); err != nil {
utils.Log.WithError(err).Warnf("failed to delete document object %s", doc.Path)
}
}
}
return nil
}
func (s *documentService) DeleteByTarget(ctx context.Context, documentableType string, documentableID uint64, removeFromStorage bool) error {
if s.repo == nil {
return errors.New("document repository not configured")
}
documentableType = strings.ToUpper(strings.TrimSpace(documentableType))
if documentableType == "" || documentableID == 0 {
return errors.New("documentable type and id are required")
}
var docs []entity.Document
if removeFromStorage && s.storage != nil {
var err error
docs, err = s.repo.ListByTarget(ctx, documentableType, documentableID, nil)
if err != nil {
return err
}
}
if err := s.repo.DeleteByTarget(ctx, documentableType, documentableID, nil); err != nil {
return err
}
if removeFromStorage && len(docs) > 0 {
for _, doc := range docs {
if err := s.storage.Delete(ctx, doc.Path); err != nil {
utils.Log.WithError(err).Warnf("failed to delete document object %s", doc.Path)
}
}
}
return nil
}
func (s *documentService) PublicURL(document entity.Document) string {
if s.storage == nil || strings.TrimSpace(document.Path) == "" {
return ""
}
return s.storage.URL(document.Path)
}
func (s *documentService) PresignURL(ctx context.Context, document entity.Document, expires time.Duration) (string, error) {
if s.storage == nil {
return "", errors.New("document storage not configured")
}
if strings.TrimSpace(document.Path) == "" {
return "", errors.New("document path is required")
}
return s.storage.PresignURL(ctx, document.Path, expires)
}
// ResolveDocumentURL normalizes a stored path or URL into a presigned URL.
func ResolveDocumentURL(
ctx context.Context,
svc DocumentService,
rawPath string,
expires time.Duration,
) (string, error) {
if svc == nil {
return "", nil
}
rawPath = strings.TrimSpace(rawPath)
if rawPath == "" {
return "", nil
}
key := rawPath
lower := strings.ToLower(rawPath)
if strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://") {
key = extractS3KeyFromURL(rawPath)
if key == "" {
return "", nil
}
}
return svc.PresignURL(ctx, entity.Document{Path: key}, expires)
}
func extractS3KeyFromURL(raw string) string {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return ""
}
path := strings.TrimPrefix(parsed.Path, "/")
if path == "" {
return ""
}
host := strings.ToLower(strings.TrimSpace(parsed.Host))
if strings.HasPrefix(host, "s3.") || strings.HasPrefix(host, "s3-") {
parts := strings.SplitN(path, "/", 2)
if len(parts) == 2 {
return parts[1]
}
return ""
}
return path
}
func (s *documentService) generateObjectKey(ext string) (string, error) {
normalizedExt := strings.TrimSpace(ext)
if normalizedExt != "" && !strings.HasPrefix(normalizedExt, ".") {
normalizedExt = "." + normalizedExt
}
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)
}
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)
}
}
if len(key) > s.maxPathLength {
return "", fmt.Errorf("object key exceeds maximum length (%d)", s.maxPathLength)
}
return key, nil
}
func (s *documentService) rollbackDocuments(ctx context.Context, docs []entity.Document) {
if len(docs) == 0 {
return
}
for i := len(docs) - 1; i >= 0; i-- {
doc := docs[i]
if s.repo != nil && doc.Id != 0 {
if err := s.repo.DeleteOne(ctx, doc.Id); err != nil {
utils.Log.WithError(err).Warnf("failed to rollback document #%d", doc.Id)
}
}
if s.storage != nil && strings.TrimSpace(doc.Path) != "" {
if err := s.storage.Delete(ctx, doc.Path); err != nil {
utils.Log.WithError(err).Warnf("failed to rollback document object %s", doc.Path)
}
}
}
}
func sanitizeDocumentName(name string) string {
name = filepath.Base(strings.TrimSpace(name))
if name == "." || name == "" {
name = "document"
}
name = strings.Map(func(r rune) rune {
if r < 32 {
return -1
}
switch r {
case '\\', '/', ':', '*', '?', '"', '<', '>', '|':
return '-'
default:
return r
}
}, name)
if len(name) > maxDocumentNameLength {
runes := []rune(name)
if len(runes) > maxDocumentNameLength {
name = string(runes[:maxDocumentNameLength])
}
}
return name
}
func detectExtension(filename, contentType string) string {
ext := strings.ToLower(strings.TrimSpace(filepath.Ext(filename)))
if ext == "" && contentType != "" {
if exts, _ := mime.ExtensionsByType(contentType); len(exts) > 0 {
ext = exts[0]
}
}
if ext == "" {
return ".bin"
}
if !strings.HasPrefix(ext, ".") {
ext = "." + ext
}
return ext
}
func detectContentType(file *multipart.FileHeader, filename string) string {
if file == nil {
return "application/octet-stream"
}
contentType := strings.TrimSpace(file.Header.Get("Content-Type"))
if contentType != "" {
return contentType
}
if ext := filepath.Ext(filename); ext != "" {
if guess := mime.TypeByExtension(ext); guess != "" {
return guess
}
}
return "application/octet-stream"
}
func resolveDocumentType(fileType, fallback string) string {
value := strings.ToUpper(strings.TrimSpace(fileType))
if value == "" {
return fallback
}
return value
}
func cloneIndex(index *int) *int {
if index == nil {
return nil
}
value := *index
return &value
}
@@ -1,101 +0,0 @@
package service
import (
"bytes"
"context"
"mime/multipart"
"net/http/httptest"
"strings"
"testing"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
"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"
"gorm.io/gorm"
)
func TestDocumentServiceUpload(t *testing.T) {
if strings.TrimSpace(config.S3Bucket) == "" {
t.Fatal("S3 bucket is not configured; set S3_* env vars to run this test")
}
ctx := context.Background()
db := setupDocumentTestDB(t)
repo := commonRepo.NewDocumentRepository(db)
svc, err := NewDocumentServiceFromConfig(ctx, repo)
if err != nil {
t.Fatalf("failed to create document service from config: %v", err)
}
file := newTestFileHeader(t, "integration-proof.txt", "text/plain", []byte("document integration test"))
userID := uint(100)
results, err := svc.UploadDocuments(ctx, DocumentUploadRequest{
DocumentableType: "INVENTORY_TRANSFER",
DocumentableID: 99,
CreatedBy: &userID,
Files: []DocumentFile{
{File: file, Type: "integration"},
},
})
if err != nil {
t.Fatalf("upload to S3 failed: %v", err)
}
if len(results) != 1 {
t.Fatalf("expected 1 uploaded document, got %d", len(results))
}
doc := results[0].Document
if doc.Path == "" {
t.Fatalf("expected non-empty storage path")
}
if results[0].URL == "" {
t.Fatalf("expected public URL for uploaded document")
}
t.Logf("uploaded document #%d to %s (path=%s)", doc.Id, results[0].URL, doc.Path)
}
func setupDocumentTestDB(t *testing.T) *gorm.DB {
t.Helper()
if strings.TrimSpace(config.DBHost) == "" || strings.TrimSpace(config.DBName) == "" {
t.Fatal("database configuration missing; ensure DB_HOST and DB_NAME are set")
}
db := database.Connect(config.DBHost, config.DBName)
if db == nil {
t.Fatal("failed to create database connection")
}
if err := db.AutoMigrate(&entity.Document{}); err != nil {
t.Fatalf("failed to migrate document table: %v", err)
}
return db
}
func newTestFileHeader(t *testing.T, filename, contentType string, data []byte) *multipart.FileHeader {
t.Helper()
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("documents", filename)
if err != nil {
t.Fatalf("failed to create form file: %v", err)
}
if _, err := part.Write(data); err != nil {
t.Fatalf("failed to write file data: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("failed to close writer: %v", err)
}
req := httptest.NewRequest("POST", "http://example.com/upload", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
_, fileHeader, err := req.FormFile("documents")
if err != nil {
t.Fatalf("failed to parse form file: %v", err)
}
fileHeader.Header.Set("Content-Type", contentType)
return fileHeader
}
@@ -1,185 +0,0 @@
package service
import (
"context"
"errors"
"fmt"
"io"
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
)
type DocumentStorage interface {
Upload(ctx context.Context, key string, body io.Reader, size int64, contentType string) (DocumentStorageUploadResult, error)
Delete(ctx context.Context, key string) error
URL(key string) string
PresignURL(ctx context.Context, key string, expires time.Duration) (string, error)
}
type DocumentStorageUploadResult struct {
Key string
URL string
ETag string
}
type S3DocumentStorageConfig struct {
Region string
Bucket string
AccessKey string
SecretKey string
Endpoint string
BaseURL string
ForcePathStyle bool
}
type s3DocumentStorage struct {
client *s3.Client
presignClient *s3.PresignClient
bucket string
base string
}
func NewS3DocumentStorage(ctx context.Context, cfg S3DocumentStorageConfig) (DocumentStorage, error) {
bucket := strings.TrimSpace(cfg.Bucket)
if bucket == "" {
return nil, errors.New("s3 bucket is required")
}
region := strings.TrimSpace(cfg.Region)
if region == "" {
region = "us-east-1"
}
options := []func(*awsconfig.LoadOptions) error{
awsconfig.WithRegion(region),
}
endpoint := strings.TrimSpace(cfg.Endpoint)
if endpoint != "" {
resolver := aws.EndpointResolverWithOptionsFunc(func(service, region string, _ ...interface{}) (aws.Endpoint, error) {
if service == s3.ServiceID {
return aws.Endpoint{
URL: endpoint,
SigningRegion: region,
HostnameImmutable: true,
}, nil
}
return aws.Endpoint{}, &aws.EndpointNotFoundError{}
})
options = append(options, awsconfig.WithEndpointResolverWithOptions(resolver))
}
accessKey := strings.TrimSpace(cfg.AccessKey)
secretKey := strings.TrimSpace(cfg.SecretKey)
if accessKey != "" && secretKey != "" {
options = append(options, awsconfig.WithCredentialsProvider(
credentials.NewStaticCredentialsProvider(accessKey, secretKey, ""),
))
}
awsCfg, err := awsconfig.LoadDefaultConfig(ctx, options...)
if err != nil {
return nil, err
}
client := s3.NewFromConfig(awsCfg, func(o *s3.Options) {
o.UsePathStyle = cfg.ForcePathStyle
})
presignClient := s3.NewPresignClient(client)
baseURL := strings.TrimSuffix(strings.TrimSpace(cfg.BaseURL), "/")
if baseURL == "" {
if endpoint != "" {
baseURL = fmt.Sprintf("%s/%s", strings.TrimSuffix(endpoint, "/"), bucket)
} else {
baseURL = fmt.Sprintf("https://%s.s3.%s.amazonaws.com", bucket, region)
}
}
return &s3DocumentStorage{
client: client,
presignClient: presignClient,
bucket: bucket,
base: baseURL,
}, nil
}
func (s *s3DocumentStorage) Upload(ctx context.Context, key string, body io.Reader, size int64, contentType string) (DocumentStorageUploadResult, error) {
if strings.TrimSpace(key) == "" {
return DocumentStorageUploadResult{}, errors.New("storage key is required")
}
if size < 0 {
size = 0
}
input := &s3.PutObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
Body: body,
}
input.ContentLength = aws.Int64(size)
if ct := strings.TrimSpace(contentType); ct != "" {
input.ContentType = aws.String(ct)
}
out, err := s.client.PutObject(ctx, input)
if err != nil {
return DocumentStorageUploadResult{}, err
}
var etag string
if out.ETag != nil {
etag = strings.Trim(*out.ETag, "\"")
}
return DocumentStorageUploadResult{
Key: key,
URL: s.URL(key),
ETag: etag,
}, nil
}
func (s *s3DocumentStorage) Delete(ctx context.Context, key string) error {
if strings.TrimSpace(key) == "" {
return nil
}
_, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
})
return err
}
func (s *s3DocumentStorage) URL(key string) string {
key = strings.TrimPrefix(strings.TrimSpace(key), "/")
if key == "" {
return s.base
}
if s.base == "" {
return key
}
return fmt.Sprintf("%s/%s", s.base, key)
}
func (s *s3DocumentStorage) PresignURL(ctx context.Context, key string, expires time.Duration) (string, error) {
key = strings.TrimPrefix(strings.TrimSpace(key), "/")
if key == "" {
return "", errors.New("storage key is required")
}
if expires <= 0 {
expires = 15 * time.Minute
}
out, err := s.presignClient.PresignGetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
}, s3.WithPresignExpires(expires))
if err != nil {
return "", err
}
return out.URL, nil
}
@@ -1,967 +0,0 @@
package service
import (
"context"
"errors"
"fmt"
"math"
"sort"
"strings"
"time"
"github.com/sirupsen/logrus"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
"gitlab.com/mbugroup/lti-api.git/internal/entities"
productWarehouseRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/inventory/product-warehouses/repositories"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type FifoService interface {
RegisterStockable(cfg fifo.StockableConfig) error
RegisterUsable(cfg fifo.UsableConfig) error
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 {
db *gorm.DB
logger *logrus.Logger
allocations commonRepo.StockAllocationRepository
productWarehouseRepo productWarehouseRepo.ProductWarehouseRepository
defaultOrderBy []string
pendingBatchPerUsable int
maxLotsPerStockable int
defaultAllocationNotes string
}
func NewFifoService(
db *gorm.DB,
allocations commonRepo.StockAllocationRepository,
productWarehouseRepo productWarehouseRepo.ProductWarehouseRepository,
logger *logrus.Logger,
) FifoService {
if logger == nil {
logger = logrus.StandardLogger()
}
return &fifoService{
db: db,
logger: logger,
allocations: allocations,
productWarehouseRepo: productWarehouseRepo,
defaultOrderBy: []string{"created_at ASC", "id ASC"},
pendingBatchPerUsable: 25,
maxLotsPerStockable: 50,
}
}
func (s *fifoService) 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 (s *fifoService) txOrDB(tx, db *gorm.DB) *gorm.DB {
if tx != nil {
return tx
}
return db
}
func (s *fifoService) RegisterStockable(cfg fifo.StockableConfig) error {
return fifo.RegisterStockable(cfg)
}
func (s *fifoService) RegisterUsable(cfg fifo.UsableConfig) error {
return fifo.RegisterUsable(cfg)
}
type StockReplenishRequest struct {
StockableKey fifo.StockableKey
StockableID uint
ProductWarehouseID uint
Quantity float64
Note *string
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
RemainingPending float64
}
type StockConsumeRequest struct {
UsableKey fifo.UsableKey
UsableID uint
ProductWarehouseID uint
Quantity float64
AllowPending bool
Note *string
Tx *gorm.DB
}
type AllocationDetail struct {
StockableKey fifo.StockableKey
StockableID uint
Quantity float64
}
type StockConsumeResult struct {
RequestedQuantity float64
UsageQuantity float64
PendingQuantity float64
AddedAllocations []AllocationDetail
ReleasedQuantity float64
}
type StockReleaseRequest struct {
UsableKey fifo.UsableKey
UsableID uint
Reason *string
Tx *gorm.DB
}
func (s *fifoService) AdjustStockableQuantity(ctx context.Context, req StockAdjustRequest) error {
if req.StockableID == 0 || strings.TrimSpace(req.StockableKey.String()) == "" {
return errors.New("stockable key and id are required")
}
if req.ProductWarehouseID == 0 {
return errors.New("product warehouse id is required")
}
if req.Quantity == 0 {
return nil
}
if req.Quantity > 0 {
return errors.New("quantity must be negative")
}
cfg, ok := fifo.Stockable(req.StockableKey)
if !ok {
return fmt.Errorf("stockable %q is not registered", req.StockableKey)
}
return s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error {
if err := s.incrementStockableQty(ctx, tx, cfg, req.StockableID, req.Quantity); err != nil {
return err
}
return s.productWarehouseRepo.AdjustQuantities(ctx, map[uint]float64{
req.ProductWarehouseID: req.Quantity,
}, func(db *gorm.DB) *gorm.DB {
return s.txOrDB(tx, db)
})
})
}
func (s *fifoService) Replenish(ctx context.Context, req StockReplenishRequest) (*StockReplenishResult, error) {
if req.StockableID == 0 || strings.TrimSpace(req.StockableKey.String()) == "" {
return nil, errors.New("stockable key and id are required")
}
if req.ProductWarehouseID == 0 {
return nil, errors.New("product warehouse id is required")
}
if req.Quantity <= 0 {
return nil, errors.New("quantity must be greater than zero")
}
cfg, ok := fifo.Stockable(req.StockableKey)
if !ok {
return nil, fmt.Errorf("stockable %q is not registered", req.StockableKey)
}
result := &StockReplenishResult{
AddedQuantity: req.Quantity,
}
err := 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
}
if err := s.productWarehouseRepo.AdjustQuantities(ctx, map[uint]float64{
req.ProductWarehouseID: req.Quantity,
}, func(db *gorm.DB) *gorm.DB {
return s.txOrDB(tx, db)
}); err != nil {
return err
}
resolved, err := s.resolvePendingForWarehouse(ctx, tx, req.ProductWarehouseID)
if err != nil {
return err
}
result.PendingResolved = resolved
return nil
})
if err != nil {
return nil, err
}
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")
}
if req.Quantity < 0 {
return nil, errors.New("quantity must be zero or greater")
}
cfg, ok := fifo.Usable(req.UsableKey)
if !ok {
return nil, fmt.Errorf("usable %q is not registered", req.UsableKey)
}
result := &StockConsumeResult{
RequestedQuantity: req.Quantity,
}
err := s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error {
ctxRow, err := s.loadUsableContext(ctx, tx, cfg, req.UsableID)
if err != nil {
return err
}
productWarehouseID := ctxRow.ProductWarehouseID
if productWarehouseID == 0 {
return fmt.Errorf("usable %q (id: %d) has no product warehouse reference", req.UsableKey, req.UsableID)
}
if req.ProductWarehouseID != 0 && req.ProductWarehouseID != productWarehouseID {
return fmt.Errorf("usable %q (id: %d) references product warehouse %d but %d was provided", req.UsableKey, req.UsableID, productWarehouseID, req.ProductWarehouseID)
}
currentUsage := ctxRow.UsageQty
currentPending := ctxRow.PendingQty
currentTotal := currentUsage + currentPending
delta := req.Quantity - currentTotal
var (
usageDelta float64
pendingDelta float64
addedAlloc []AllocationDetail
releasedAmount float64
)
switch {
case delta > 0:
var excludedStockables []fifo.StockableKey
if cfg.ExcludedStockables != nil {
excludedStockables = cfg.ExcludedStockables
}
allocationRes, err := s.allocateFromStock(ctx, tx, productWarehouseID, req.UsableKey, req.UsableID, delta, excludedStockables)
if err != nil {
return err
}
if allocationRes.pending > 0 && !req.AllowPending {
return fmt.Errorf("insufficient stock: requested %.3f, allocated %.3f", req.Quantity, currentUsage+allocationRes.allocated)
}
usageDelta += allocationRes.allocated
pendingDelta += allocationRes.pending
addedAlloc = allocationRes.allocations
if allocationRes.allocated > 0 {
if err := s.productWarehouseRepo.AdjustQuantities(ctx, map[uint]float64{
productWarehouseID: -allocationRes.allocated,
}, func(db *gorm.DB) *gorm.DB {
return s.txOrDB(tx, db)
}); err != nil {
return err
}
}
case delta < 0:
reductionTarget := -delta
if currentPending > 0 {
pendingReduction := math.Min(currentPending, reductionTarget)
if pendingReduction > 0 {
pendingDelta -= pendingReduction
reductionTarget -= pendingReduction
}
}
if reductionTarget > 0 {
released, err := s.releaseUsagePortion(ctx, tx, req.UsableKey, req.UsableID, reductionTarget, productWarehouseID)
if err != nil {
return err
}
if released+1e-6 < reductionTarget {
return fmt.Errorf("unable to release %.3f from usable %d, only %.3f available", reductionTarget, req.UsableID, released)
}
usageDelta -= released
releasedAmount = released
}
default:
// no change
}
if err := s.applyUsableDeltas(ctx, tx, cfg, req.UsableID, usageDelta, pendingDelta); err != nil {
return err
}
result.AddedAllocations = addedAlloc
result.ReleasedQuantity = releasedAmount
result.UsageQuantity = currentUsage + usageDelta
result.PendingQuantity = currentPending + pendingDelta
return nil
})
if err != nil {
return nil, err
}
return result, nil
}
func (s *fifoService) ReleaseUsage(ctx context.Context, req StockReleaseRequest) error {
if req.UsableID == 0 || strings.TrimSpace(req.UsableKey.String()) == "" {
return errors.New("usable key and id are required")
}
return s.withTransaction(ctx, req.Tx, func(tx *gorm.DB) error {
cfg, ok := fifo.Usable(req.UsableKey)
if !ok {
return fmt.Errorf("usable %q is not registered", req.UsableKey)
}
ctxRow, err := s.loadUsableContext(ctx, tx, cfg, req.UsableID)
if err != nil {
return err
}
var usageDelta, pendingDelta float64
if ctxRow.UsageQty > 0 {
if _, err := s.releaseUsagePortion(ctx, tx, req.UsableKey, req.UsableID, ctxRow.UsageQty, ctxRow.ProductWarehouseID); err != nil {
return err
}
usageDelta -= ctxRow.UsageQty
}
if ctxRow.PendingQty > 0 {
pendingDelta -= ctxRow.PendingQty
}
if err := s.applyUsableDeltas(ctx, tx, cfg, req.UsableID, usageDelta, pendingDelta); err != nil {
return err
}
return s.allocations.ReleaseByUsable(ctx, req.UsableKey.String(), req.UsableID, req.Reason, func(db *gorm.DB) *gorm.DB {
return s.txOrDB(tx, db)
})
})
}
// --- helpers ---
type usableContextRow struct {
ProductWarehouseID uint
UsageQty float64
PendingQty float64
}
func (s *fifoService) loadUsableContext(ctx context.Context, tx *gorm.DB, cfg fifo.UsableConfig, id uint) (*usableContextRow, error) {
var row usableContextRow
query := tx.Table(cfg.Table).
Select(fmt.Sprintf("%s AS product_warehouse_id, COALESCE(%s,0) AS usage_qty, COALESCE(%s,0) AS pending_qty", cfg.Columns.ProductWarehouseID, cfg.Columns.UsageQuantity, cfg.Columns.PendingQuantity)).
Where(fmt.Sprintf("%s = ?", cfg.Columns.ID), id).
Clauses(clause.Locking{Strength: "UPDATE"})
if cfg.Scope != nil {
query = cfg.Scope(query)
}
if err := query.Take(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("usable record %d not found", id)
}
return nil, err
}
return &row, nil
}
func (s *fifoService) incrementStockableQty(ctx context.Context, tx *gorm.DB, cfg fifo.StockableConfig, id uint, qty float64) error {
column := cfg.Columns.TotalQuantity
query := tx.Table(cfg.Table).
Where(fmt.Sprintf("%s = ?", cfg.Columns.ID), id)
if cfg.Scope != nil {
query = cfg.Scope(query)
}
updates := map[string]any{
column: gorm.Expr(fmt.Sprintf("COALESCE(%s,0) + ?", column), qty),
}
if cfg.Columns.TotalUsedQuantity != "" {
updates[cfg.Columns.TotalUsedQuantity] = gorm.Expr(fmt.Sprintf("COALESCE(%s,0)", cfg.Columns.TotalUsedQuantity))
}
return query.Updates(updates).Error
}
func (s *fifoService) incrementStockableUsage(ctx context.Context, tx *gorm.DB, cfg fifo.StockableConfig, id uint, qty float64) error {
if qty == 0 {
return nil
}
column := cfg.Columns.TotalUsedQuantity
query := tx.Table(cfg.Table).
Where(fmt.Sprintf("%s = ?", cfg.Columns.ID), id)
if cfg.Scope != nil {
query = cfg.Scope(query)
}
return query.Update(column, gorm.Expr(fmt.Sprintf("COALESCE(%s,0) + ?", column), qty)).Error
}
type allocationOutcome struct {
allocated float64
pending float64
allocations []AllocationDetail
}
type stockLot struct {
StockableKey fifo.StockableKey
RecordID uint
AvailableQty float64
CreatedAt time.Time
}
func (s *fifoService) allocateFromStock(
ctx context.Context,
tx *gorm.DB,
productWarehouseID uint,
usableKey fifo.UsableKey,
usableID uint,
requestQty float64,
excludedStockables []fifo.StockableKey,
) (*allocationOutcome, error) {
lots, err := s.fetchStockLots(ctx, tx, productWarehouseID, excludedStockables)
if err != nil {
return nil, err
}
if len(lots) == 0 {
return &allocationOutcome{pending: requestQty}, nil
}
var (
remaining = requestQty
applied float64
allocations []*entities.StockAllocation
allocationSummaries []AllocationDetail
usageAdjustments = make(map[fifo.StockableKey]map[uint]float64)
)
for _, lot := range lots {
if remaining <= 0 {
break
}
if lot.AvailableQty <= 0 {
continue
}
portion := lot.AvailableQty
if portion > remaining {
portion = remaining
}
applied += portion
remaining -= portion
allocationSummaries = append(allocationSummaries, AllocationDetail{
StockableKey: lot.StockableKey,
StockableID: lot.RecordID,
Quantity: portion,
})
allocations = append(allocations, &entities.StockAllocation{
ProductWarehouseId: productWarehouseID,
StockableType: lot.StockableKey.String(),
StockableId: lot.RecordID,
UsableType: usableKey.String(),
UsableId: usableID,
Qty: portion,
Status: entities.StockAllocationStatusActive,
})
if _, ok := usageAdjustments[lot.StockableKey]; !ok {
usageAdjustments[lot.StockableKey] = make(map[uint]float64)
}
usageAdjustments[lot.StockableKey][lot.RecordID] += portion
}
if len(allocations) > 0 {
if err := s.allocations.CreateMany(ctx, allocations, func(db *gorm.DB) *gorm.DB {
return s.txOrDB(tx, db)
}); err != nil {
return nil, err
}
for key, deltas := range usageAdjustments {
cfg, ok := fifo.Stockable(key)
if !ok {
continue
}
for id, qty := range deltas {
if err := s.incrementStockableUsage(ctx, tx, cfg, id, qty); err != nil {
return nil, err
}
}
}
}
return &allocationOutcome{
allocated: applied,
pending: remaining,
allocations: allocationSummaries,
}, nil
}
func (s *fifoService) fetchStockLots(ctx context.Context, tx *gorm.DB, productWarehouseID uint, excludedStockables []fifo.StockableKey) ([]stockLot, error) {
configs := fifo.Stockables()
if len(configs) == 0 {
return nil, nil
}
// Create exclusion set for faster lookup
excludedSet := make(map[fifo.StockableKey]bool)
for _, key := range excludedStockables {
excludedSet[key] = true
}
var lots []stockLot
for key, cfg := range configs {
// Skip excluded stockables
if excludedSet[key] {
continue
}
usesNumericTime := cfg.Columns.CreatedAt == cfg.Columns.ID
var selectStmt string
if usesNumericTime {
selectStmt = fmt.Sprintf(
"%s AS id, %s AS available_qty, '1970-01-01 00:00:00 UTC'::timestamp AS created_at",
cfg.Columns.ID,
fmt.Sprintf("%s - COALESCE(%s,0)", cfg.Columns.TotalQuantity, cfg.Columns.TotalUsedQuantity),
)
} else {
selectStmt = fmt.Sprintf(
"%s AS id, %s AS available_qty, %s AS created_at",
cfg.Columns.ID,
fmt.Sprintf("%s - COALESCE(%s,0)", cfg.Columns.TotalQuantity, cfg.Columns.TotalUsedQuantity),
cfg.Columns.CreatedAt,
)
}
var rows []struct {
ID uint
AvailableQty float64
CreatedAt time.Time
}
query := tx.Table(cfg.Table).
Select(selectStmt).
Where(fmt.Sprintf("%s = ?", cfg.Columns.ProductWarehouseID), productWarehouseID).
Where(fmt.Sprintf("%s > %s", cfg.Columns.TotalQuantity, cfg.Columns.TotalUsedQuantity))
if cfg.Scope != nil {
query = cfg.Scope(query)
}
for _, order := range s.orderClauses(cfg.OrderBy) {
query = query.Order(order)
}
query = query.Limit(s.maxLotsPerStockable)
if err := query.Find(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
if row.AvailableQty <= 0 {
continue
}
lots = append(lots, stockLot{
StockableKey: key,
RecordID: row.ID,
AvailableQty: row.AvailableQty,
CreatedAt: row.CreatedAt,
})
}
}
if len(lots) == 0 {
return nil, nil
}
sort.SliceStable(lots, func(i, j int) bool {
if lots[i].CreatedAt.Equal(lots[j].CreatedAt) {
return lots[i].RecordID < lots[j].RecordID
}
return lots[i].CreatedAt.Before(lots[j].CreatedAt)
})
return lots, nil
}
func (s *fifoService) applyUsableDeltas(ctx context.Context, tx *gorm.DB, cfg fifo.UsableConfig, id uint, usageDelta, pendingDelta float64) error {
if usageDelta == 0 && pendingDelta == 0 {
return nil
}
updates := map[string]any{}
if usageDelta != 0 {
updates[cfg.Columns.UsageQuantity] = gorm.Expr(fmt.Sprintf("COALESCE(%s,0) + ?", cfg.Columns.UsageQuantity), usageDelta)
}
if pendingDelta != 0 {
updates[cfg.Columns.PendingQuantity] = gorm.Expr(fmt.Sprintf("COALESCE(%s,0) + ?", cfg.Columns.PendingQuantity), pendingDelta)
}
query := tx.Table(cfg.Table).Where(fmt.Sprintf("%s = ?", cfg.Columns.ID), id)
if cfg.Scope != nil {
query = cfg.Scope(query)
}
return query.Updates(updates).Error
}
type pendingCandidate struct {
UsableKey fifo.UsableKey
Config fifo.UsableConfig
UsableID uint
Pending float64
CreatedAt time.Time
}
func (s *fifoService) resolvePendingForWarehouse(ctx context.Context, tx *gorm.DB, productWarehouseID uint) ([]PendingResolution, error) {
candidates, err := s.fetchPendingCandidates(ctx, tx, productWarehouseID)
if err != nil {
return nil, err
}
if len(candidates) == 0 {
return nil, nil
}
var resolutions []PendingResolution
for _, candidate := range candidates {
if candidate.Pending <= 0 {
continue
}
// Get excluded stockables from candidate usable config
var excludedStockables []fifo.StockableKey
if candidate.Config.ExcludedStockables != nil {
excludedStockables = candidate.Config.ExcludedStockables
}
outcome, err := s.allocateFromStock(ctx, tx, productWarehouseID, candidate.UsableKey, candidate.UsableID, candidate.Pending, excludedStockables)
if err != nil {
return nil, err
}
if outcome.allocated <= 0 {
break
}
if err := s.applyUsableDeltas(ctx, tx, candidate.Config, candidate.UsableID, outcome.allocated, -outcome.allocated); err != nil {
return nil, err
}
if err := s.productWarehouseRepo.AdjustQuantities(ctx, map[uint]float64{
productWarehouseID: -outcome.allocated,
}, func(db *gorm.DB) *gorm.DB {
return s.txOrDB(tx, db)
}); err != nil {
return nil, err
}
resolutions = append(resolutions, PendingResolution{
UsableKey: candidate.UsableKey,
UsableID: candidate.UsableID,
Quantity: outcome.allocated,
})
if outcome.pending > 0 {
// No more stock available for this warehouse at the moment.
break
}
}
return resolutions, nil
}
func (s *fifoService) releaseUsagePortion(
ctx context.Context,
tx *gorm.DB,
usableKey fifo.UsableKey,
usableID uint,
target float64,
expectedWarehouseID uint,
) (float64, error) {
if target <= 0 {
return 0, nil
}
allocations, err := s.allocations.FindActiveByUsable(ctx, usableKey.String(), usableID, func(db *gorm.DB) *gorm.DB {
target := s.txOrDB(tx, db)
return target.Clauses(clause.Locking{Strength: "UPDATE"})
})
if err != nil {
return 0, err
}
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
totalReleased float64
warehouseAdjustments = make(map[uint]float64)
stockableAdjustments = make(map[fifo.StockableKey]map[uint]float64)
)
now := time.Now()
for i := len(allocations) - 1; i >= 0 && remaining > 0; i-- {
allocation := allocations[i]
releaseAmt := allocation.Qty
if releaseAmt > remaining {
releaseAmt = remaining
}
remaining -= releaseAmt
totalReleased += releaseAmt
warehouseAdjustments[allocation.ProductWarehouseId] += releaseAmt
key := fifo.StockableKey(allocation.StockableType)
if _, ok := stockableAdjustments[key]; !ok {
stockableAdjustments[key] = make(map[uint]float64)
}
stockableAdjustments[key][allocation.StockableId] += releaseAmt
if releaseAmt == allocation.Qty {
if err := s.allocations.PatchOne(ctx, allocation.Id, map[string]any{
"status": entities.StockAllocationStatusReleased,
"released_at": now,
}, func(db *gorm.DB) *gorm.DB {
return s.txOrDB(tx, db)
}); err != nil {
return 0, err
}
} else {
if err := s.allocations.PatchOne(ctx, allocation.Id, map[string]any{
"qty": allocation.Qty - releaseAmt,
}, func(db *gorm.DB) *gorm.DB {
return s.txOrDB(tx, db)
}); err != nil {
return 0, err
}
}
}
if totalReleased == 0 {
return 0, nil
}
for key, deltas := range stockableAdjustments {
cfg, ok := fifo.Stockable(key)
if !ok {
continue
}
for id, qty := range deltas {
if err := s.incrementStockableUsage(ctx, tx, cfg, id, -qty); err != nil {
return 0, err
}
}
}
if len(warehouseAdjustments) > 0 {
if err := s.productWarehouseRepo.AdjustQuantities(ctx, warehouseAdjustments, func(db *gorm.DB) *gorm.DB {
return s.txOrDB(tx, db)
}); err != nil {
return 0, err
}
for warehouseID := range warehouseAdjustments {
if _, err := s.resolvePendingForWarehouse(ctx, tx, warehouseID); err != nil {
return 0, err
}
}
}
return totalReleased, nil
}
func (s *fifoService) fetchPendingCandidates(ctx context.Context, tx *gorm.DB, productWarehouseID uint) ([]pendingCandidate, error) {
configs := fifo.Usables()
if len(configs) == 0 {
return nil, nil
}
var candidates []pendingCandidate
for key, cfg := range configs {
selectStmt := fmt.Sprintf(
"%s AS id, %s AS pending_qty, %s AS created_at",
cfg.Columns.ID,
cfg.Columns.PendingQuantity,
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"`
}
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: time.Unix(0, row.CreatedAt),
})
}
} else {
var rows []struct {
ID uint
Pending float64 `gorm:"column:pending_qty"`
CreatedAt time.Time `gorm:"column:created_at"`
}
query := tx.Table(cfg.Table).
Select(selectStmt).
Where(fmt.Sprintf("%s = ?", cfg.Columns.ProductWarehouseID), productWarehouseID).
Where(fmt.Sprintf("%s > 0", cfg.Columns.PendingQuantity)).
Limit(s.pendingBatchPerUsable)
if cfg.Scope != nil {
query = cfg.Scope(query)
}
for _, order := range s.orderClauses(cfg.OrderBy) {
query = query.Order(order)
}
if err := query.Find(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
if row.Pending <= 0 {
continue
}
candidates = append(candidates, pendingCandidate{
UsableKey: key,
Config: cfg,
UsableID: row.ID,
Pending: row.Pending,
CreatedAt: row.CreatedAt,
})
}
}
}
if len(candidates) == 0 {
return nil, nil
}
sort.SliceStable(candidates, func(i, j int) bool {
if candidates[i].CreatedAt.Equal(candidates[j].CreatedAt) {
return candidates[i].UsableID < candidates[j].UsableID
}
return candidates[i].CreatedAt.Before(candidates[j].CreatedAt)
})
return candidates, nil
}
func (s *fifoService) orderClauses(custom []string) []string {
if len(custom) > 0 {
return custom
}
return s.defaultOrderBy
}
@@ -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,660 +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(),
"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 || req.Usable.ID == 0 || strings.TrimSpace(req.Usable.LegacyTypeKey) == "" {
return nil, fmt.Errorf("%w: invalid reflow request", ErrInvalidRequest)
}
if req.DesiredQty < 0 {
return nil, fmt.Errorf("%w: desired qty must be >= 0", 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,
"usable_type": req.Usable.LegacyTypeKey,
"usable_id": req.Usable.ID,
"desired_qty": req.DesiredQty,
"as_of": req.AsOf,
"allow_over_consume": req.AllowOverConsume,
})
logRow, reused, err := s.beginOperation(
tx,
OperationReflow,
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 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)
}
}()
}
rollbackRes, rollbackErr := s.rollbackInternal(ctx, tx, RollbackRequest{
ProductWarehouseID: req.ProductWarehouseID,
Usable: req.Usable,
ReleaseQty: nil,
Reason: "reflow reset",
}, req.FlagGroupCode)
if rollbackErr != nil {
err = rollbackErr
return rollbackErr
}
result.Rollback = *rollbackRes
if req.DesiredQty > 0 {
allocateRes, allocateErr := s.allocateInternal(ctx, tx, AllocateRequest{
FlagGroupCode: req.FlagGroupCode,
ProductWarehouseID: req.ProductWarehouseID,
Usable: req.Usable,
NeedQty: req.DesiredQty,
AllowOverConsume: req.AllowOverConsume,
AsOf: req.AsOf,
})
if allocateErr != nil {
err = allocateErr
return allocateErr
}
result.Allocate = *allocateRes
}
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) 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 = ?", usableType, usableID, activeAllocationStatus())
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("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,268 +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) {
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, 1)
if req.Lane == LaneStockable {
if rule.UsedQuantityCol != nil && strings.TrimSpace(*rule.UsedQuantityCol) != "" {
usedCol, _ := mustSafeIdentifier(*rule.UsedQuantityCol)
usedExpr = fmt.Sprintf("COALESCE(src.%s,0)::numeric", usedCol)
} else {
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')",
sourceIDCol,
activeAllocationStatus(),
)
extraArgs = append(extraArgs, rule.LegacyTypeKey)
}
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
}
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 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,
?::text 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
`, sourceIDCol, productWarehouseCol, sortExpr, baseQtyExpr, usedExpr, pendingExpr, availableExpr, sourceTable, joinClause, strings.Join(whereParts, " AND "))
args := []any{
rule.SourceTable,
rule.LegacyTypeKey,
rule.FunctionCode,
trait.SortPriority,
}
args = append(args, extraArgs...)
args = append(args,
req.ProductWarehouseID,
entity.FlagableTypeProduct,
req.FlagGroupCode,
)
if req.AsOf != nil {
args = append(args, *req.AsOf)
}
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,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,265 +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"}
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
}
@@ -1,142 +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
ProductWarehouseID uint
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
Usable Ref
DesiredQty float64
AllowOverConsume *bool
IdempotencyKey string
AsOf *time.Time
Tx *gorm.DB
}
type ReflowResult struct {
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)
}
+43 -91
View File
@@ -22,61 +22,49 @@ 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
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
)
func init() {
@@ -118,26 +106,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"))
S3Bucket = strings.TrimSpace(viper.GetString("S3_BUCKET"))
S3AccessKey = strings.TrimSpace(viper.GetString("S3_ACCESS_KEY"))
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)
// SSO integration
SSOIssuer = viper.GetString("SSO_ISSUER")
SSOJWKSURL = viper.GetString("SSO_JWKS_URL")
@@ -145,16 +113,11 @@ func init() {
SSOAuthorizeURL = viper.GetString("SSO_AUTHORIZE_URL")
SSOTokenURL = viper.GetString("SSO_TOKEN_URL")
SSOGetMeURL = viper.GetString("SSO_GETME_URL")
SSOPortalURL = strings.TrimSpace(viper.GetString("SSO_PORTAL_URL"))
SSOAccessCookieName = defaultString(viper.GetString("SSO_ACCESS_COOKIE_NAME"), "sso_access")
SSORefreshCookieName = defaultString(viper.GetString("SSO_REFRESH_COOKIE_NAME"), "sso_refresh")
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 +222,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
View File
@@ -13,7 +13,6 @@ func FiberConfig() fiber.Config {
CaseSensitive: true,
ServerHeader: "Fiber",
AppName: "Fiber API",
BodyLimit: 8 * 1024 * 1024,
ErrorHandler: utils.ErrorHandler,
JSONEncoder: sonic.Marshal,
JSONDecoder: sonic.Unmarshal,
@@ -2,42 +2,42 @@
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
id_user BIGINT NOT NULL,
name VARCHAR(50) NOT NULL,
email VARCHAR(50) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW (),
updated_at TIMESTAMPTZ DEFAULT NOW (),
name VARCHAR NOT NULL,
email VARCHAR NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX users_id_user_unique ON users (id_user)
WHERE
deleted_at IS NULL;
CREATE UNIQUE INDEX users_id_user_unique ON users (id_user) WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX users_email_unique ON users (email)
WHERE
deleted_at IS NULL;
CREATE UNIQUE INDEX users_email_unique ON users (email) WHERE deleted_at IS NULL;
-- FLAGS
CREATE TABLE flags (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(50) NOT NULL,
name VARCHAR NOT NULL,
flagable_id BIGINT NOT NULL,
flagable_type VARCHAR(50) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW (),
updated_at TIMESTAMPTZ DEFAULT NOW ()
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE UNIQUE INDEX flags_unique_flagable ON flags (name, flagable_id, flagable_type);
CREATE UNIQUE INDEX flags_unique_flagable ON flags (
name,
flagable_id,
flagable_type
);
CREATE INDEX flags_flagable_lookup ON flags (flagable_type, flagable_id);
-- PRODUCT CATEGORIES
CREATE TABLE product_categories (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(50) NOT NULL,
name VARCHAR NOT NULL,
code VARCHAR(10) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW (),
updated_at TIMESTAMPTZ DEFAULT NOW (),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
);
@@ -53,9 +53,9 @@ WHERE
-- UOM
CREATE TABLE uoms (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(50) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW (),
updated_at TIMESTAMPTZ DEFAULT NOW (),
name VARCHAR NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
);
@@ -67,12 +67,12 @@ WHERE
-- BANKS
CREATE TABLE banks (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(50) NOT NULL,
name VARCHAR NOT NULL,
alias VARCHAR(5) NOT NULL,
owner VARCHAR(50),
owner VARCHAR,
account_number VARCHAR(50) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW (),
updated_at TIMESTAMPTZ DEFAULT NOW (),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
);
@@ -84,9 +84,9 @@ WHERE
-- AREAS
CREATE TABLE areas (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(50) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW (),
updated_at TIMESTAMPTZ DEFAULT NOW (),
name VARCHAR NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
);
@@ -98,11 +98,11 @@ WHERE
-- LOCATIONS
CREATE TABLE locations (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(50) NOT NULL,
name VARCHAR NOT NULL,
address TEXT NOT NULL,
area_id BIGINT NOT NULL REFERENCES areas (id) ON DELETE RESTRICT ON UPDATE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW (),
updated_at TIMESTAMPTZ DEFAULT NOW (),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
);
@@ -114,11 +114,11 @@ WHERE
-- KANDANG
CREATE TABLE kandangs (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(50) NOT NULL,
name VARCHAR NOT NULL,
location_id BIGINT NOT NULL REFERENCES locations (id) ON DELETE RESTRICT ON UPDATE CASCADE,
pic_id BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW (),
updated_at TIMESTAMPTZ DEFAULT NOW (),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
);
@@ -130,13 +130,13 @@ WHERE
-- WAREHOUSES
CREATE TABLE warehouses (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(50) NOT NULL,
name VARCHAR NOT NULL,
type VARCHAR(50) NOT NULL,
area_id BIGINT NOT NULL REFERENCES areas (id) ON DELETE RESTRICT ON UPDATE CASCADE,
location_id BIGINT REFERENCES locations (id) ON DELETE SET NULL ON UPDATE CASCADE,
kandang_id BIGINT REFERENCES kandangs (id) ON DELETE SET NULL ON UPDATE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW (),
updated_at TIMESTAMPTZ DEFAULT NOW (),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
);
@@ -148,16 +148,16 @@ WHERE
-- CUSTOMERS
CREATE TABLE customers (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(50) NOT NULL,
name VARCHAR NOT NULL,
pic_id BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE,
type VARCHAR(50) NOT NULL,
address TEXT NOT NULL,
phone VARCHAR(20) NOT NULL,
email VARCHAR(50) NOT NULL,
email VARCHAR NOT NULL,
account_number VARCHAR(50) NOT NULL,
balance NUMERIC(15, 3) DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW (),
updated_at TIMESTAMPTZ DEFAULT NOW (),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
);
@@ -169,10 +169,10 @@ WHERE
-- NONSTOCK
CREATE TABLE nonstocks (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(50) NOT NULL,
name VARCHAR NOT NULL,
uom_id BIGINT NOT NULL REFERENCES uoms (id) ON DELETE RESTRICT ON UPDATE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW (),
updated_at TIMESTAMPTZ DEFAULT NOW (),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
);
@@ -184,9 +184,9 @@ WHERE
-- FCR
CREATE TABLE fcrs (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(50) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW (),
updated_at TIMESTAMPTZ DEFAULT NOW (),
name VARCHAR NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
);
@@ -201,29 +201,29 @@ CREATE TABLE fcr_standards (
weight NUMERIC(15, 3) NOT NULL,
fcr_number NUMERIC(15, 3) NOT NULL,
mortality NUMERIC(15, 3) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW (),
updated_at TIMESTAMPTZ DEFAULT NOW (),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
-- SUPPLIERS
CREATE TABLE suppliers (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(50) NOT NULL,
name VARCHAR NOT NULL,
alias VARCHAR(5) NOT NULL,
pic VARCHAR(50) NOT NULL,
pic VARCHAR NOT NULL,
type VARCHAR(50) NOT NULL,
category VARCHAR(20) NOT NULL,
hatchery VARCHAR(50),
hatchery VARCHAR,
phone VARCHAR(20) NOT NULL,
email VARCHAR(50) NOT NULL,
email VARCHAR NOT NULL,
address TEXT NOT NULL,
npwp VARCHAR(50),
account_number VARCHAR(50),
balance NUMERIC(15, 3) DEFAULT 0,
due_date INT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW (),
updated_at TIMESTAMPTZ DEFAULT NOW (),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
);
@@ -235,15 +235,15 @@ WHERE
CREATE TABLE nonstock_suppliers (
nonstock_id BIGINT NOT NULL REFERENCES nonstocks (id) ON DELETE CASCADE ON UPDATE CASCADE,
supplier_id BIGINT NOT NULL REFERENCES suppliers (id) ON DELETE CASCADE ON UPDATE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW (),
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (nonstock_id, supplier_id)
);
-- PRODUCTS
CREATE TABLE products (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(50) NOT NULL,
brand VARCHAR(50) NOT NULL,
name VARCHAR NOT NULL,
brand VARCHAR NOT NULL,
sku VARCHAR(100),
uom_id BIGINT NOT NULL REFERENCES uoms (id) ON DELETE RESTRICT ON UPDATE CASCADE,
product_category_id BIGINT NOT NULL REFERENCES product_categories (id) ON DELETE RESTRICT ON UPDATE CASCADE,
@@ -251,8 +251,8 @@ CREATE TABLE products (
selling_price NUMERIC(15, 3),
tax NUMERIC(15, 3),
expiry_period INT,
created_at TIMESTAMPTZ DEFAULT NOW (),
updated_at TIMESTAMPTZ DEFAULT NOW (),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
);
@@ -268,15 +268,15 @@ WHERE
CREATE TABLE product_suppliers (
product_id BIGINT NOT NULL REFERENCES products (id) ON DELETE CASCADE ON UPDATE CASCADE,
supplier_id BIGINT NOT NULL REFERENCES suppliers (id) ON DELETE CASCADE ON UPDATE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW (),
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (product_id, supplier_id)
);
-- PROJECTS
CREATE TABLE projects (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ DEFAULT NOW (),
updated_at TIMESTAMPTZ DEFAULT NOW (),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
);
@@ -288,8 +288,8 @@ CREATE TABLE product_warehouses (
warehouse_id BIGINT NOT NULL REFERENCES warehouses (id),
quantity INTEGER NOT NULL DEFAULT 0,
created_by BIGINT NOT NULL REFERENCES users (id),
created_at TIMESTAMPTZ DEFAULT NOW (),
updated_at TIMESTAMPTZ DEFAULT NOW (),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
@@ -316,8 +316,8 @@ CREATE TABLE stock_logs (
note TEXT,
product_warehouse_id BIGINT NOT NULL REFERENCES product_warehouses (id) ON DELETE CASCADE ON UPDATE CASCADE,
created_by BIGINT NOT NULL REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE,
created_at TIMESTAMPTZ DEFAULT NOW (),
updated_at TIMESTAMPTZ DEFAULT NOW (),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
@@ -29,7 +29,7 @@ ADD CONSTRAINT fk_project_chickins_kandang FOREIGN KEY (project_flock_kandang_id
-- Relasi ke product_warehouses
ALTER TABLE project_chickins
ADD CONSTRAINT fk_project_chickins_warehouse FOREIGN KEY (product_warehouse_id) REFERENCES product_warehouses (id) ON DELETE CASCADE ON UPDATE CASCADE;
ADD CONSTRAINT fk_project_chickins_warehouse FOREIGN KEY (product_warehouse_id) REFERENCES product_warehouses (id) ON DELETE RESTRICT ON UPDATE CASCADE;
-- Relasi ke users
ALTER TABLE project_chickins
@@ -1,7 +0,0 @@
DROP INDEX IF EXISTS stock_allocations_released_at_idx;
DROP INDEX IF EXISTS stock_allocations_status_idx;
DROP INDEX IF EXISTS stock_allocations_usage_lookup;
DROP INDEX IF EXISTS stock_allocations_lookup;
DROP INDEX IF EXISTS stock_allocations_product_warehouse_id_idx;
DROP TABLE IF EXISTS stock_allocations;
@@ -1,30 +0,0 @@
CREATE TABLE IF NOT EXISTS stock_allocations (
id BIGSERIAL PRIMARY KEY,
product_warehouse_id BIGINT NOT NULL REFERENCES product_warehouses(id),
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',
note TEXT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
released_at TIMESTAMPTZ NULL,
deleted_at TIMESTAMPTZ NULL
);
CREATE INDEX IF NOT EXISTS stock_allocations_product_warehouse_id_idx
ON stock_allocations (product_warehouse_id);
CREATE INDEX IF NOT EXISTS stock_allocations_lookup
ON stock_allocations (stockable_type, stockable_id);
CREATE INDEX IF NOT EXISTS stock_allocations_usage_lookup
ON stock_allocations (usable_type, usable_id);
CREATE INDEX IF NOT EXISTS stock_allocations_status_idx
ON stock_allocations (status);
CREATE INDEX IF NOT EXISTS stock_allocations_released_at_idx
ON stock_allocations (released_at);
@@ -1,2 +1 @@
DROP SEQUENCE IF EXISTS expenses_ref_seq;
DROP TABLE IF EXISTS expenses;
@@ -1,7 +1,7 @@
CREATE TABLE expenses (
id BIGSERIAL PRIMARY KEY,
reference_number VARCHAR(50) UNIQUE NOT NULL,
supplier_id BIGINT NOT NULL,
supplier_id BIGINT NULL,
category VARCHAR(50) NOT NULL CHECK (
category IN ('BOP', 'NON-BOP')
),
@@ -1,44 +0,0 @@
-- ============================
-- EXPENSES
-- ============================
ALTER TABLE expenses DROP COLUMN IF EXISTS grand_total;
ALTER TABLE expenses RENAME COLUMN note TO notes;
ALTER TABLE expenses RENAME COLUMN expense_date TO transaction_date;
-- ============================
-- EXPENSE_REALIZATIONS
-- ============================
ALTER TABLE expense_realizations
RENAME COLUMN realization_qty TO qty;
ALTER TABLE expense_realizations
RENAME COLUMN realization_unit_price TO price;
ALTER TABLE expense_realizations RENAME COLUMN note TO notes;
ALTER TABLE expense_realizations
DROP COLUMN IF EXISTS realization_total_price;
ALTER TABLE expense_realizations
DROP COLUMN IF EXISTS realization_date;
ALTER TABLE expense_realizations DROP COLUMN IF EXISTS created_by;
ALTER TABLE expense_realizations
ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ DEFAULT NOW();
-- ============================
-- EXPENSE_NONSTOCKS
-- ============================
ALTER TABLE expense_nonstocks RENAME COLUMN note TO notes;
ALTER TABLE expense_nonstocks DROP COLUMN IF EXISTS total_price;
ALTER TABLE expense_nonstocks RENAME COLUMN unit_price TO price;
ALTER TABLE expense_nonstocks DROP COLUMN IF EXISTS created_by;
ALTER TABLE expense_nonstocks
ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ DEFAULT NOW();
@@ -1,2 +0,0 @@
DROP Table IF EXISTS project_budgets;
@@ -1,31 +0,0 @@
CREATE TABLE project_budgets (
id BIGSERIAL PRIMARY KEY,
project_flock_id BIGINT NOT NULL,
nonstock_id BIGINT NOT NULL,
qty NUMERIC(15, 3) NOT NULL,
price NUMERIC(15, 3) NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
-- Tambahkan Foreign Key ke project_flocks
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'project_flocks') THEN
ALTER TABLE project_budgets
ADD CONSTRAINT fk_project_budgets_project_flock_id
FOREIGN KEY (project_flock_id) REFERENCES project_flocks(id);
END IF;
END $$;
-- Tambahkan Foreign Key ke nonstocks
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'nonstocks') THEN
ALTER TABLE project_budgets
ADD CONSTRAINT fk_project_budgets_nonstock_id
FOREIGN KEY (nonstock_id) REFERENCES nonstocks(id);
END IF;
END $$;
-- Index
CREATE INDEX idx_project_budgets_project_flock_id ON project_budgets (project_flock_id);
CREATE INDEX idx_project_budgets_nonstock_id ON project_budgets (nonstock_id);
@@ -1,3 +0,0 @@
DROP INDEX IF EXISTS idx_project_flock_kandangs_closed_at;
ALTER TABLE project_flock_kandangs
DROP COLUMN IF EXISTS closed_at;
@@ -1,5 +0,0 @@
ALTER TABLE project_flock_kandangs
ADD COLUMN IF NOT EXISTS closed_at TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS idx_project_flock_kandangs_closed_at
ON project_flock_kandangs (closed_at);
@@ -1,2 +0,0 @@
ALTER TABLE products
DROP COLUMN IF EXISTS is_visible;
@@ -1,2 +0,0 @@
ALTER TABLE products
ADD COLUMN IF NOT EXISTS is_visible BOOLEAN NOT NULL DEFAULT TRUE;
@@ -1,2 +0,0 @@
DROP INDEX IF EXISTS documents_documentable_polymorphic;
DROP TABLE IF EXISTS documents;
@@ -1,14 +0,0 @@
CREATE TABLE documents (
id BIGSERIAL PRIMARY KEY,
documentable_type VARCHAR(50) NOT NULL,
documentable_id BIGINT NOT NULL,
type VARCHAR(50) NOT NULL,
path VARCHAR(50) NOT NULL,
name VARCHAR(50) NOT NULL,
ext VARCHAR(50) NOT NULL,
size NUMERIC(15, 3) NOT NULL,
created_by BIGINT REFERENCES users (id) ON DELETE RESTRICT ON UPDATE CASCADE,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX documents_documentable_polymorphic ON documents (documentable_type, documentable_id);
@@ -1,35 +0,0 @@
BEGIN;
-- Drop new indexes and FK
DROP INDEX IF EXISTS idx_product_warehouses_project_flock_kandang_id;
DROP INDEX IF EXISTS idx_product_warehouses_unique;
ALTER TABLE product_warehouses
DROP CONSTRAINT IF EXISTS fk_product_warehouses_project_flock_kandang_id,
ALTER COLUMN project_flock_kandang_id DROP NOT NULL,
DROP COLUMN IF EXISTS project_flock_kandang_id;
-- Revert qty to integer quantity
ALTER TABLE product_warehouses
RENAME COLUMN qty TO quantity;
ALTER TABLE product_warehouses
ALTER COLUMN quantity TYPE INTEGER USING quantity::integer,
ALTER COLUMN quantity SET DEFAULT 0,
ALTER COLUMN quantity SET NOT NULL;
-- Restore audit/soft-delete columns
ALTER TABLE product_warehouses
ADD COLUMN IF NOT EXISTS created_by BIGINT REFERENCES users (id),
ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ DEFAULT NOW(),
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT NOW(),
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
-- Recreate prior indexes
CREATE INDEX IF NOT EXISTS idx_product_warehouses_deleted_at ON product_warehouses (deleted_at);
CREATE UNIQUE INDEX IF NOT EXISTS idx_product_warehouses_unique
ON product_warehouses (product_id, warehouse_id)
WHERE deleted_at IS NULL;
COMMIT;
@@ -1,41 +0,0 @@
BEGIN;
-- Drop indexes that depend on deleted_at or old uniqueness
DROP INDEX IF EXISTS idx_product_warehouses_deleted_at;
DROP INDEX IF EXISTS idx_product_warehouses_unique;
-- Add new relation and adjust quantity column
ALTER TABLE product_warehouses
ADD COLUMN IF NOT EXISTS project_flock_kandang_id BIGINT;
ALTER TABLE product_warehouses
RENAME COLUMN quantity TO qty;
-- Enforce numeric quantity with precision and default
ALTER TABLE product_warehouses
ALTER COLUMN qty TYPE NUMERIC(15, 3) USING qty::numeric(15, 3),
ALTER COLUMN qty SET DEFAULT 0,
ALTER COLUMN qty SET NOT NULL;
-- Remove audit/soft-delete columns no longer used
ALTER TABLE product_warehouses
DROP COLUMN IF EXISTS created_by,
DROP COLUMN IF EXISTS created_at,
DROP COLUMN IF EXISTS updated_at,
DROP COLUMN IF EXISTS deleted_at;
-- Enforce FK and not-null for project_flock_kandang_id
ALTER TABLE product_warehouses
ADD CONSTRAINT fk_product_warehouses_project_flock_kandang_id
FOREIGN KEY (project_flock_kandang_id)
REFERENCES project_flock_kandangs (id)
ON DELETE RESTRICT ON UPDATE CASCADE;
-- New indexes
CREATE INDEX IF NOT EXISTS idx_product_warehouses_project_flock_kandang_id
ON product_warehouses (project_flock_kandang_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_product_warehouses_unique
ON product_warehouses (product_id, warehouse_id, project_flock_kandang_id);
COMMIT;
@@ -1,44 +0,0 @@
BEGIN;
-- Drop new indexes
DROP INDEX IF EXISTS stock_logs_loggable_type_loggable_id_idx;
DROP INDEX IF EXISTS stock_logs_product_warehouse_id_idx;
DROP INDEX IF EXISTS stock_logs_created_by_idx;
DROP INDEX IF EXISTS stock_logs_created_at_idx;
-- Restore obsolete columns
ALTER TABLE stock_logs
ADD COLUMN IF NOT EXISTS transaction_type VARCHAR(20) DEFAULT '' NOT NULL,
ADD COLUMN IF NOT EXISTS quantity NUMERIC(15, 3) DEFAULT 0 NOT NULL,
ADD COLUMN IF NOT EXISTS before_quantity NUMERIC(15, 3) DEFAULT 0 NOT NULL,
ADD COLUMN IF NOT EXISTS after_quantity NUMERIC(15, 3) DEFAULT 0 NOT NULL,
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT NOW(),
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
-- Rename columns back
ALTER TABLE stock_logs
RENAME COLUMN loggable_type TO log_type;
ALTER TABLE stock_logs
RENAME COLUMN loggable_id TO log_id;
ALTER TABLE stock_logs
RENAME COLUMN notes TO note;
-- Drop new columns
ALTER TABLE stock_logs
DROP COLUMN IF EXISTS increase,
DROP COLUMN IF EXISTS decrease;
-- Restore indexes for old structure
CREATE INDEX IF NOT EXISTS stock_logs_product_warehouse_id_idx ON stock_logs (product_warehouse_id);
CREATE INDEX IF NOT EXISTS stock_logs_log_type_log_id_idx ON stock_logs (log_type, log_id);
CREATE INDEX IF NOT EXISTS stock_logs_created_by_idx ON stock_logs (created_by);
CREATE INDEX IF NOT EXISTS stock_logs_created_at_idx ON stock_logs (created_at);
CREATE INDEX IF NOT EXISTS stock_logs_deleted_at_idx ON stock_logs (deleted_at);
COMMIT;
@@ -1,50 +0,0 @@
BEGIN;
-- Drop old indexes tied to removed columns
DROP INDEX IF EXISTS stock_logs_log_type_log_id_idx;
DROP INDEX IF EXISTS stock_logs_deleted_at_idx;
-- Rename columns to new naming
ALTER TABLE stock_logs
RENAME COLUMN log_type TO loggable_type;
ALTER TABLE stock_logs
RENAME COLUMN log_id TO loggable_id;
ALTER TABLE stock_logs
RENAME COLUMN note TO notes;
-- Add new increase/decrease columns
ALTER TABLE stock_logs
ADD COLUMN IF NOT EXISTS increase NUMERIC(15, 3) DEFAULT 0,
ADD COLUMN IF NOT EXISTS decrease NUMERIC(15, 3) DEFAULT 0;
-- Adjust column definitions
ALTER TABLE stock_logs
ALTER COLUMN loggable_type TYPE VARCHAR(50),
ALTER COLUMN loggable_type SET NOT NULL,
ALTER COLUMN loggable_id SET NOT NULL,
ALTER COLUMN increase SET DEFAULT 0,
ALTER COLUMN increase SET NOT NULL,
ALTER COLUMN decrease SET DEFAULT 0,
ALTER COLUMN decrease SET NOT NULL;
-- Remove obsolete columns
ALTER TABLE stock_logs
DROP COLUMN IF EXISTS transaction_type,
DROP COLUMN IF EXISTS quantity,
DROP COLUMN IF EXISTS before_quantity,
DROP COLUMN IF EXISTS after_quantity,
DROP COLUMN IF EXISTS updated_at,
DROP COLUMN IF EXISTS deleted_at;
-- Recreate indexes for new structure
CREATE INDEX IF NOT EXISTS stock_logs_product_warehouse_id_idx ON stock_logs (product_warehouse_id);
CREATE INDEX IF NOT EXISTS stock_logs_loggable_type_loggable_id_idx ON stock_logs (loggable_type, loggable_id);
CREATE INDEX IF NOT EXISTS stock_logs_created_by_idx ON stock_logs (created_by);
CREATE INDEX IF NOT EXISTS stock_logs_created_at_idx ON stock_logs (created_at);
COMMIT;
@@ -1,33 +0,0 @@
BEGIN;
-- Remove grading details from recording_eggs
ALTER TABLE recording_eggs
DROP CONSTRAINT IF EXISTS chk_recording_eggs_qty;
ALTER TABLE recording_eggs
DROP COLUMN IF EXISTS weight;
ALTER TABLE recording_eggs
ADD CONSTRAINT chk_recording_eggs_qty CHECK (qty >= 0);
-- Restore grading_eggs table for rollback scenarios
CREATE TABLE grading_eggs (
id BIGSERIAL PRIMARY KEY,
recording_egg_id BIGINT NOT NULL,
qty NUMERIC(15,3) NOT NULL,
grade VARCHAR,
created_by BIGINT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT fk_grading_eggs_recording_egg
FOREIGN KEY (recording_egg_id) REFERENCES recording_eggs(id) ON DELETE CASCADE,
CONSTRAINT fk_grading_eggs_created_by
FOREIGN KEY (created_by) REFERENCES users(id),
CONSTRAINT chk_grading_eggs_qty CHECK (qty >= 0)
);
CREATE INDEX idx_grading_eggs_recording_egg
ON grading_eggs (recording_egg_id);
COMMIT;
@@ -1,18 +0,0 @@
BEGIN;
-- Remove separate grading table and move grading details into recording_eggs
DROP INDEX IF EXISTS idx_grading_eggs_recording_egg;
DROP TABLE IF EXISTS grading_eggs;
ALTER TABLE recording_eggs
ADD COLUMN IF NOT EXISTS weight NUMERIC(10,3);
ALTER TABLE recording_eggs
DROP CONSTRAINT IF EXISTS chk_recording_eggs_qty;
ALTER TABLE recording_eggs
ADD CONSTRAINT chk_recording_eggs_qty CHECK (
qty >= 0 AND (weight IS NULL OR weight >= 0)
);
COMMIT;
@@ -1,38 +0,0 @@
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'fk_purchase_items_expense_nonstock'
) THEN
ALTER TABLE purchase_items
DROP CONSTRAINT fk_purchase_items_expense_nonstock;
END IF;
IF EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'fk_purchase_items_project_flock_kandang'
) THEN
ALTER TABLE purchase_items
DROP CONSTRAINT fk_purchase_items_project_flock_kandang;
END IF;
END $$;
DROP INDEX IF EXISTS idx_purchase_items_expense_nonstock_id;
DROP INDEX IF EXISTS idx_purchase_items_project_flock_kandang_id;
ALTER TABLE purchase_items
DROP COLUMN IF EXISTS expense_nonstock_id,
DROP COLUMN IF EXISTS project_flock_kandang_id,
ALTER COLUMN vehicle_number DROP NOT NULL,
ALTER COLUMN vehicle_number TYPE VARCHAR USING vehicle_number;
ALTER TABLE purchases
ALTER COLUMN pr_number TYPE VARCHAR USING pr_number,
ALTER COLUMN po_number TYPE VARCHAR USING po_number,
ALTER COLUMN created_at DROP DEFAULT,
ALTER COLUMN updated_at DROP DEFAULT;
ALTER TABLE purchases
ADD COLUMN credit_term INT NOT NULL DEFAULT 0,
ADD COLUMN grand_total NUMERIC(15, 3) NOT NULL DEFAULT 0;
ALTER TABLE purchases
ALTER COLUMN credit_term DROP DEFAULT,
ALTER COLUMN grand_total DROP DEFAULT;
@@ -1,57 +0,0 @@
-- Adjust purchases table to new purchasing schema
ALTER TABLE purchases
ALTER COLUMN pr_number TYPE VARCHAR(50) USING LEFT(pr_number, 50),
ALTER COLUMN po_number TYPE VARCHAR(50) USING LEFT(po_number, 50),
ALTER COLUMN created_at SET DEFAULT now(),
ALTER COLUMN updated_at SET DEFAULT now();
ALTER TABLE purchases
DROP COLUMN IF EXISTS credit_term,
DROP COLUMN IF EXISTS grand_total;
-- Bring purchase_items in line with new requirements
ALTER TABLE purchase_items
ADD COLUMN IF NOT EXISTS expense_nonstock_id BIGINT,
ADD COLUMN IF NOT EXISTS project_flock_kandang_id BIGINT;
UPDATE purchase_items
SET vehicle_number = ''
WHERE vehicle_number IS NULL;
ALTER TABLE purchase_items
ALTER COLUMN vehicle_number TYPE VARCHAR(10) USING LEFT(vehicle_number, 10),
ALTER COLUMN vehicle_number SET NOT NULL;
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'expense_nonstocks') THEN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'fk_purchase_items_expense_nonstock'
) THEN
EXECUTE
'ALTER TABLE purchase_items
ADD CONSTRAINT fk_purchase_items_expense_nonstock
FOREIGN KEY (expense_nonstock_id)
REFERENCES expense_nonstocks(id)
ON DELETE SET NULL ON UPDATE CASCADE';
END IF;
END IF;
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'project_flock_kandangs') THEN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'fk_purchase_items_project_flock_kandang'
) THEN
EXECUTE
'ALTER TABLE purchase_items
ADD CONSTRAINT fk_purchase_items_project_flock_kandang
FOREIGN KEY (project_flock_kandang_id)
REFERENCES project_flock_kandangs(id)
ON DELETE SET NULL ON UPDATE CASCADE';
END IF;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_purchase_items_expense_nonstock_id
ON purchase_items (expense_nonstock_id);
CREATE INDEX IF NOT EXISTS idx_purchase_items_project_flock_kandang_id
ON purchase_items (project_flock_kandang_id);
@@ -1,3 +0,0 @@
-- Drop function and sequence for sales order numbers
DROP SEQUENCE IF EXISTS so_number_seq;
DROP FUNCTION IF EXISTS generate_so_number();
@@ -1,12 +0,0 @@
-- Create sequence for sales order numbers
CREATE SEQUENCE so_number_seq START WITH 1 INCREMENT BY 1;
CREATE OR REPLACE FUNCTION generate_so_number()
RETURNS VARCHAR AS $$
DECLARE
next_val INTEGER;
BEGIN
next_val := nextval('so_number_seq');
RETURN 'SO-' || LPAD(next_val::TEXT, 5, '0');
END;
$$ LANGUAGE plpgsql;
@@ -1,2 +0,0 @@
ALTER TABLE purchases
DROP COLUMN IF EXISTS credit_term;
@@ -1,5 +0,0 @@
ALTER TABLE purchases
ADD COLUMN IF NOT EXISTS credit_term INT NOT NULL DEFAULT 0;
ALTER TABLE purchases
ALTER COLUMN credit_term DROP DEFAULT;
@@ -1,3 +0,0 @@
DROP INDEX IF EXISTS idx_payments_bank_id;
DROP INDEX IF EXISTS payments_party_polymorphic;
DROP TABLE IF EXISTS payments;
@@ -1,22 +0,0 @@
CREATE TABLE IF NOT EXISTS payments (
id BIGSERIAL PRIMARY KEY,
payment_code VARCHAR(50) NOT NULL,
reference_number VARCHAR(100) NULL,
transaction_type VARCHAR(50),
party_type VARCHAR(50) NOT NULL,
party_id BIGINT NOT NULL,
payment_date TIMESTAMPTZ NOT NULL,
payment_method VARCHAR(20) NOT NULL,
bank_id BIGINT NULL REFERENCES banks(id) ON DELETE RESTRICT ON UPDATE CASCADE,
direction VARCHAR(5) NOT NULL,
nominal NUMERIC(15, 3) NOT NULL,
notes TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ NULL,
created_by BIGINT REFERENCES users (id) ON DELETE SET NULL ON UPDATE CASCADE
);
-- Indexes
CREATE INDEX payments_party_polymorphic ON payments (party_type, party_id);
CREATE INDEX idx_payments_bank_id ON payments (bank_id);
@@ -1,18 +0,0 @@
DO $$
DECLARE
r record;
trigger_name text;
BEGIN
FOR r IN
SELECT table_schema, table_name
FROM information_schema.columns
WHERE column_name = 'deleted_at'
AND table_schema = 'public'
GROUP BY table_schema, table_name
LOOP
trigger_name := format('trg_soft_delete_fk_%s', r.table_name);
EXECUTE format('DROP TRIGGER IF EXISTS %I ON %I.%I', trigger_name, r.table_schema, r.table_name);
END LOOP;
END $$;
DROP FUNCTION IF EXISTS soft_delete_handle_fk();
@@ -1,126 +0,0 @@
CREATE OR REPLACE FUNCTION soft_delete_handle_fk() RETURNS TRIGGER AS $$
DECLARE
fk record;
child_column text;
parent_column text;
parent_value text;
child_has_deleted_at boolean;
ref_exists boolean;
sql text;
BEGIN
IF OLD.deleted_at IS NULL AND NEW.deleted_at IS NOT NULL THEN
FOR fk IN
SELECT conrelid::regclass AS child_table,
conkey AS child_cols,
confkey AS parent_cols,
confdeltype
FROM pg_constraint
WHERE contype = 'f'
AND confrelid = TG_RELID
LOOP
IF array_length(fk.child_cols, 1) IS DISTINCT FROM 1
OR array_length(fk.parent_cols, 1) IS DISTINCT FROM 1 THEN
RAISE NOTICE 'soft_delete_handle_fk skipped composite fk on %', fk.child_table;
CONTINUE;
END IF;
SELECT attname INTO child_column
FROM pg_attribute
WHERE attrelid = fk.child_table
AND attnum = fk.child_cols[1]
AND NOT attisdropped;
SELECT attname INTO parent_column
FROM pg_attribute
WHERE attrelid = TG_RELID
AND attnum = fk.parent_cols[1]
AND NOT attisdropped;
EXECUTE format('SELECT ($1).%I', parent_column)
INTO parent_value
USING OLD;
SELECT EXISTS (
SELECT 1
FROM pg_attribute
WHERE attrelid = fk.child_table
AND attname = 'deleted_at'
AND NOT attisdropped
) INTO child_has_deleted_at;
IF fk.confdeltype IN ('r', 'a') THEN
sql := format(
'SELECT EXISTS (SELECT 1 FROM %s WHERE %I = $1 %s)',
fk.child_table,
child_column,
CASE WHEN child_has_deleted_at THEN 'AND deleted_at IS NULL' ELSE '' END
);
EXECUTE sql INTO ref_exists USING parent_value;
IF ref_exists THEN
RAISE EXCEPTION 'Cannot soft delete %, still referenced by %',
TG_TABLE_NAME, fk.child_table;
END IF;
ELSIF fk.confdeltype = 'n' THEN
sql := format(
'UPDATE %s SET %I = NULL WHERE %I = $1 %s',
fk.child_table,
child_column,
child_column,
CASE WHEN child_has_deleted_at THEN 'AND deleted_at IS NULL' ELSE '' END
);
EXECUTE sql USING parent_value;
ELSIF fk.confdeltype = 'c' THEN
IF child_has_deleted_at THEN
sql := format(
'UPDATE %s SET deleted_at = NOW() WHERE %I = $1 AND deleted_at IS NULL',
fk.child_table,
child_column
);
EXECUTE sql USING parent_value;
ELSE
sql := format(
'DELETE FROM %s WHERE %I = $1',
fk.child_table,
child_column
);
EXECUTE sql USING parent_value;
END IF;
ELSIF fk.confdeltype = 'd' THEN
sql := format(
'UPDATE %s SET %I = DEFAULT WHERE %I = $1 %s',
fk.child_table,
child_column,
child_column,
CASE WHEN child_has_deleted_at THEN 'AND deleted_at IS NULL' ELSE '' END
);
EXECUTE sql USING parent_value;
END IF;
END LOOP;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DO $$
DECLARE
r record;
trigger_name text;
BEGIN
FOR r IN
SELECT table_schema, table_name
FROM information_schema.columns
WHERE column_name = 'deleted_at'
AND table_schema = 'public'
GROUP BY table_schema, table_name
LOOP
trigger_name := format('trg_soft_delete_fk_%s', r.table_name);
EXECUTE format('DROP TRIGGER IF EXISTS %I ON %I.%I', trigger_name, r.table_schema, r.table_name);
EXECUTE format(
'CREATE TRIGGER %I BEFORE UPDATE OF deleted_at ON %I.%I FOR EACH ROW EXECUTE FUNCTION soft_delete_handle_fk()',
trigger_name,
r.table_schema,
r.table_name
);
END LOOP;
END $$;
@@ -1 +0,0 @@
DROP SEQUENCE IF EXISTS payments_code_seq;
@@ -1 +0,0 @@
CREATE SEQUENCE IF NOT EXISTS payments_code_seq START WITH 1 INCREMENT BY 1;
@@ -1,3 +0,0 @@
-- Rollback: restore document columns to expenses table
ALTER TABLE expenses ADD COLUMN IF NOT EXISTS document_path JSON;
ALTER TABLE expenses ADD COLUMN IF NOT EXISTS realization_document_path JSON;
@@ -1,3 +0,0 @@
-- Delete document columns from expenses table since we now use Document service with polymorphic relations
ALTER TABLE expenses DROP COLUMN IF EXISTS document_path;
ALTER TABLE expenses DROP COLUMN IF EXISTS realization_document_path;
@@ -1,28 +0,0 @@
-- ============================================
-- Rollback: Remove FIFO fields and restore qty column
-- ============================================
-- STEP 1: Drop indexes
DROP INDEX IF EXISTS idx_marketing_delivery_products_fifo_lookup;
DROP INDEX IF EXISTS idx_marketing_delivery_products_pending_qty;
DROP INDEX IF EXISTS idx_marketing_delivery_products_usage_qty;
DROP INDEX IF EXISTS idx_marketing_delivery_products_created_at;
-- STEP 2: Drop constraints
ALTER TABLE marketing_delivery_products
DROP CONSTRAINT IF EXISTS chk_marketing_delivery_products_fifo_nonneg;
-- STEP 3: Restore qty column from usage_qty data
ALTER TABLE marketing_delivery_products
ADD COLUMN IF NOT EXISTS qty NUMERIC(15, 3) DEFAULT 0 NOT NULL;
-- Migrate data back from usage_qty to qty
UPDATE marketing_delivery_products
SET qty = usage_qty
WHERE qty = 0;
-- STEP 4: Drop FIFO columns
ALTER TABLE marketing_delivery_products
DROP COLUMN IF EXISTS usage_qty,
DROP COLUMN IF EXISTS pending_qty,
DROP COLUMN IF EXISTS created_at;
@@ -1,58 +0,0 @@
-- ============================================
-- Add FIFO fields to marketing_delivery_products
-- This migration adds fields needed for FIFO stock management
-- and removes the old qty field in favor of FIFO-based allocation
-- ============================================
-- STEP 0: Drop orphan indexes from previous migration
DROP INDEX IF EXISTS idx_marketing_delivery_products_deleted_at;
-- STEP 1: Add created_at column (required for FIFO ordering)
ALTER TABLE marketing_delivery_products
ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ DEFAULT NOW();
-- STEP 2: Add FIFO tracking fields
ALTER TABLE marketing_delivery_products
ADD COLUMN IF NOT EXISTS usage_qty NUMERIC(15, 3) DEFAULT 0,
ADD COLUMN IF NOT EXISTS pending_qty NUMERIC(15, 3) DEFAULT 0;
-- STEP 3: Migrate data from old qty to usage_qty for existing records
-- This preserves existing quantity data as allocated quantity
UPDATE marketing_delivery_products
SET
usage_qty = COALESCE(qty, 0),
pending_qty = 0
WHERE usage_qty = 0;
-- STEP 4: Drop the old qty column (replaced by usage_qty + pending_qty)
ALTER TABLE marketing_delivery_products
DROP COLUMN IF EXISTS qty;
-- STEP 5: Make FIFO fields NOT NULL
ALTER TABLE marketing_delivery_products
ALTER COLUMN usage_qty SET NOT NULL,
ALTER COLUMN pending_qty SET NOT NULL,
ALTER COLUMN created_at SET NOT NULL;
-- STEP 6: Add constraints to ensure non-negative values
ALTER TABLE marketing_delivery_products
ADD CONSTRAINT chk_marketing_delivery_products_fifo_nonneg CHECK (
usage_qty >= 0 AND
pending_qty >= 0
);
-- STEP 7: Create indexes for FIFO operations
CREATE INDEX IF NOT EXISTS idx_marketing_delivery_products_created_at
ON marketing_delivery_products(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_marketing_delivery_products_usage_qty
ON marketing_delivery_products(usage_qty)
WHERE usage_qty > 0;
CREATE INDEX IF NOT EXISTS idx_marketing_delivery_products_pending_qty
ON marketing_delivery_products(pending_qty)
WHERE pending_qty > 0;
-- Composite index for FIFO lookups
CREATE INDEX IF NOT EXISTS idx_marketing_delivery_products_fifo_lookup
ON marketing_delivery_products(marketing_product_id, created_at DESC);
@@ -1,7 +0,0 @@
-- Remove foreign key constraint
ALTER TABLE marketing_delivery_products
DROP CONSTRAINT IF EXISTS fk_marketing_delivery_products_product_warehouse;
-- Drop product_warehouse_id column
ALTER TABLE marketing_delivery_products
DROP COLUMN IF EXISTS product_warehouse_id;
@@ -1,19 +0,0 @@
-- Add product_warehouse_id column to marketing_delivery_products
ALTER TABLE marketing_delivery_products
ADD COLUMN IF NOT EXISTS product_warehouse_id INT NOT NULL DEFAULT 0;
-- Fill product_warehouse_id from marketing_products
UPDATE marketing_delivery_products mdp
SET product_warehouse_id = mp.product_warehouse_id
FROM marketing_products mp
WHERE mdp.marketing_product_id = mp.id
AND mdp.product_warehouse_id = 0;
-- Set NOT NULL constraint
ALTER TABLE marketing_delivery_products
ALTER COLUMN product_warehouse_id SET NOT NULL;
-- Add foreign key constraint
ALTER TABLE marketing_delivery_products
ADD CONSTRAINT fk_marketing_delivery_products_product_warehouse
FOREIGN KEY (product_warehouse_id) REFERENCES product_warehouses(id);
@@ -1,10 +0,0 @@
-- Drop indexes
DROP INDEX IF EXISTS idx_standard_growth_details_standard_week;
DROP INDEX IF EXISTS idx_production_standard_details_standard_week;
DROP INDEX IF EXISTS idx_production_standards_project_category;
DROP INDEX IF EXISTS idx_production_standards_deleted_at;
-- Drop tables (in reverse order due to foreign keys)
DROP TABLE IF EXISTS standard_growth_details;
DROP TABLE IF EXISTS production_standard_details;
DROP TABLE IF EXISTS production_standards;
@@ -1,96 +0,0 @@
-- Create production_standards table
CREATE TABLE IF NOT EXISTS production_standards (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) UNIQUE NOT NULL,
project_category VARCHAR(20) NOT NULL CHECK (project_category IN ('GROWING', 'LAYING')),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
created_by BIGINT
);
-- Create index for deleted_at (soft delete)
CREATE INDEX idx_production_standards_deleted_at ON production_standards(deleted_at);
-- Tambahkan Foreign Key ke users
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'users') THEN
ALTER TABLE production_standards
ADD CONSTRAINT fk_production_standards_created_by
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE;
END IF;
END $$;
-- Index
CREATE INDEX idx_production_standards_created_by ON production_standards(created_by);
-- Create production_standard_details table
CREATE TABLE IF NOT EXISTS production_standard_details (
id BIGSERIAL PRIMARY KEY,
production_standard_id BIGINT NOT NULL,
week INT NOT NULL,
target_hen_day_production NUMERIC(15, 3),
target_hen_house_production NUMERIC(15, 3),
target_egg_weight NUMERIC(15, 3),
target_egg_mass NUMERIC(15, 3),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Tambahkan Foreign Key ke production_standards
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'production_standards') THEN
ALTER TABLE production_standard_details
ADD CONSTRAINT fk_production_standard_details_standard
FOREIGN KEY (production_standard_id) REFERENCES production_standards(id) ON DELETE CASCADE;
END IF;
END $$;
-- Create unique constraint for standard_id + week
CREATE UNIQUE INDEX idx_production_standard_details_standard_week
ON production_standard_details(production_standard_id, week);
-- Create standard_growth_details table
CREATE TABLE IF NOT EXISTS standard_growth_details (
id BIGSERIAL PRIMARY KEY,
production_standard_id BIGINT NOT NULL,
target_mean_bw NUMERIC(15, 3),
max_depletion NUMERIC(15, 3),
min_uniformity NUMERIC(15, 3) NOT NULL,
week INT NOT NULL,
feed_intake NUMERIC(15, 3),
created_at TIMESTAMPTZ DEFAULT NOW(),
created_by BIGINT
);
-- Tambahkan Foreign Key ke production_standards
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'production_standards') THEN
ALTER TABLE standard_growth_details
ADD CONSTRAINT fk_standard_growth_details_standard
FOREIGN KEY (production_standard_id) REFERENCES production_standards(id) ON DELETE CASCADE;
END IF;
END $$;
-- Tambahkan Foreign Key ke users
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'users') THEN
ALTER TABLE standard_growth_details
ADD CONSTRAINT fk_standard_growth_details_created_by
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ON UPDATE CASCADE;
END IF;
END $$;
-- Create unique constraint for standard_id + week
CREATE UNIQUE INDEX idx_standard_growth_details_standard_week
ON standard_growth_details(production_standard_id, week);
-- Index
CREATE INDEX idx_standard_growth_details_created_by ON standard_growth_details(created_by);
-- Create index for project_category
CREATE INDEX idx_production_standards_project_category ON production_standards(project_category);
@@ -1,24 +0,0 @@
-- Rollback: Update expense and expense_nonstocks tables
-- Drop indexes
DROP INDEX IF EXISTS idx_expenses_project_flock_id;
DROP INDEX IF EXISTS idx_expenses_location_id;
-- Drop Foreign Key constraint
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fk_expenses_location_id'
) THEN
ALTER TABLE expenses
DROP CONSTRAINT fk_expenses_location_id;
END IF;
END $$;
-- Drop columns from expenses table
ALTER TABLE expenses
DROP COLUMN IF EXISTS project_flock_id;
ALTER TABLE expenses
DROP COLUMN IF EXISTS location_id;
@@ -1,29 +0,0 @@
-- Migration: Update expense and expense_nonstocks tables
-- Add location_id column to expenses table
ALTER TABLE expenses
ADD COLUMN IF NOT EXISTS location_id BIGINT NOT NULL DEFAULT 1;
-- Add project_flock_id column to expenses table (JSON type)
ALTER TABLE expenses
ADD COLUMN IF NOT EXISTS project_flock_id JSON NULL;
-- Add Foreign Key constraint to locations table
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'locations') THEN
ALTER TABLE expenses
ADD CONSTRAINT fk_expenses_location_id
FOREIGN KEY (location_id) REFERENCES locations(id) ON DELETE RESTRICT ON UPDATE CASCADE;
END IF;
END $$;
-- Create index for location_id
CREATE INDEX IF NOT EXISTS idx_expenses_location_id ON expenses (location_id);
-- Create index for project_flock_id
CREATE INDEX IF NOT EXISTS idx_expenses_project_flock_id ON expenses ((project_flock_id::text));
-- Ensure kandang_id is nullable in expense_nonstocks table
ALTER TABLE expense_nonstocks
ALTER COLUMN kandang_id DROP NOT NULL;
@@ -1,6 +0,0 @@
DROP INDEX IF EXISTS idx_project_flock_kandang_uniformity_deleted_at;
DROP INDEX IF EXISTS idx_project_flock_kandang_uniformity_created_by;
DROP INDEX IF EXISTS idx_project_flock_kandang_uniformity_project_flock_kandang_week;
DROP INDEX IF EXISTS idx_project_flock_kandang_uniformity_project_flock_kandang_id;
DROP TABLE IF EXISTS project_flock_kandang_uniformity;
@@ -1,58 +0,0 @@
CREATE TABLE IF NOT EXISTS project_flock_kandang_uniformity (
id BIGSERIAL PRIMARY KEY,
uniformity NUMERIC(15, 3),
week INT NOT NULL,
cv NUMERIC(15, 3),
chick_qty_of_weight NUMERIC(15, 3),
mean_up NUMERIC(15, 3),
mean_down NUMERIC(15, 3),
project_flock_kandang_id BIGINT NOT NULL,
uniform_qty NUMERIC(15, 3),
not_uniform_qty NUMERIC(15, 3),
uniform_date TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
deleted_at TIMESTAMPTZ,
created_by BIGINT NOT NULL
);
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'project_flock_kandangs') THEN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'fk_project_flock_kandang_uniformity_project_flock_kandang'
) THEN
EXECUTE
'ALTER TABLE project_flock_kandang_uniformity
ADD CONSTRAINT fk_project_flock_kandang_uniformity_project_flock_kandang
FOREIGN KEY (project_flock_kandang_id)
REFERENCES project_flock_kandangs(id)
ON DELETE RESTRICT ON UPDATE CASCADE';
END IF;
END IF;
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'users') THEN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'fk_project_flock_kandang_uniformity_created_by'
) THEN
EXECUTE
'ALTER TABLE project_flock_kandang_uniformity
ADD CONSTRAINT fk_project_flock_kandang_uniformity_created_by
FOREIGN KEY (created_by)
REFERENCES users(id)
ON DELETE RESTRICT ON UPDATE CASCADE';
END IF;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_project_flock_kandang_uniformity_project_flock_kandang_id
ON project_flock_kandang_uniformity (project_flock_kandang_id);
CREATE INDEX IF NOT EXISTS idx_project_flock_kandang_uniformity_project_flock_kandang_week
ON project_flock_kandang_uniformity (project_flock_kandang_id, week);
CREATE INDEX IF NOT EXISTS idx_project_flock_kandang_uniformity_created_by
ON project_flock_kandang_uniformity (created_by);
CREATE INDEX IF NOT EXISTS idx_project_flock_kandang_uniformity_deleted_at
ON project_flock_kandang_uniformity (deleted_at);
@@ -1,42 +0,0 @@
-- ===============================================================
-- ROLLBACK: Remove FIFO fields from STOCK_TRANSFER_DETAILS
-- ===============================================================
-- Drop indexes
DROP INDEX IF EXISTS idx_stock_transfer_details_dest_pw;
DROP INDEX IF EXISTS idx_stock_transfer_details_source_pw;
-- Drop foreign keys
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fk_stock_transfer_details_source_pw'
) THEN
EXECUTE 'ALTER TABLE stock_transfer_details
DROP CONSTRAINT fk_stock_transfer_details_source_pw';
END IF;
IF EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fk_stock_transfer_details_dest_pw'
) THEN
EXECUTE 'ALTER TABLE stock_transfer_details
DROP CONSTRAINT fk_stock_transfer_details_dest_pw';
END IF;
END $$;
-- Drop FIFO columns
ALTER TABLE stock_transfer_details
DROP COLUMN IF EXISTS total_used,
DROP COLUMN IF EXISTS total_qty,
DROP COLUMN IF EXISTS pending_qty,
DROP COLUMN IF EXISTS usage_qty,
DROP COLUMN IF EXISTS dest_product_warehouse_id,
DROP COLUMN IF EXISTS source_product_warehouse_id;
-- Restore original columns (in case rollback)
ALTER TABLE stock_transfer_details
ADD COLUMN IF NOT EXISTS quantity NUMERIC(15, 3) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS before_quantity NUMERIC(15, 3),
ADD COLUMN IF NOT EXISTS after_quantity NUMERIC(15, 3);
@@ -1,83 +0,0 @@
-- ===============================================================
-- ADD FIFO FIELDS TO STOCK_TRANSFER_DETAILS
-- Enable transfer module to work with FIFO stock system
--
-- Notes:
-- - Field 'quantity' will be removed (replaced by usage_qty + pending_qty)
-- - Fields 'before_quantity' & 'after_quantity' will be removed (unused legacy)
-- - New FIFO fields track actual allocation instead of requested quantity
-- ===============================================================
-- Add FIFO tracking fields
ALTER TABLE stock_transfer_details
ADD COLUMN IF NOT EXISTS source_product_warehouse_id BIGINT,
ADD COLUMN IF NOT EXISTS dest_product_warehouse_id BIGINT,
ADD COLUMN IF NOT EXISTS usage_qty NUMERIC(15, 3) DEFAULT 0,
ADD COLUMN IF NOT EXISTS pending_qty NUMERIC(15, 3) DEFAULT 0,
ADD COLUMN IF NOT EXISTS total_qty NUMERIC(15, 3) DEFAULT 0,
ADD COLUMN IF NOT EXISTS total_used NUMERIC(15, 3) DEFAULT 0;
-- Remove obsolete columns (quantity replaced by FIFO fields, legacy fields never used)
ALTER TABLE stock_transfer_details
DROP COLUMN IF EXISTS quantity,
DROP COLUMN IF EXISTS before_quantity,
DROP COLUMN IF EXISTS after_quantity;
-- Add foreign keys for product warehouse references
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'product_warehouses') THEN
-- Source warehouse foreign key
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fk_stock_transfer_details_source_pw'
) THEN
EXECUTE
'ALTER TABLE stock_transfer_details
ADD CONSTRAINT fk_stock_transfer_details_source_pw
FOREIGN KEY (source_product_warehouse_id)
REFERENCES product_warehouses(id)
ON DELETE SET NULL ON UPDATE CASCADE';
END IF;
-- Destination warehouse foreign key
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fk_stock_transfer_details_dest_pw'
) THEN
EXECUTE
'ALTER TABLE stock_transfer_details
ADD CONSTRAINT fk_stock_transfer_details_dest_pw
FOREIGN KEY (dest_product_warehouse_id)
REFERENCES product_warehouses(id)
ON DELETE SET NULL ON UPDATE CASCADE';
END IF;
END IF;
END $$;
-- Add indexes for FIFO operations
CREATE INDEX IF NOT EXISTS idx_stock_transfer_details_source_pw
ON stock_transfer_details (source_product_warehouse_id);
CREATE INDEX IF NOT EXISTS idx_stock_transfer_details_dest_pw
ON stock_transfer_details (dest_product_warehouse_id);
-- Add comments for documentation
COMMENT ON COLUMN stock_transfer_details.source_product_warehouse_id IS
'Source product warehouse ID - referensi warehouse asal (FIFO usable)';
COMMENT ON COLUMN stock_transfer_details.dest_product_warehouse_id IS
'Destination product warehouse ID - referensi warehouse tujuan (FIFO stockable)';
COMMENT ON COLUMN stock_transfer_details.usage_qty IS
'Actual quantity successfully taken from source warehouse (FIFO usable tracking) - replaces quantity field';
COMMENT ON COLUMN stock_transfer_details.pending_qty IS
'Quantity waiting for stock availability (FIFO usable tracking)';
COMMENT ON COLUMN stock_transfer_details.total_qty IS
'Total lot quantity available at destination warehouse (FIFO stockable tracking)';
COMMENT ON COLUMN stock_transfer_details.total_used IS
'Quantity already consumed from this lot at destination warehouse (FIFO stockable tracking)';
@@ -1,16 +0,0 @@
-- Rollback: Drop adjustment_stocks table
BEGIN;
DROP INDEX IF EXISTS idx_adjustment_stocks_product_warehouse;
DROP INDEX IF EXISTS idx_adjustment_stocks_stock_log;
ALTER TABLE adjustment_stocks
DROP CONSTRAINT IF EXISTS fk_adjustment_stocks_product_warehouse;
ALTER TABLE adjustment_stocks
DROP CONSTRAINT IF EXISTS fk_adjustment_stocks_stock_log;
DROP TABLE IF EXISTS adjustment_stocks;
COMMIT;
@@ -1,40 +0,0 @@
-- Migration: Create adjustment_stocks table for FIFO tracking
-- This table tracks FIFO allocation for stock adjustments (both increase and decrease)
BEGIN;
CREATE TABLE IF NOT EXISTS adjustment_stocks (
id BIGSERIAL PRIMARY KEY,
stock_log_id BIGINT NOT NULL,
product_warehouse_id BIGINT NOT NULL,
-- FIFO fields for Adjustment INCREASE (Stockable)
-- Tracks stock added to warehouse via adjustment
total_qty NUMERIC(15, 3) DEFAULT 0,
total_used NUMERIC(15, 3) DEFAULT 0,
-- FIFO fields for Adjustment DECREASE (Usable)
-- Tracks stock consumed from warehouse via adjustment
usage_qty NUMERIC(15, 3) DEFAULT 0,
pending_qty NUMERIC(15, 3) DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- Foreign keys
ALTER TABLE adjustment_stocks
ADD CONSTRAINT fk_adjustment_stocks_stock_log
FOREIGN KEY (stock_log_id) REFERENCES stock_logs(id)
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE adjustment_stocks
ADD CONSTRAINT fk_adjustment_stocks_product_warehouse
FOREIGN KEY (product_warehouse_id) REFERENCES product_warehouses(id)
ON DELETE CASCADE ON UPDATE CASCADE;
-- Indexes
CREATE INDEX idx_adjustment_stocks_stock_log ON adjustment_stocks(stock_log_id);
CREATE INDEX idx_adjustment_stocks_product_warehouse ON adjustment_stocks(product_warehouse_id);
COMMIT;
@@ -1,54 +0,0 @@
BEGIN;
CREATE TABLE IF NOT EXISTS recording_bws (
id BIGSERIAL PRIMARY KEY,
recording_id BIGINT NOT NULL,
avg_weight NUMERIC(8,2) NOT NULL,
qty NUMERIC(15,3) NOT NULL DEFAULT 1,
total_weight NUMERIC(10,3) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT fk_recording_bws_recording
FOREIGN KEY (recording_id) REFERENCES recordings(id) ON DELETE CASCADE,
CONSTRAINT chk_recording_bws_nonneg
CHECK (avg_weight >= 0 AND qty >= 0 AND total_weight >= 0)
);
CREATE INDEX IF NOT EXISTS idx_recording_bws_recording
ON recording_bws (recording_id);
ALTER TABLE recordings
DROP CONSTRAINT IF EXISTS chk_recordings_nonnegatives_v3;
ALTER TABLE recordings
DROP COLUMN IF EXISTS hand_day,
DROP COLUMN IF EXISTS hand_house,
DROP COLUMN IF EXISTS feed_intake,
DROP COLUMN IF EXISTS egg_mesh,
DROP COLUMN IF EXISTS egg_weight;
ALTER TABLE recordings
ADD CONSTRAINT chk_recordings_nonnegatives_v2 CHECK (
(total_depletion_qty IS NULL OR total_depletion_qty >= 0) AND
(cum_depletion_rate IS NULL OR cum_depletion_rate >= 0) AND
(daily_gain IS NULL OR daily_gain >= 0) AND
(avg_daily_gain IS NULL OR avg_daily_gain >= 0) AND
(cum_intake IS NULL OR cum_intake >= 0) AND
(fcr_value IS NULL OR fcr_value >= 0) AND
(total_chick_qty IS NULL OR total_chick_qty >= 0)
);
ALTER TABLE recording_eggs
DROP CONSTRAINT IF EXISTS chk_recording_eggs_qty;
ALTER TABLE recording_eggs
ALTER COLUMN weight TYPE NUMERIC(10,3) USING weight::NUMERIC(10,3);
ALTER TABLE recording_eggs
ADD CONSTRAINT chk_recording_eggs_qty CHECK (
qty >= 0 AND (weight IS NULL OR weight >= 0)
);
COMMIT;
@@ -1,44 +0,0 @@
BEGIN;
ALTER TABLE recordings
DROP CONSTRAINT IF EXISTS chk_recordings_nonnegatives_v2;
ALTER TABLE recordings
ADD COLUMN IF NOT EXISTS hand_day NUMERIC(15,3),
ADD COLUMN IF NOT EXISTS hand_house NUMERIC(15,3),
ADD COLUMN IF NOT EXISTS feed_intake NUMERIC(15,3),
ADD COLUMN IF NOT EXISTS egg_mesh NUMERIC(15,3),
ADD COLUMN IF NOT EXISTS egg_weight NUMERIC(15,3);
ALTER TABLE recordings
ADD CONSTRAINT chk_recordings_nonnegatives_v3 CHECK (
(total_depletion_qty IS NULL OR total_depletion_qty >= 0) AND
(cum_depletion_rate IS NULL OR cum_depletion_rate >= 0) AND
(daily_gain IS NULL OR daily_gain >= 0) AND
(avg_daily_gain IS NULL OR avg_daily_gain >= 0) AND
(cum_intake IS NULL OR cum_intake >= 0) AND
(fcr_value IS NULL OR fcr_value >= 0) AND
(total_chick_qty IS NULL OR total_chick_qty >= 0) AND
(hand_day IS NULL OR hand_day >= 0) AND
(hand_house IS NULL OR hand_house >= 0) AND
(feed_intake IS NULL OR feed_intake >= 0) AND
(egg_mesh IS NULL OR egg_mesh >= 0) AND
(egg_weight IS NULL OR egg_weight >= 0)
);
ALTER TABLE recording_eggs
ALTER COLUMN weight TYPE NUMERIC(15,3) USING weight::NUMERIC(15,3);
ALTER TABLE recording_eggs
DROP CONSTRAINT IF EXISTS chk_recording_eggs_qty;
ALTER TABLE recording_eggs
ADD CONSTRAINT chk_recording_eggs_qty CHECK (
qty >= 0 AND
(weight IS NULL OR weight >= 0)
);
DROP INDEX IF EXISTS idx_recording_bws_recording;
DROP TABLE IF EXISTS recording_bws;
COMMIT;
@@ -1,7 +0,0 @@
DROP INDEX IF EXISTS idx_project_flocks_production_standard_id;
ALTER TABLE project_flocks
DROP CONSTRAINT IF EXISTS fk_project_flocks_production_standard_id;
ALTER TABLE project_flocks
DROP COLUMN IF EXISTS production_standard_id;
@@ -1,15 +0,0 @@
-- Add production_standard_id to project_flocks
ALTER TABLE project_flocks
ADD COLUMN IF NOT EXISTS production_standard_id BIGINT;
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_tables WHERE tablename = 'production_standards') THEN
ALTER TABLE project_flocks
ADD CONSTRAINT fk_project_flocks_production_standard_id
FOREIGN KEY (production_standard_id) REFERENCES production_standards (id) ON DELETE RESTRICT ON UPDATE CASCADE;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_project_flocks_production_standard_id
ON project_flocks (production_standard_id);
@@ -1,3 +0,0 @@
-- Remove standard_fcr column from production_standard_details table
ALTER TABLE production_standard_details
DROP COLUMN IF EXISTS standard_fcr;
@@ -1,3 +0,0 @@
-- Add standard_fcr column to production_standard_details table
ALTER TABLE production_standard_details
ADD COLUMN standard_fcr NUMERIC(15, 3);
@@ -1,20 +0,0 @@
-- Drop CASCADE constraint
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'fk_project_chickins_kandang'
AND conrelid = 'project_chickins'::regclass
) THEN
ALTER TABLE project_chickins
DROP CONSTRAINT fk_project_chickins_kandang;
END IF;
END $$;
-- Recreate foreign key constraint with RESTRICT (original behavior)
ALTER TABLE project_chickins
ADD CONSTRAINT fk_project_chickins_kandang
FOREIGN KEY (project_flock_kandang_id)
REFERENCES project_flock_kandangs(id)
ON DELETE RESTRICT ON UPDATE CASCADE;

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