Merge branch 'development' of https://gitlab.com/mbugroup/lti-api into Fix/BE/Purchase-rejected

This commit is contained in:
ragilap
2026-02-02 15:24:11 +07:00
26 changed files with 713 additions and 157 deletions
+56 -17
View File
@@ -49,41 +49,80 @@ build_production:
# ========================= # =========================
# MIGRATE (PRODUCTION - MANUAL) # MIGRATE (PRODUCTION)
# ========================= # =========================
migrate_production: migrate_production:
stage: migrate stage: migrate
rules: rules:
- if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"' - if: '$CI_PIPELINE_SOURCE == "push" && $CI_COMMIT_BRANCH == "production"'
when: manual
allow_failure: false
needs: needs:
- job: build_production - job: build_production
artifacts: false artifacts: false
script: | script: |
set -e set -e
cd /opt/deploy/lti echo "✅ Running migrations (production) ..."
test -f .env || (echo "❌ .env not found" && exit 1)
cd "$DEPLOY_DIR"
test -f "$COMPOSE_FILE" || (echo "❌ $COMPOSE_FILE not found in $DEPLOY_DIR" && exit 1)
test -f .env || (echo "❌ .env not found in $DEPLOY_DIR" && exit 1)
# ✅ load env dari server
set -a set -a
. ./.env . ./.env
set +a set +a
# Validasi env wajib # ✅ validasi
: "${DB_HOST:?DB_HOST not set}" test -n "$DB_HOST" || (echo "❌ DB_HOST empty" && exit 1)
: "${DB_PORT:?DB_PORT not set}" test -n "$DB_PORT" || (echo "❌ DB_PORT empty" && exit 1)
: "${DB_USER:?DB_USER not set}" test -n "$DB_USER" || (echo "❌ DB_USER empty" && exit 1)
: "${DB_PASSWORD:?DB_PASSWORD not set}" test -n "$DB_PASSWORD" || (echo "❌ DB_PASSWORD empty" && exit 1)
: "${DB_NAME:?DB_NAME not set}" test -n "$DB_NAME" || (echo "❌ DB_NAME empty" && exit 1)
DB_SSLMODE="${DB_SSLMODE:-require}" export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE:-disable}"
export DATABASE_URL="postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE}" echo "✅ DATABASE_URL=$DATABASE_URL"
echo "✅ Running migrations (production)..." # ✅ Pastikan postgres & redis ON (sesuaikan nama service compose kamu!)
docker run --rm \ echo "✅ Ensuring postgres & redis running ..."
-v "/opt/deploy/lti/internal/database/migrations:/migrations:ro" \ docker compose -f "$COMPOSE_FILE" up -d stg-postgres-lti stg-redis-lti || true
# ✅ Ambil network key dari compose
COMPOSE_NETWORK_KEY="$(docker compose -f "$COMPOSE_FILE" config | awk '/networks:/ {getline; print $1}' | tr -d ':')"
echo "✅ Compose network key: $COMPOSE_NETWORK_KEY"
# ✅ Cari network name yang dipakai docker
NETWORK_NAME="$(docker network ls --format '{{.Name}}' | grep "_${COMPOSE_NETWORK_KEY}$" | head -n 1)"
test -n "$NETWORK_NAME" || (echo "❌ Cannot find docker network for compose ($COMPOSE_NETWORK_KEY)" && exit 1)
echo "✅ Docker network detected: $NETWORK_NAME"
# ✅ Migrations dari repo (CI workspace)
echo "✅ Checking migrations from repo..."
ls -lah "$CI_PROJECT_DIR/internal/database/migrations"
echo "✅ Running migrations via migrate/migrate container"
set +e
out=$(docker run --rm \
--network "$NETWORK_NAME" \
-v "$CI_PROJECT_DIR/internal/database/migrations:/migrations:ro" \
migrate/migrate:v4.15.2 \ migrate/migrate:v4.15.2 \
-path=/migrations -database "$DATABASE_URL" up -path=/migrations -database "$DATABASE_URL" up 2>&1)
code=$?
set -e
echo "$out"
# ✅ Handle no change dengan benar (tidak false-success)
if echo "$out" | grep -qi "no change"; then
echo "✅ No change (already up to date)"
exit 0
fi
if [ $code -ne 0 ]; then
echo "❌ Migration failed with exit code $code"
exit $code
fi
echo "✅ Migration applied successfully"
# ========================= # =========================
@@ -196,10 +196,10 @@ func (r *HppRepositoryImpl) GetPulletCost(ctx context.Context, projectFlockKanda
} }
func (r *HppRepositoryImpl) GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, float64, error) { func (r *HppRepositoryImpl) GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, date *time.Time) (float64, float64, error) {
// if date == nil { if date == nil {
// now := time.Now() now := time.Now()
// date = &now date = &now
// } }
var totals struct { var totals struct {
TotalPieces float64 TotalPieces float64
+5
View File
@@ -61,6 +61,7 @@ var (
SSOCookieDomain string SSOCookieDomain string
SSOCookieSecure bool SSOCookieSecure bool
SSOCookieSameSite string SSOCookieSameSite string
SSOAccessTokenMaxBytes int
SSOTokenBlacklistPrefix string SSOTokenBlacklistPrefix string
SSOPKCETTL time.Duration SSOPKCETTL time.Duration
SSOUserSyncDrift time.Duration SSOUserSyncDrift time.Duration
@@ -144,6 +145,10 @@ func init() {
SSOCookieDomain = viper.GetString("SSO_COOKIE_DOMAIN") SSOCookieDomain = viper.GetString("SSO_COOKIE_DOMAIN")
SSOCookieSecure = viper.GetBool("SSO_COOKIE_SECURE") SSOCookieSecure = viper.GetBool("SSO_COOKIE_SECURE")
SSOCookieSameSite = defaultString(viper.GetString("SSO_COOKIE_SAMESITE"), "Lax") SSOCookieSameSite = defaultString(viper.GetString("SSO_COOKIE_SAMESITE"), "Lax")
SSOAccessTokenMaxBytes = viper.GetInt("SSO_ACCESS_TOKEN_MAX_BYTES")
if SSOAccessTokenMaxBytes <= 0 {
SSOAccessTokenMaxBytes = 4096
}
SSOTokenBlacklistPrefix = defaultString(viper.GetString("SSO_TOKEN_BLACKLIST_PREFIX"), "sso:blacklist") SSOTokenBlacklistPrefix = defaultString(viper.GetString("SSO_TOKEN_BLACKLIST_PREFIX"), "sso:blacklist")
if ttl := viper.GetInt("SSO_PKCE_TTL_SECONDS"); ttl > 0 { if ttl := viper.GetInt("SSO_PKCE_TTL_SECONDS"); ttl > 0 {
SSOPKCETTL = time.Duration(ttl) * time.Second SSOPKCETTL = time.Duration(ttl) * time.Second
@@ -0,0 +1,2 @@
ALTER TABLE stock_logs
DROP COLUMN stock;
@@ -0,0 +1,18 @@
ALTER TABLE stock_logs
ADD COLUMN stock NUMERIC(15, 3) NOT NULL DEFAULT 0;
WITH calc AS (
SELECT
id,
SUM(COALESCE(increase, 0) - COALESCE(decrease, 0))
OVER (
PARTITION BY product_warehouse_id
ORDER BY id
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_stock
FROM stock_logs
)
UPDATE stock_logs t
SET stock = c.running_stock
FROM calc c
WHERE t.id = c.id;
@@ -0,0 +1,2 @@
-- Drop transfer laying sequence
DROP SEQUENCE IF EXISTS transfer_laying_seq;
@@ -0,0 +1,33 @@
-- Create sequence for transfer laying movement number
CREATE SEQUENCE transfer_laying_seq START
WITH
1 INCREMENT BY 1 MINVALUE 1 MAXVALUE 99999 NO CYCLE;
-- Set sequence starting value based on existing data (if any)
-- This prevents duplicate movement numbers if there's already data
DO $$ DECLARE max_existing INTEGER;
BEGIN
-- Check if table exists and has data
IF EXISTS (
SELECT 1
FROM information_schema.tables
WHERE
table_schema = 'public'
AND table_name = 'transfer_to_layings'
) THEN
-- Get max ID from existing records
SELECT COALESCE(MAX(id), 0) INTO max_existing
FROM transfer_to_layings;
-- Set sequence to start after the highest existing ID
IF max_existing > 0 THEN PERFORM setval (
'transfer_laying_seq',
max_existing
);
END IF;
END IF;
END $$;
+1
View File
@@ -9,6 +9,7 @@ type StockLog struct {
Increase float64 `gorm:"column:increase;type:numeric(15,3);default:0"` Increase float64 `gorm:"column:increase;type:numeric(15,3);default:0"`
Decrease float64 `gorm:"column:decrease;type:numeric(15,3);default:0"` Decrease float64 `gorm:"column:decrease;type:numeric(15,3);default:0"`
Stock float64 `gorm:"column:stock;type:numeric(15,3);not null;default:0"`
LoggableType string `gorm:"column:loggable_type;type:varchar(50);not null"` LoggableType string `gorm:"column:loggable_type;type:varchar(50);not null"`
LoggableId uint `gorm:"column:loggable_id;not null"` LoggableId uint `gorm:"column:loggable_id;not null"`
@@ -101,17 +101,44 @@ func ToSalesDTO(e entity.MarketingDeliveryProduct) SalesDTO {
} }
} }
func ToSummaryDto(e []entity.MarketingDeliveryProduct) SummaryDTO { func ToSalesAgeDTO(e entity.MarketingDeliveryProduct) SalesDTO {
productFlags := make([]string, len(e.MarketingProduct.ProductWarehouse.Product.Flags))
for i, f := range e.MarketingProduct.ProductWarehouse.Product.Flags {
productFlags[i] = f.Name
}
var category string
if e.MarketingProduct.ProductWarehouse.ProjectFlockKandang != nil {
category = e.MarketingProduct.ProductWarehouse.ProjectFlockKandang.ProjectFlock.Category
}
ageInDay, _ := calculateAgeFromChickin(e.MarketingProduct.ProductWarehouse.ProjectFlockKandang, e.DeliveryDate, productFlags, category)
return SalesDTO{
Age: ageInDay,
Qty: e.UsageQty,
}
}
func ToSummaryDto(e []entity.MarketingDeliveryProduct) SummaryDTO {
var totalSalesPrice, totalActualPrice, sumSales, sumActual float64 var totalSalesPrice, totalActualPrice, sumSales, sumActual float64
count := len(e) count := len(e)
if count == 0 {
return SummaryDTO{
TotalSalesPrice: 0,
TotalActualPrice: 0,
AvgSalesPrice: 0,
AvgActualPrice: 0,
}
}
for _, item := range e { for _, item := range e {
totalSalesPrice += item.MarketingProduct.TotalPrice totalSalesPrice += item.MarketingProduct.TotalPrice
totalActualPrice += item.TotalPrice totalActualPrice += item.TotalPrice
sumSales += item.MarketingProduct.UnitPrice sumSales += item.MarketingProduct.UnitPrice
sumActual += item.UnitPrice sumActual += item.UnitPrice
} }
return SummaryDTO{ return SummaryDTO{
@@ -367,7 +367,7 @@ func (s closingService) GetClosingSapronak(c *fiber.Ctx, projectFlockID uint, pa
return nil, 0, fiber.NewError(fiber.StatusBadRequest, "type must be either incoming or outgoing") return nil, 0, fiber.NewError(fiber.StatusBadRequest, "type must be either incoming or outgoing")
} }
warehouseIDs, err := s.getWarehouseIDsByProjectFlock(c.Context(), projectFlockID) warehouseIDs, err := s.getWarehouseIDsByProjectFlock(c.Context(), projectFlockID, params.KandangID)
if err != nil { if err != nil {
s.Log.Errorf("Failed to fetch warehouses for project flock %d: %+v", projectFlockID, err) s.Log.Errorf("Failed to fetch warehouses for project flock %d: %+v", projectFlockID, err)
return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch warehouses for project flock") return nil, 0, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch warehouses for project flock")
@@ -451,7 +451,7 @@ func (s closingService) GetClosingSapronakSummary(c *fiber.Ctx, projectFlockID u
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock") return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch project flock")
} }
warehouseIDs, err := s.getWarehouseIDsByProjectFlock(c.Context(), projectFlockID) warehouseIDs, err := s.getWarehouseIDsByProjectFlock(c.Context(), projectFlockID, params.KandangID)
if err != nil { if err != nil {
s.Log.Errorf("Failed to fetch warehouses for project flock %d: %+v", projectFlockID, err) s.Log.Errorf("Failed to fetch warehouses for project flock %d: %+v", projectFlockID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch warehouses for project flock") return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch warehouses for project flock")
@@ -494,13 +494,16 @@ func (s closingService) GetClosingSapronakSummary(c *fiber.Ctx, projectFlockID u
return items, nil return items, nil
} }
func (s closingService) getWarehouseIDsByProjectFlock(ctx context.Context, projectFlockID uint) ([]uint, error) { func (s closingService) getWarehouseIDsByProjectFlock(ctx context.Context, projectFlockID uint, kandangID *uint) ([]uint, error) {
var kandangIDs []uint var kandangIDs []uint
db := s.Repository.DB().WithContext(ctx) db := s.Repository.DB().WithContext(ctx)
if err := db.Model(&entity.ProjectFlockKandang{}). query := db.Model(&entity.ProjectFlockKandang{}).
Where("project_flock_id = ?", projectFlockID). Where("project_flock_id = ?", projectFlockID)
Pluck("kandang_id", &kandangIDs).Error; err != nil { if kandangID != nil && *kandangID > 0 {
query = query.Where("id = ?", *kandangID)
}
if err := query.Pluck("kandang_id", &kandangIDs).Error; err != nil {
return nil, err return nil, err
} }
@@ -841,7 +844,7 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch FCR standard data") return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch FCR standard data")
} }
} }
age, err := s.calculateAverageSalesAge(c.Context(), projectFlockID) age, err := s.calculateAverageSalesAge(c.Context(), projectFlockID, kandangID)
if err != nil { if err != nil {
s.Log.Errorf("Failed to calculate sales age for project flock %d: %+v", projectFlockID, err) s.Log.Errorf("Failed to calculate sales age for project flock %d: %+v", projectFlockID, err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch sales age data") return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch sales age data")
@@ -1028,38 +1031,24 @@ func (s closingService) GetClosingDataProduksi(c *fiber.Ctx, projectFlockID uint
return &result, nil return &result, nil
} }
func (s closingService) calculateAverageSalesAge(ctx context.Context, projectFlockID uint) (float64, error) { func (s closingService) calculateAverageSalesAge(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) (float64, error) {
deliveryProducts, err := s.MarketingDeliveryProductRepo.GetDeliveryProductsByProjectFlockID(ctx, projectFlockID, func(db *gorm.DB) *gorm.DB { penjualan, err := s.MarketingDeliveryProductRepo.GetClosingPenjualanForAgeChickDataProduction(ctx, projectFlockID, projectFlockKandangID)
return db.
Preload("MarketingProduct").
Preload("MarketingProduct.ProductWarehouse").
Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang").
Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang.Chickins")
})
if err != nil { if err != nil {
return 0, err return 0, err
} }
acumulateAgeQty := 0.0
var ( totalQty := 0.0
totalQty float64 for _, v := range penjualan {
totalAgeWeeks float64 sale := dto.ToSalesAgeDTO(v)
) acumulateAgeQty += float64(sale.Age) * sale.Qty
totalQty += sale.Qty
for _, product := range deliveryProducts { }
if product.UsageQty == 0 { if totalQty > 0 {
continue averageAge := acumulateAgeQty / totalQty
} return averageAge, nil
projectFlockKandang := product.MarketingProduct.ProductWarehouse.ProjectFlockKandang
ageWeeks := dto.CalculateAgeFromChickinDataProduksi(projectFlockKandang, product.DeliveryDate)
totalAgeWeeks += float64(ageWeeks) * product.UsageQty
totalQty += product.UsageQty
} }
if totalQty == 0 { return 0, err
return 0, nil
}
return totalAgeWeeks / totalQty, nil
} }
func (s closingService) determineProductionWeek(ctx context.Context, projectFlockKandangIDs []uint) (int, error) { func (s closingService) determineProductionWeek(ctx context.Context, projectFlockKandangIDs []uint) (int, error) {
@@ -2,6 +2,7 @@ package controller
import ( import (
"math" "math"
"mime/multipart"
"strconv" "strconv"
"gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/dto" "gitlab.com/mbugroup/lti-api.git/internal/modules/daily-checklists/dto"
@@ -362,6 +363,9 @@ func (u *DailyChecklistController) UpdateOne(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadRequest, "Invalid multipart form") return fiber.NewError(fiber.StatusBadRequest, "Invalid multipart form")
} }
req.Documents = form.File["documents"] req.Documents = form.File["documents"]
if err := validateDailyChecklistDocumentSizes(req.Documents); err != nil {
return err
}
if err := c.BodyParser(req); err != nil { if err := c.BodyParser(req); err != nil {
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body") return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
@@ -381,6 +385,16 @@ func (u *DailyChecklistController) UpdateOne(c *fiber.Ctx) error {
}) })
} }
func validateDailyChecklistDocumentSizes(files []*multipart.FileHeader) error {
const maxDailyChecklistDocumentBytes = 5 * 1024 * 1024 // 5MB
for _, file := range files {
if file != nil && file.Size > maxDailyChecklistDocumentBytes {
return fiber.NewError(fiber.StatusRequestEntityTooLarge, "Document size must be <= 5MB")
}
}
return nil
}
func (u *DailyChecklistController) DeleteOne(c *fiber.Ctx) error { func (u *DailyChecklistController) DeleteOne(c *fiber.Ctx) error {
param := c.Params("idDailyChecklist") param := c.Params("idDailyChecklist")
@@ -3,6 +3,7 @@ package service
import ( import (
"errors" "errors"
"math" "math"
"regexp"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@@ -259,8 +260,9 @@ func (s dailyChecklistService) GetAll(c *fiber.Ctx, params *validation.Query) ([
} }
if params.Search != "" { if params.Search != "" {
like := "%" + params.Search + "%" re := regexp.MustCompile("[^a-zA-Z0-9]")
db = db.Where("(k.name ILIKE ? OR dc.category::text ILIKE ?)", like, like) like := re.ReplaceAll([]byte("%"+params.Search+"%"), []byte(""))
db = db.Where("(regexp_replace(k.name, '[^a-zA-Z0-9]', '', 'g') ILIKE ? OR regexp_replace(dc.category::text, '[^a-zA-Z0-9]', '', 'g') ILIKE ?)", string(like), string(like))
} }
countDB := db.Session(&gorm.Session{}) countDB := db.Session(&gorm.Session{})
@@ -169,15 +169,30 @@ func (s *adjustmentService) Adjustment(c *fiber.Ctx, req *validation.Create) (*e
CreatedBy: actorID, CreatedBy: actorID,
} }
stockLogs, err := s.StockLogsRepository.GetByProductWarehouse(ctx, productWarehouse.Id, 1)
if err != nil {
s.Log.Errorf("Failed to get stock logs: %+v", err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
newLog.Stock = latestStockLog.Stock
} else {
newLog.Stock = 0
}
if transactionType == string(utils.StockLogTransactionTypeIncrease) { if transactionType == string(utils.StockLogTransactionTypeIncrease) {
afterQuantity += req.Quantity afterQuantity += req.Quantity
newLog.Increase = req.Quantity newLog.Increase = req.Quantity
newLog.Stock += newLog.Increase
} else { } else {
if productWarehouse.Quantity < req.Quantity { if productWarehouse.Quantity < req.Quantity {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak mencukupi untuk pengurangan. Stok saat ini: %.2f, Jumlah yang akan dikurangi: %.2f", productWarehouse.Quantity, req.Quantity)) return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Stok tidak mencukupi untuk pengurangan. Stok saat ini: %.2f, Jumlah yang akan dikurangi: %.2f", productWarehouse.Quantity, req.Quantity))
} }
afterQuantity -= req.Quantity afterQuantity -= req.Quantity
newLog.Decrease = req.Quantity newLog.Decrease = req.Quantity
newLog.Stock -= newLog.Decrease
} }
if err := s.StockLogsRepository.WithTx(tx).CreateOne(ctx, newLog, nil); err != nil { if err := s.StockLogsRepository.WithTx(tx).CreateOne(ctx, newLog, nil); err != nil {
@@ -62,6 +62,7 @@ type StockLogDetailDTO struct {
Id uint `json:"id"` Id uint `json:"id"`
Increase float64 `json:"increase"` Increase float64 `json:"increase"`
Decrease float64 `json:"decrease"` Decrease float64 `json:"decrease"`
Stock float64 `json:"stock"`
LoggableType string `json:"loggable_type"` LoggableType string `json:"loggable_type"`
LoggableId uint `json:"loggable_id"` LoggableId uint `json:"loggable_id"`
Notes *string `json:"notes"` Notes *string `json:"notes"`
@@ -195,6 +196,7 @@ func mapStockLogs(src []entity.StockLog) []StockLogDetailDTO {
Id: log.Id, Id: log.Id,
Increase: log.Increase, Increase: log.Increase,
Decrease: log.Decrease, Decrease: log.Decrease,
Stock: log.Stock,
LoggableType: log.LoggableType, LoggableType: log.LoggableType,
LoggableId: log.LoggableId, LoggableId: log.LoggableId,
Notes: notes, Notes: notes,
@@ -232,11 +232,24 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
} }
for _, delivery := range req.Deliveries { for _, delivery := range req.Deliveries {
// Skip supplier validation if SupplierID is 0 (optional)
if delivery.SupplierID == 0 { if delivery.SupplierID == 0 {
continue continue
} }
if delivery.VehiclePlate == "" {
return nil, fiber.NewError(fiber.StatusBadRequest, "Vehicle plate wajib diisi ketika supplier dipilih")
}
if delivery.DriverName == "" {
return nil, fiber.NewError(fiber.StatusBadRequest, "Driver name wajib diisi ketika supplier dipilih")
}
if delivery.DeliveryCost <= 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "Delivery cost harus lebih dari 0 ketika supplier dipilih")
}
if delivery.DeliveryCostPerItem <= 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "Delivery cost per item harus lebih dari 0 ketika supplier dipilih")
}
supplier, err := s.SupplierRepo.GetByID(c.Context(), uint(delivery.SupplierID), nil) supplier, err := s.SupplierRepo.GetByID(c.Context(), uint(delivery.SupplierID), nil)
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -463,6 +476,18 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
LoggableId: uint(detail.Id), LoggableId: uint(detail.Id),
Notes: "", Notes: "",
} }
stockLogs, err := s.StockLogsRepository.GetByProductWarehouse(c.Context(), uint(*detail.SourceProductWarehouseID), 1)
if err != nil {
s.Log.Errorf("Failed to get stock logs: %+v", err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
stockLogDecrease.Stock -= latestStockLog.Stock - stockLogDecrease.Decrease
} else {
stockLogDecrease.Stock -= stockLogDecrease.Decrease
}
if err := stocklogsRepoTx.CreateOne(c.Context(), stockLogDecrease, nil); err != nil { if err := stocklogsRepoTx.CreateOne(c.Context(), stockLogDecrease, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok keluar") return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok keluar")
} }
@@ -499,6 +524,17 @@ func (s *transferService) CreateOne(c *fiber.Ctx, req *validation.TransferReques
LoggableId: uint(detail.Id), LoggableId: uint(detail.Id),
Notes: "", Notes: "",
} }
stockLogs, err = s.StockLogsRepository.GetByProductWarehouse(c.Context(), uint(*detail.DestProductWarehouseID), 1)
if err != nil {
s.Log.Errorf("Failed to get stock logs: %+v", err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
stockLogIncrease.Stock = latestStockLog.Stock + stockLogIncrease.Increase
} else {
stockLogIncrease.Stock += stockLogIncrease.Increase
}
if err := stocklogsRepoTx.CreateOne(c.Context(), stockLogIncrease, nil); err != nil { if err := stocklogsRepoTx.CreateOne(c.Context(), stockLogIncrease, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok masuk") return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok masuk")
} }
@@ -21,11 +21,11 @@ type TransferDeliveryProduct struct {
} }
type TransferDelivery struct { type TransferDelivery struct {
DeliveryCost float64 `json:"delivery_cost" validate:"required"` DeliveryCost float64 `json:"delivery_cost"`
DeliveryCostPerItem float64 `json:"delivery_cost_per_item" validate:"required"` DeliveryCostPerItem float64 `json:"delivery_cost_per_item"`
DocumentIndex int `json:"document_index" validate:"omitempty,min=-1" default:"-1"` DocumentIndex int `json:"document_index" validate:"omitempty,min=-1" default:"-1"`
DriverName string `json:"driver_name" validate:"required"` DriverName string `json:"driver_name"`
VehiclePlate string `json:"vehicle_plate" validate:"required"` VehiclePlate string `json:"vehicle_plate"`
SupplierID uint `json:"supplier_id" ` SupplierID uint `json:"supplier_id" `
Products []TransferDeliveryProduct `json:"products" validate:"required,dive"` Products []TransferDeliveryProduct `json:"products" validate:"required,dive"`
} }
@@ -22,6 +22,7 @@ type MarketingDeliveryProductRepository interface {
UpdateFifoFields(ctx context.Context, id uint, usageQty, pendingQty float64) error UpdateFifoFields(ctx context.Context, id uint, usageQty, pendingQty float64) error
GetUsageQty(ctx context.Context, id uint) (float64, error) GetUsageQty(ctx context.Context, id uint) (float64, error)
ResetFifoFields(ctx context.Context, id uint) error ResetFifoFields(ctx context.Context, id uint) error
GetClosingPenjualanForAgeChickDataProduction(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]entity.MarketingDeliveryProduct, error)
} }
type MarketingDeliveryProductRepositoryImpl struct { type MarketingDeliveryProductRepositoryImpl struct {
@@ -93,6 +94,46 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetClosingPenjualan(ctx context
return deliveryProducts, nil return deliveryProducts, nil
} }
func (r *MarketingDeliveryProductRepositoryImpl) GetClosingPenjualanForAgeChickDataProduction(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint) ([]entity.MarketingDeliveryProduct, error) {
var deliveryProducts []entity.MarketingDeliveryProduct
db := r.DB().WithContext(ctx).
Joins("JOIN marketing_products ON marketing_products.id = marketing_delivery_products.marketing_product_id").
Joins("JOIN product_warehouses ON product_warehouses.id = marketing_products.product_warehouse_id").
Joins("JOIN products ON products.id = product_warehouses.product_id").
Joins("JOIN flags ON flags.flagable_id = products.id AND flags.flagable_type = 'products'").
Joins("JOIN project_flock_kandangs ON project_flock_kandangs.id = product_warehouses.project_flock_kandang_id").
Where("project_flock_kandangs.project_flock_id = ?", projectFlockID).
Where("flags.name IN (?)", []string{
string(utils.FlagAyamAfkir),
string(utils.FlagAyamCulling),
string(utils.FlagPullet),
string(utils.FlagLayer),
}).
Where("marketing_delivery_products.delivery_date IS NOT NULL").
Distinct("marketing_delivery_products.*")
if projectFlockKandangID != nil {
db = db.Where("product_warehouses.project_flock_kandang_id = ?", *projectFlockKandangID)
}
db = db.
Preload("MarketingProduct").
Preload("MarketingProduct.ProductWarehouse").
Preload("MarketingProduct.ProductWarehouse.Product").
Preload("MarketingProduct.ProductWarehouse.Product.ProductCategory").
Preload("MarketingProduct.ProductWarehouse.Product.Flags").
Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang").
Preload("MarketingProduct.ProductWarehouse.ProjectFlockKandang.Chickins").
Order("marketing_delivery_products.delivery_date DESC")
if err := db.Find(&deliveryProducts).Error; err != nil {
return nil, err
}
return deliveryProducts, nil
}
func (r *MarketingDeliveryProductRepositoryImpl) GetClosingPenjualanByCategory(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint, category string) ([]entity.MarketingDeliveryProduct, error) { func (r *MarketingDeliveryProductRepositoryImpl) GetClosingPenjualanByCategory(ctx context.Context, projectFlockID uint, projectFlockKandangID *uint, category string) ([]entity.MarketingDeliveryProduct, error) {
var deliveryProducts []entity.MarketingDeliveryProduct var deliveryProducts []entity.MarketingDeliveryProduct
@@ -410,6 +410,7 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO
return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch delivery product") return fiber.NewError(fiber.StatusInternalServerError, "Failed to fetch delivery product")
} }
oldRequestedQty := deliveryProduct.UsageQty + deliveryProduct.PendingQty
var itemDeliveryDate *time.Time var itemDeliveryDate *time.Time
if requestedProduct.DeliveryDate != "" { if requestedProduct.DeliveryDate != "" {
parsedDate, err := utils.ParseDateString(requestedProduct.DeliveryDate) parsedDate, err := utils.ParseDateString(requestedProduct.DeliveryDate)
@@ -421,11 +422,8 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO
itemDeliveryDate = deliveryProduct.DeliveryDate itemDeliveryDate = deliveryProduct.DeliveryDate
} }
oldRequestedQty := deliveryProduct.UsageQty + deliveryProduct.PendingQty
// Cek apakah product punya flag PAKAN atau OVK
isPakanOrOVK := false isPakanOrOVK := false
if foundMarketingProduct.ProductWarehouse.Product.Id != 0 && len(foundMarketingProduct.ProductWarehouse.Product.Flags) > 0 { if foundMarketingProduct.ProductWarehouse.Id != 0 && foundMarketingProduct.ProductWarehouse.Product.Id != 0 && len(foundMarketingProduct.ProductWarehouse.Product.Flags) > 0 {
for _, flag := range foundMarketingProduct.ProductWarehouse.Product.Flags { for _, flag := range foundMarketingProduct.ProductWarehouse.Product.Flags {
if flag.Name == string(utils.FlagPakan) || flag.Name == string(utils.FlagOVK) { if flag.Name == string(utils.FlagPakan) || flag.Name == string(utils.FlagOVK) {
isPakanOrOVK = true isPakanOrOVK = true
@@ -506,60 +504,82 @@ func (s deliveryOrdersService) consumeDeliveryStock(ctx context.Context, tx *gor
deliveryProductRepo := marketingRepo.NewMarketingDeliveryProductRepository(tx) deliveryProductRepo := marketingRepo.NewMarketingDeliveryProductRepository(tx)
if err == nil && result.UsageQuantity > 0 { totalConsumed := 0.0
if actorID > 0 { var fifoConsumed float64
decreaseLog := &entity.StockLog{ var directConsumed float64
Decrease: result.UsageQuantity,
LoggableType: string(utils.StockLogTypeMarketing), if result != nil && result.UsageQuantity > 0 {
LoggableId: deliveryProduct.Id, fifoConsumed = result.UsageQuantity
ProductWarehouseId: marketingProduct.ProductWarehouseId, totalConsumed = result.UsageQuantity
CreatedBy: actorID,
Notes: "",
}
s.StockLogRepo.WithTx(tx).CreateOne(ctx, decreaseLog, nil)
}
} }
if err != nil { if err != nil || (totalConsumed < requestedQty) {
remainder := requestedQty - totalConsumed
pwRepo := productWarehouseRepo.NewProductWarehouseRepository(tx) pwRepo := productWarehouseRepo.NewProductWarehouseRepository(tx)
pw, err2 := pwRepo.GetByID(ctx, marketingProduct.ProductWarehouseId, nil) pw, err2 := pwRepo.GetByID(ctx, marketingProduct.ProductWarehouseId, nil)
if err2 != nil { if err2 != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to check product warehouse stock") return fiber.NewError(fiber.StatusInternalServerError, "Failed to check product warehouse stock")
} }
if pw == nil || pw.Quantity < requestedQty { if pw == nil || pw.Quantity < remainder {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Insufficient stock. Available: %.2f, Requested: %.2f", func() float64 { return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Insufficient stock. FIFO: %.2f, Direct Available: %.2f, Total Needed: %.2f", func() float64 {
if pw != nil { if pw != nil {
return pw.Quantity return pw.Quantity
} else { } else {
return 0 return 0
} }
}(), requestedQty)) }(), remainder, requestedQty))
} }
if err := deliveryProductRepo.UpdateFifoFields(ctx, deliveryProduct.Id, requestedQty, 0); err != nil { if err := pwRepo.AdjustQuantities(ctx, map[uint]float64{
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update delivery product") marketingProduct.ProductWarehouseId: -remainder,
}, func(db *gorm.DB) *gorm.DB {
return tx
}); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to adjust product warehouse quantity")
} }
if actorID > 0 { directConsumed = remainder
decreaseLog := &entity.StockLog{ totalConsumed += remainder
Decrease: requestedQty,
LoggableType: string(utils.StockLogTypeMarketing),
LoggableId: deliveryProduct.Id,
ProductWarehouseId: marketingProduct.ProductWarehouseId,
CreatedBy: actorID,
Notes: "",
}
s.StockLogRepo.WithTx(tx).CreateOne(ctx, decreaseLog, nil)
}
return nil
} }
if err := deliveryProductRepo.UpdateFifoFields(ctx, deliveryProduct.Id, result.UsageQuantity, result.PendingQuantity); err != nil { if err := deliveryProductRepo.UpdateFifoFields(ctx, deliveryProduct.Id, totalConsumed, 0); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to update delivery product") return fiber.NewError(fiber.StatusInternalServerError, "Failed to update delivery product")
} }
if actorID > 0 && totalConsumed > 0 {
notes := ""
if fifoConsumed > 0 && directConsumed > 0 {
notes = fmt.Sprintf("Partial FIFO (%.2f) + Direct (%.2f)", fifoConsumed, directConsumed)
} else if fifoConsumed > 0 {
notes = fmt.Sprintf("FIFO stock only (%.2f)", fifoConsumed)
} else if directConsumed > 0 {
notes = fmt.Sprintf("Direct stock only (%.2f)", directConsumed)
}
decreaseLog := &entity.StockLog{
Decrease: totalConsumed,
LoggableType: string(utils.StockLogTypeMarketing),
LoggableId: deliveryProduct.Id,
ProductWarehouseId: marketingProduct.ProductWarehouseId,
CreatedBy: actorID,
Notes: notes,
}
stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, marketingProduct.ProductWarehouseId, 1)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
decreaseLog.Stock = latestStockLog.Stock
decreaseLog.Stock -= decreaseLog.Decrease
} else {
decreaseLog.Stock -= decreaseLog.Decrease
}
s.StockLogRepo.WithTx(tx).CreateOne(ctx, decreaseLog, nil)
}
return nil return nil
} }
@@ -599,6 +619,18 @@ func (s deliveryOrdersService) releaseDeliveryStock(ctx context.Context, tx *gor
CreatedBy: actorID, CreatedBy: actorID,
Notes: "", Notes: "",
} }
stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, marketingProduct.ProductWarehouseId, 1)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
increaseLog.Stock = latestStockLog.Stock
increaseLog.Stock += increaseLog.Increase
} else {
increaseLog.Stock += increaseLog.Increase
}
s.StockLogRepo.WithTx(tx).CreateOne(ctx, increaseLog, nil) s.StockLogRepo.WithTx(tx).CreateOne(ctx, increaseLog, nil)
} }
@@ -645,6 +645,18 @@ func (s *chickinService) ConsumeChickinStocks(ctx context.Context, tx *gorm.DB,
CreatedBy: actorID, CreatedBy: actorID,
Notes: fmt.Sprintf("Chickin #%d", chickin.Id), Notes: fmt.Sprintf("Chickin #%d", chickin.Id),
} }
stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, chickin.ProductWarehouseId, 1)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
decreaseLog.Stock = latestStockLog.Stock
decreaseLog.Stock -= decreaseLog.Decrease
} else {
decreaseLog.Stock -= decreaseLog.Decrease
}
s.StockLogRepo.CreateOne(ctx, decreaseLog, nil) s.StockLogRepo.CreateOne(ctx, decreaseLog, nil)
} }
@@ -701,6 +713,18 @@ func (s *chickinService) ReleaseChickinStocks(ctx context.Context, tx *gorm.DB,
CreatedBy: actorID, CreatedBy: actorID,
Notes: fmt.Sprintf("Chickin #%d - Stock released", chickin.Id), Notes: fmt.Sprintf("Chickin #%d - Stock released", chickin.Id),
} }
stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, chickin.ProductWarehouseId, 1)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
increaseLog.Stock = latestStockLog.Stock
increaseLog.Stock += increaseLog.Increase
} else {
increaseLog.Stock += increaseLog.Increase
}
s.StockLogRepo.CreateOne(ctx, increaseLog, nil) s.StockLogRepo.CreateOne(ctx, increaseLog, nil)
} }
@@ -4,6 +4,10 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"math"
"strings"
"time"
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository" commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service" commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
entity "gitlab.com/mbugroup/lti-api.git/internal/entities" entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
@@ -19,9 +23,6 @@ import (
approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals" approvalutils "gitlab.com/mbugroup/lti-api.git/internal/utils/approvals"
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo" "gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
recordingutil "gitlab.com/mbugroup/lti-api.git/internal/utils/recording" recordingutil "gitlab.com/mbugroup/lti-api.git/internal/utils/recording"
"math"
"strings"
"time"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
@@ -535,6 +536,17 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
if egg.ProductWarehouseId == 0 || egg.Qty <= 0 { if egg.ProductWarehouseId == 0 || egg.Qty <= 0 {
continue continue
} }
stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, egg.ProductWarehouseId, 1)
if err != nil {
s.Log.Errorf("Failed to get stock logs: %+v", err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
latestStockLog := &entity.StockLog{}
if len(stockLogs) > 0 {
latestStockLog = stockLogs[0]
} else {
latestStockLog.Stock = 0
}
logs = append(logs, &entity.StockLog{ logs = append(logs, &entity.StockLog{
ProductWarehouseId: egg.ProductWarehouseId, ProductWarehouseId: egg.ProductWarehouseId,
CreatedBy: actorID, CreatedBy: actorID,
@@ -542,6 +554,7 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
LoggableType: string(utils.StockLogTypeRecording), LoggableType: string(utils.StockLogTypeRecording),
LoggableId: recordingEntity.Id, LoggableId: recordingEntity.Id,
Notes: note, Notes: note,
Stock: latestStockLog.Stock - float64(egg.Qty),
}) })
} }
if len(logs) > 0 { if len(logs) > 0 {
@@ -937,6 +950,18 @@ func (s *recordingService) consumeRecordingStocks(
LoggableId: stock.RecordingId, LoggableId: stock.RecordingId,
Notes: note, Notes: note,
} }
stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, stock.ProductWarehouseId, 1)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
log.Stock = latestStockLog.Stock
log.Stock -= log.Decrease
} else {
log.Stock -= log.Decrease
}
if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil {
return err return err
} }
@@ -1004,6 +1029,18 @@ func (s *recordingService) consumeRecordingDepletions(
LoggableId: depletion.RecordingId, LoggableId: depletion.RecordingId,
Notes: note, Notes: note,
} }
stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, sourceWarehouseID, 1)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
log.Stock = latestStockLog.Stock
log.Stock -= log.Decrease
} else {
log.Stock -= log.Decrease
}
if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil {
return err return err
} }
@@ -1022,6 +1059,18 @@ func (s *recordingService) consumeRecordingDepletions(
LoggableId: depletion.RecordingId, LoggableId: depletion.RecordingId,
Notes: note, Notes: note,
} }
stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, depletion.ProductWarehouseId, 1)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
log.Stock = latestStockLog.Stock
log.Stock += log.Increase
} else {
log.Stock += log.Increase
}
if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil {
return err return err
} }
@@ -1082,6 +1131,18 @@ func (s *recordingService) releaseRecordingStocks(
LoggableId: stock.RecordingId, LoggableId: stock.RecordingId,
Notes: note, Notes: note,
} }
stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, stock.ProductWarehouseId, 1)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
log.Stock = latestStockLog.Stock
log.Stock += log.Increase
} else {
log.Stock += log.Increase
}
if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil {
return err return err
} }
@@ -1144,6 +1205,18 @@ func (s *recordingService) releaseRecordingDepletions(
LoggableId: depletion.RecordingId, LoggableId: depletion.RecordingId,
Notes: note, Notes: note,
} }
stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, sourceWarehouseID, 1)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
log.Stock = latestStockLog.Stock
log.Stock += log.Increase
} else {
log.Stock += log.Increase
}
if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil {
return err return err
} }
@@ -1162,6 +1235,18 @@ func (s *recordingService) releaseRecordingDepletions(
LoggableId: depletion.RecordingId, LoggableId: depletion.RecordingId,
Notes: note, Notes: note,
} }
stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, depletion.ProductWarehouseId, 1)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
log.Stock = latestStockLog.Stock
log.Stock -= log.Decrease
} else {
log.Stock -= log.Decrease
}
if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil {
return err return err
} }
@@ -1309,6 +1394,18 @@ func (s *recordingService) replenishRecordingEggs(
LoggableId: egg.RecordingId, LoggableId: egg.RecordingId,
Notes: note, Notes: note,
} }
stockLogs, err := s.StockLogRepo.GetByProductWarehouse(ctx, egg.ProductWarehouseId, 1)
if err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
log.Stock = latestStockLog.Stock
log.Stock += log.Increase
} else {
log.Stock += log.Increase
}
if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil { if err := s.StockLogRepo.WithTx(tx).CreateOne(ctx, log, nil); err != nil {
return err return err
} }
@@ -1692,7 +1789,8 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm
var eggMass float64 var eggMass float64
if remainingChick > 0 && totalEggWeightGrams > 0 { if remainingChick > 0 && totalEggWeightGrams > 0 {
eggMass = (totalEggWeightGrams / remainingChick) / 1000 // totalEggWeightGrams is in grams; egg mass is grams per hen.
eggMass = totalEggWeightGrams / remainingChick
updates["egg_mass"] = eggMass updates["egg_mass"] = eggMass
recording.EggMass = &eggMass recording.EggMass = &eggMass
} else { } else {
@@ -2,6 +2,7 @@ package repository
import ( import (
"context" "context"
"fmt"
"strings" "strings"
"gitlab.com/mbugroup/lti-api.git/internal/common/repository" "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
@@ -16,6 +17,10 @@ type TransferLayingRepository interface {
// Tambah method baru untuk query dengan filter lengkap // Tambah method baru untuk query dengan filter lengkap
GetAllWithFilters(ctx context.Context, offset int, limit int, params *GetAllFilterParams) ([]entity.LayingTransfer, int64, error) GetAllWithFilters(ctx context.Context, offset int, limit int, params *GetAllFilterParams) ([]entity.LayingTransfer, int64, error)
// Get sequence for movement number
GetNextMovementNumber(ctx context.Context) (int64, error)
GenerateMovementNumber(ctx context.Context) (string, error)
} }
type TransferLayingRepositoryImpl struct { type TransferLayingRepositoryImpl struct {
@@ -29,6 +34,26 @@ func NewTransferLayingRepository(db *gorm.DB) TransferLayingRepository {
db: db, db: db,
} }
} }
func (r *TransferLayingRepositoryImpl) GetNextMovementNumber(ctx context.Context) (int64, error) {
var seq int64
err := r.db.WithContext(ctx).Raw("SELECT nextval('transfer_laying_seq')").Scan(&seq).Error
if err != nil {
return 0, err
}
return seq, nil
}
func (r *TransferLayingRepositoryImpl) GenerateMovementNumber(ctx context.Context) (string, error) {
seq, err := r.GetNextMovementNumber(ctx)
if err != nil {
return "", err
}
movementNumber := fmt.Sprintf("TL-%05d", seq)
return movementNumber, nil
}
func (r *TransferLayingRepositoryImpl) IdExists(ctx context.Context, id uint) (bool, error) { func (r *TransferLayingRepositoryImpl) IdExists(ctx context.Context, id uint) (bool, error) {
return repository.Exists[entity.LayingTransfer](ctx, r.db, id) return repository.Exists[entity.LayingTransfer](ctx, r.db, id)
} }
@@ -271,7 +271,11 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create)
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Jumlah total sumber (%.0f) harus sama dengan jumlah total tujuan (%.0f)", totalSourceQty, totalTargetQty)) return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Jumlah total sumber (%.0f) harus sama dengan jumlah total tujuan (%.0f)", totalSourceQty, totalTargetQty))
} }
transferNumber := fmt.Sprintf("TL-%d", time.Now().UnixNano()) transferNumber, err := s.Repository.GenerateMovementNumber(c.Context())
if err != nil {
s.Log.Errorf("Failed to generate movement number: %+v", err)
return nil, fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat nomor transfer")
}
createBody := &entity.LayingTransfer{ createBody := &entity.LayingTransfer{
TransferNumber: transferNumber, TransferNumber: transferNumber,
@@ -440,15 +444,105 @@ func (s *transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update,
return nil, fiber.NewError(fiber.StatusBadRequest, "Target project flock not found") return nil, fiber.NewError(fiber.StatusBadRequest, "Target project flock not found")
} }
sourceKandangIDs := make([]uint, len(req.SourceKandangs))
for i, detail := range req.SourceKandangs {
sourceKandangIDs[i] = detail.ProjectFlockKandangId
}
if err := s.validateKandangOwnership(
c.Context(),
req.SourceProjectFlockId,
sourceKandangIDs,
); err != nil {
return nil, err
}
targetKandangIDs := make([]uint, len(req.TargetKandangs))
for i, detail := range req.TargetKandangs {
targetKandangIDs[i] = detail.ProjectFlockKandangId
}
if err := s.validateKandangOwnership(
c.Context(),
req.TargetProjectFlockId,
targetKandangIDs,
); err != nil {
return nil, err
}
transferDate, err := time.Parse("2006-01-02", req.TransferDate) transferDate, err := time.Parse("2006-01-02", req.TransferDate)
if err != nil { if err != nil {
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid transfer date format") return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid transfer date format")
} }
var totalSourceQty, totalTargetQty float64
sourceWarehouseMap := make(map[uint]uint)
for _, sourceDetail := range req.SourceKandangs {
if sourceDetail.Quantity <= 0 {
continue
}
totalSourceQty += sourceDetail.Quantity
populations, err := s.ProjectFlockPopulationRepo.GetByProjectFlockKandangID(c.Context(), sourceDetail.ProjectFlockKandangId)
if err != nil {
return nil, err
}
var totalPopulation float64
var productWarehouseId uint
for _, pop := range populations {
totalPopulation += pop.TotalQty
if productWarehouseId == 0 {
productWarehouseId = pop.ProductWarehouseId
}
}
if totalPopulation == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Kandang sumber %d tidak memiliki populasi untuk ditransfer", sourceDetail.ProjectFlockKandangId))
}
if totalPopulation < sourceDetail.Quantity {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Kandang sumber %d jumlah tidak mencukupi. Tersedia: %.0f, Diminta: %.0f", sourceDetail.ProjectFlockKandangId, totalPopulation, sourceDetail.Quantity))
}
sourceWarehouseMap[sourceDetail.ProjectFlockKandangId] = productWarehouseId
}
for _, targetDetail := range req.TargetKandangs {
if targetDetail.Quantity <= 0 {
continue
}
totalTargetQty += targetDetail.Quantity
}
if totalSourceQty == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "Minimal harus ada 1 kandang sumber dengan jumlah lebih dari 0")
}
if totalTargetQty == 0 {
return nil, fiber.NewError(fiber.StatusBadRequest, "Minimal harus ada 1 kandang tujuan dengan jumlah lebih dari 0")
}
if totalSourceQty != totalTargetQty {
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Jumlah total sumber (%.0f) harus sama dengan jumlah total tujuan (%.0f)", totalSourceQty, totalTargetQty))
}
// Ambil productWarehouseId pertama dari source yang valid (quantity > 0)
var firstProductWarehouseId uint
for _, sourceDetail := range req.SourceKandangs {
if sourceDetail.Quantity > 0 {
if pwId, ok := sourceWarehouseMap[sourceDetail.ProjectFlockKandangId]; ok {
firstProductWarehouseId = pwId
break
}
}
}
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error { err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error {
repoTx := s.Repository.WithTx(dbTransaction) repoTx := s.Repository.WithTx(dbTransaction)
sourceRepo := s.LayingTransferSourceRepo.WithTx(dbTransaction) sourceRepo := s.LayingTransferSourceRepo.WithTx(dbTransaction)
targetRepo := s.LayingTransferTargetRepo.WithTx(dbTransaction) targetRepo := s.LayingTransferTargetRepo.WithTx(dbTransaction)
pwRepo := rInventory.NewProductWarehouseRepository(dbTransaction)
// Hapus old sources dan targets // Hapus old sources dan targets
for _, oldSource := range existingTransfer.Sources { for _, oldSource := range existingTransfer.Sources {
@@ -472,26 +566,11 @@ func (s *transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update,
// Create new sources dengan pending quantity // Create new sources dengan pending quantity
for _, sourceDetail := range req.SourceKandangs { for _, sourceDetail := range req.SourceKandangs {
populations, err := s.ProjectFlockPopulationRepo.GetByProjectFlockKandangID(c.Context(), sourceDetail.ProjectFlockKandangId) if sourceDetail.Quantity == 0 {
if err != nil { continue
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get populations")
} }
if len(populations) == 0 { productWarehouseId := sourceWarehouseMap[sourceDetail.ProjectFlockKandangId]
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Source kandang %d has no population available", sourceDetail.ProjectFlockKandangId))
}
var productWarehouseId uint
for _, pop := range populations {
if pop.ProductWarehouseId > 0 {
productWarehouseId = pop.ProductWarehouseId
break
}
}
if productWarehouseId == 0 {
return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Source kandang %d has no product warehouse", sourceDetail.ProjectFlockKandangId))
}
source := entity.LayingTransferSource{ source := entity.LayingTransferSource{
LayingTransferId: id, LayingTransferId: id,
@@ -506,7 +585,18 @@ func (s *transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update,
} }
} }
pwRepo := rInventory.NewProductWarehouseRepository(dbTransaction) // Ambil product ID dari source warehouse pertama yang valid
var sourceProductID uint
if firstProductWarehouseId > 0 {
sourcePW, err := pwRepo.GetByID(c.Context(), firstProductWarehouseId, nil)
if err == nil {
sourceProductID = sourcePW.ProductId
}
}
if sourceProductID == 0 {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get product from source warehouse")
}
for _, targetDetail := range req.TargetKandangs { for _, targetDetail := range req.TargetKandangs {
targetprojectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), targetDetail.ProjectFlockKandangId) targetprojectFlockKandang, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), targetDetail.ProjectFlockKandangId)
@@ -522,23 +612,6 @@ func (s *transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update,
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get target warehouse") return fiber.NewError(fiber.StatusInternalServerError, "Failed to get target warehouse")
} }
// Ambil product ID dari source yang pertama (semua sources seharusnya product-nya sama)
var sourceProductID uint
if len(req.SourceKandangs) > 0 {
firstSourceKandangID := req.SourceKandangs[0].ProjectFlockKandangId
populations, err := s.ProjectFlockPopulationRepo.GetByProjectFlockKandangID(c.Context(), firstSourceKandangID)
if err == nil && len(populations) > 0 && populations[0].ProductWarehouseId > 0 {
sourcePW, err := pwRepo.GetByID(c.Context(), populations[0].ProductWarehouseId, nil)
if err == nil {
sourceProductID = sourcePW.ProductId
}
}
}
if sourceProductID == 0 {
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get product from source warehouse")
}
targetPW, err := pwRepo.FindByProductWarehouseAndPfk(c.Context(), sourceProductID, targetWarehouse.Id, &targetDetail.ProjectFlockKandangId) targetPW, err := pwRepo.FindByProductWarehouseAndPfk(c.Context(), sourceProductID, targetWarehouse.Id, &targetDetail.ProjectFlockKandangId)
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -753,6 +826,18 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) (
LoggableId: approvableID, LoggableId: approvableID,
Notes: fmt.Sprintf("TL #%s", transfer.TransferNumber), Notes: fmt.Sprintf("TL #%s", transfer.TransferNumber),
} }
stockLogs, err := stockLogRepoTx.GetByProductWarehouse(c.Context(), *source.ProductWarehouseId, 1)
if err != nil {
s.Log.Errorf("Failed to get stock logs: %+v", err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
stockLogDecrease.Stock = latestStockLog.Stock - stockLogDecrease.Decrease
} else {
stockLogDecrease.Stock -= stockLogDecrease.Decrease
}
if err := stockLogRepoTx.CreateOne(c.Context(), stockLogDecrease, nil); err != nil { if err := stockLogRepoTx.CreateOne(c.Context(), stockLogDecrease, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok keluar") return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok keluar")
} }
@@ -791,6 +876,18 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) (
LoggableId: approvableID, LoggableId: approvableID,
Notes: fmt.Sprintf("TL #%s", transfer.TransferNumber), Notes: fmt.Sprintf("TL #%s", transfer.TransferNumber),
} }
stockLogs, err := stockLogRepoTx.GetByProductWarehouse(c.Context(), *target.ProductWarehouseId, 1)
if err != nil {
s.Log.Errorf("Failed to get stock logs: %+v", err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
stockLogIncrease.Stock = latestStockLog.Stock + stockLogIncrease.Increase
} else {
stockLogIncrease.Stock += stockLogIncrease.Increase
}
if err := stockLogRepoTx.CreateOne(c.Context(), stockLogIncrease, nil); err != nil { if err := stockLogRepoTx.CreateOne(c.Context(), stockLogIncrease, nil); err != nil {
return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok masuk") return fiber.NewError(fiber.StatusInternalServerError, "Gagal membuat log stok masuk")
} }
@@ -1081,10 +1081,25 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
LoggableId: purchase.Id, LoggableId: purchase.Id,
Notes: receiveNote, Notes: receiveNote,
} }
stockLogs, err := stockLogRepoTx.GetByProductWarehouse(ctx, entry.pwID, 1)
if err != nil {
s.Log.Errorf("Failed to get stock logs: %+v", err)
return fiber.NewError(fiber.StatusInternalServerError, "Failed to get stock logs")
}
if len(stockLogs) > 0 {
latestStockLog := stockLogs[0]
log.Stock = latestStockLog.Stock
} else {
log.Stock = 0
}
if entry.delta > 0 { if entry.delta > 0 {
log.Increase = entry.delta log.Increase = entry.delta
log.Stock += log.Increase
} else { } else {
log.Decrease = -entry.delta log.Decrease = -entry.delta
log.Stock -= log.Decrease
} }
logs = append(logs, log) logs = append(logs, log)
} }
@@ -200,7 +200,7 @@ func (h *Controller) Refresh(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusUnauthorized, "invalid access token") return fiber.NewError(fiber.StatusUnauthorized, "invalid access token")
} }
issueCookies(c, struct { if err := issueCookies(c, struct {
AccessToken string `json:"access_token"` AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"` RefreshToken string `json:"refresh_token"`
TokenType string `json:"token_type"` TokenType string `json:"token_type"`
@@ -218,7 +218,9 @@ func (h *Controller) Refresh(c *fiber.Ctx) error {
IDToken: tokenResp.IDToken, IDToken: tokenResp.IDToken,
Error: tokenResp.Error, Error: tokenResp.Error,
Description: tokenResp.Description, Description: tokenResp.Description,
}, verification) }, verification); err != nil {
return err
}
utils.Log.WithFields(logrus.Fields{ utils.Log.WithFields(logrus.Fields{
"user_id": verification.UserID, "user_id": verification.UserID,
@@ -307,7 +309,9 @@ func (h *Controller) Callback(c *fiber.Ctx) error {
} }
// prepare cookies // prepare cookies
issueCookies(c, tokenResp, verification) if err := issueCookies(c, tokenResp, verification); err != nil {
return err
}
redirectTarget := sessionData.ReturnTo redirectTarget := sessionData.ReturnTo
if redirectTarget == "" { if redirectTarget == "" {
@@ -742,13 +746,21 @@ func issueCookies(c *fiber.Ctx, tokenResp struct {
IDToken string `json:"id_token"` IDToken string `json:"id_token"`
Error string `json:"error"` Error string `json:"error"`
Description string `json:"error_description"` Description string `json:"error_description"`
}, verification *sso.VerificationResult) { }, verification *sso.VerificationResult) error {
if revoker := session.GetRevocationStore(); revoker != nil && verification != nil { if revoker := session.GetRevocationStore(); revoker != nil && verification != nil {
if err := revoker.ClearUserLogout(c.Context(), verification.UserID); err != nil { if err := revoker.ClearUserLogout(c.Context(), verification.UserID); err != nil {
utils.Log.WithError(err).Warn("failed to clear logout marker") utils.Log.WithError(err).Warn("failed to clear logout marker")
} }
} }
if max := config.SSOAccessTokenMaxBytes; max > 0 && len(tokenResp.AccessToken) > max {
utils.Log.WithFields(logrus.Fields{
"token_len": len(tokenResp.AccessToken),
"max_len": max,
}).Warn("sso access token exceeds cookie size limit")
return fiber.NewError(fiber.StatusRequestEntityTooLarge, "access token too large")
}
accessName := resolveSSOCookieName(config.SSOAccessCookieName, "access") accessName := resolveSSOCookieName(config.SSOAccessCookieName, "access")
refreshName := resolveSSOCookieName(config.SSORefreshCookieName, "refresh") refreshName := resolveSSOCookieName(config.SSORefreshCookieName, "refresh")
maxAge := tokenResp.ExpiresIn maxAge := tokenResp.ExpiresIn
@@ -790,6 +802,7 @@ func issueCookies(c *fiber.Ctx, tokenResp struct {
// Optional: expose limited info via headers for FE debugging (avoid tokens) // Optional: expose limited info via headers for FE debugging (avoid tokens)
c.Set("X-Auth-User", fmt.Sprintf("%d", verification.UserID)) c.Set("X-Auth-User", fmt.Sprintf("%d", verification.UserID))
return nil
} }
func clearSSOCookie(c *fiber.Ctx, name string) { func clearSSOCookie(c *fiber.Ctx, name string) {
@@ -291,6 +291,8 @@ func (h *UserSyncController) upsertUser(c *fiber.Ctx, alias string, req *userSyn
"user_id": req.User.ID, "user_id": req.User.ID,
}).Info("sso user synced") }).Info("sso user synced")
sso.InvalidateProfileCache(c.Context(), uint(req.User.ID))
msg := fmt.Sprintf("User %s successfully", req.Action) msg := fmt.Sprintf("User %s successfully", req.Action)
return c.Status(fiber.StatusOK).JSON(response.Success{ return c.Status(fiber.StatusOK).JSON(response.Success{
Code: fiber.StatusOK, Code: fiber.StatusOK,
@@ -318,6 +320,8 @@ func (h *UserSyncController) logoutUser(c *fiber.Ctx, alias string, req *userSyn
"user_id": req.User.ID, "user_id": req.User.ID,
}).Info("sso user logout enforced") }).Info("sso user logout enforced")
sso.InvalidateProfileCache(c.Context(), uint(req.User.ID))
return c.Status(fiber.StatusOK).JSON(response.Common{ return c.Status(fiber.StatusOK).JSON(response.Common{
Code: fiber.StatusOK, Code: fiber.StatusOK,
Status: "success", Status: "success",
@@ -341,6 +345,8 @@ func (h *UserSyncController) removeUser(c *fiber.Ctx, alias string, req *userSyn
"user_id": req.User.ID, "user_id": req.User.ID,
}).Info("sso user deleted") }).Info("sso user deleted")
sso.InvalidateProfileCache(c.Context(), uint(req.User.ID))
return c.Status(fiber.StatusOK).JSON(response.Common{ return c.Status(fiber.StatusOK).JSON(response.Common{
Code: fiber.StatusOK, Code: fiber.StatusOK,
Status: "success", Status: "success",
+32 -12
View File
@@ -265,24 +265,44 @@ func profileCacheKey(userID uint) string {
return profileCachePrefix + strconv.FormatUint(uint64(userID), 10) return profileCachePrefix + strconv.FormatUint(uint64(userID), 10)
} }
// InvalidateProfileCache clears cached profile data for the given user in both local and Redis caches.
func InvalidateProfileCache(ctx context.Context, userID uint) {
if userID == 0 {
return
}
key := profileCacheKey(userID)
profileLocalCache.Delete(key)
client := cache.Redis()
if client == nil {
return
}
if ctx == nil {
ctx = context.Background()
}
if err := client.Del(ctx, key).Err(); err != nil && !errors.Is(err, redis.Nil) {
utils.Log.WithError(err).Warn("sso profile redis delete failed")
}
}
func canonicalPermissionName(name string) string { func canonicalPermissionName(name string) string {
return strings.ToLower(strings.TrimSpace(name)) return strings.ToLower(strings.TrimSpace(name))
} }
// userInfoEnvelope handles the varying shapes returned by the SSO userinfo endpoint. // userInfoEnvelope handles the varying shapes returned by the SSO userinfo endpoint.
type userInfoEnvelope struct { type userInfoEnvelope struct {
Roles []userInfoRole `json:"roles"` Roles []userInfoRole `json:"roles"`
AreaIDs []uint `json:"area_ids"` AreaIDs []uint `json:"area_ids"`
LocationIDs []uint `json:"location_ids"` LocationIDs []uint `json:"location_ids"`
AllArea bool `json:"all_area"` AllArea bool `json:"all_area"`
AllLocation bool `json:"all_location"` AllLocation bool `json:"all_location"`
Data *struct { Data *struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Roles []userInfoRole `json:"roles"` Roles []userInfoRole `json:"roles"`
AreaIDs []uint `json:"area_ids"` AreaIDs []uint `json:"area_ids"`
LocationIDs []uint `json:"location_ids"` LocationIDs []uint `json:"location_ids"`
AllArea bool `json:"all_area"` AllArea bool `json:"all_area"`
AllLocation bool `json:"all_location"` AllLocation bool `json:"all_location"`
} `json:"data"` } `json:"data"`
User *struct { User *struct {
ID int64 `json:"id"` ID int64 `json:"id"`