mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-05-20 13:31:56 +00:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b9986f4fc | |||
| a7ab396bca | |||
| 50d239e26f | |||
| cf81d5086a | |||
| 0e8314f4cc | |||
| 5ea4ed4e66 | |||
| c2a8a5f08a | |||
| 685f583f02 | |||
| 8079566ddf | |||
| 6f523b9709 | |||
| e6dc658046 | |||
| 491fe0abef | |||
| c07ba79ddb | |||
| 480e430289 | |||
| 07b55e79a5 | |||
| d54e8a4e02 | |||
| 8d9d06a757 | |||
| 796417d56f | |||
| 65409e5efa |
@@ -1,132 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/apikeys"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/config"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/database"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
db := database.Connect(config.DBHost, config.DBName)
|
||||
service := apikeys.NewService(db)
|
||||
ctx := context.Background()
|
||||
|
||||
switch os.Args[1] {
|
||||
case "create":
|
||||
fs := flag.NewFlagSet("create", flag.ExitOnError)
|
||||
name := fs.String("name", "dashboard-read-api", "integration client name")
|
||||
environment := fs.String("env", config.AppEnv, "environment label")
|
||||
permissions := fs.String("permissions", "", "comma separated permission codes")
|
||||
allArea := fs.Bool("all-area", true, "grant all areas")
|
||||
areaIDs := fs.String("area-ids", "", "comma separated area ids")
|
||||
allLocation := fs.Bool("all-location", true, "grant all locations")
|
||||
locationIDs := fs.String("location-ids", "", "comma separated location ids")
|
||||
fs.Parse(os.Args[2:])
|
||||
|
||||
permissionCodes := apikeys.DefaultDashboardPermissions()
|
||||
if strings.TrimSpace(*permissions) != "" {
|
||||
permissionCodes = splitCSV(*permissions)
|
||||
}
|
||||
|
||||
issued, err := service.Create(ctx, apikeys.CreateInput{
|
||||
Name: *name,
|
||||
Environment: *environment,
|
||||
PermissionCodes: permissionCodes,
|
||||
AllArea: *allArea,
|
||||
AreaIDs: parseUintCSV(*areaIDs),
|
||||
AllLocation: *allLocation,
|
||||
LocationIDs: parseUintCSV(*locationIDs),
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
fmt.Printf("name: %s\n", issued.Record.Name)
|
||||
fmt.Printf("environment: %s\n", issued.Record.Environment)
|
||||
fmt.Printf("prefix: %s\n", issued.Record.KeyPrefix)
|
||||
fmt.Printf("status: %s\n", issued.Record.Status)
|
||||
fmt.Printf("api_key: %s\n", issued.Key)
|
||||
case "list":
|
||||
fs := flag.NewFlagSet("list", flag.ExitOnError)
|
||||
environment := fs.String("env", "", "filter by environment")
|
||||
fs.Parse(os.Args[2:])
|
||||
|
||||
records, err := service.List(ctx, *environment)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
for _, record := range records {
|
||||
fmt.Printf("%s\t%s\t%s\t%s\tareas=%t\tlocations=%t\n",
|
||||
record.Environment,
|
||||
record.KeyPrefix,
|
||||
record.Status,
|
||||
record.Name,
|
||||
record.AllArea,
|
||||
record.AllLocation,
|
||||
)
|
||||
}
|
||||
case "revoke":
|
||||
fs := flag.NewFlagSet("revoke", flag.ExitOnError)
|
||||
environment := fs.String("env", config.AppEnv, "environment label")
|
||||
prefix := fs.String("prefix", "", "key prefix to revoke")
|
||||
fs.Parse(os.Args[2:])
|
||||
|
||||
if err := service.Revoke(ctx, *environment, *prefix); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Printf("revoked %s/%s\n", *environment, *prefix)
|
||||
default:
|
||||
usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Println("usage:")
|
||||
fmt.Println(" go run ./cmd/api-key create [flags]")
|
||||
fmt.Println(" go run ./cmd/api-key list [flags]")
|
||||
fmt.Println(" go run ./cmd/api-key revoke -env <environment> -prefix <prefix>")
|
||||
}
|
||||
|
||||
func splitCSV(raw string) []string {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(raw, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" {
|
||||
out = append(out, part)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseUintCSV(raw string) []uint {
|
||||
parts := splitCSV(raw)
|
||||
if len(parts) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
values := make([]uint, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
var value uint
|
||||
if _, err := fmt.Sscanf(part, "%d", &value); err == nil && value > 0 {
|
||||
values = append(values, value)
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
@@ -9,14 +9,12 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/apikeys"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/cache"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/config"
|
||||
"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/readapi"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/route"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
||||
|
||||
@@ -133,7 +131,6 @@ func setupDatabase() *gorm.DB {
|
||||
}
|
||||
|
||||
func setupRoutes(app *fiber.App, db *gorm.DB, rdb *redis.Client) {
|
||||
middleware.SetAPIKeyAuthenticator(apikeys.NewService(db))
|
||||
|
||||
// route.Routes(app, db)
|
||||
// app.Use(utils.NotFoundHandler)
|
||||
@@ -172,8 +169,6 @@ func setupRoutes(app *fiber.App, db *gorm.DB, rdb *redis.Client) {
|
||||
return c.Status(status).JSON(body)
|
||||
})
|
||||
|
||||
readAPIRoutes := app.Group("/api")
|
||||
readapi.RegisterRoutes(readAPIRoutes)
|
||||
route.Routes(app, db)
|
||||
app.Use(utils.NotFoundHandler)
|
||||
}
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/cache"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/config"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/readapi"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/route"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
func main() {
|
||||
root, err := findRepoRoot()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
readapi.PrimeBuildConfig()
|
||||
cache.SetRedis(redis.NewClient(&redis.Options{Addr: "127.0.0.1:6379"}))
|
||||
app := fiber.New(config.FiberConfig())
|
||||
app.Get("/healthz", func(c *fiber.Ctx) error {
|
||||
return c.JSON(fiber.Map{"status": "ok", "service": "api", "version": config.Version})
|
||||
})
|
||||
app.Get("/readyz", func(c *fiber.Ctx) error {
|
||||
return c.JSON(fiber.Map{"status": "ok", "db": "up", "redis": "up"})
|
||||
})
|
||||
route.Routes(app, nil)
|
||||
|
||||
artifacts, err := readapi.BuildArtifactsFromApp(app)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
files := map[string][]byte{
|
||||
filepath.Join(root, "docs", "openapi", "read-api.json"): artifacts.OpenAPIJSON,
|
||||
filepath.Join(root, "docs", "openapi", "read-api.yaml"): artifacts.OpenAPIYAML,
|
||||
filepath.Join(root, "docs", "postman", "read-api.collection.json"): artifacts.PostmanCollectionJSON,
|
||||
filepath.Join(root, "docs", "postman", "read-api.environment.json"): artifacts.PostmanEnvironmentJSON,
|
||||
}
|
||||
|
||||
for path, body := range files {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := os.WriteFile(path, body, 0o644); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Printf("wrote %s\n", path)
|
||||
}
|
||||
}
|
||||
|
||||
func findRepoRoot() (string, error) {
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
current := wd
|
||||
for {
|
||||
if _, err := os.Stat(filepath.Join(current, "go.mod")); err == nil {
|
||||
return current, nil
|
||||
}
|
||||
parent := filepath.Dir(current)
|
||||
if parent == current {
|
||||
return "", fmt.Errorf("go.mod not found from %s", wd)
|
||||
}
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
@@ -1,380 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/config"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/database"
|
||||
recordingRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/repositories"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const metricEpsilon = 1e-9
|
||||
|
||||
type normalizeOptions struct {
|
||||
Apply bool
|
||||
RecordingID uint
|
||||
ProjectFlockKandangID uint
|
||||
From *time.Time
|
||||
To *time.Time
|
||||
BatchSize int
|
||||
Limit int
|
||||
}
|
||||
|
||||
type normalizeStats struct {
|
||||
Processed int
|
||||
Changed int
|
||||
Updated int
|
||||
Skipped int
|
||||
Failed int
|
||||
}
|
||||
|
||||
type recordingMetricRow struct {
|
||||
ID uint `gorm:"column:id"`
|
||||
ProjectFlockKandangID uint `gorm:"column:project_flock_kandangs_id"`
|
||||
RecordDatetime time.Time `gorm:"column:record_datetime"`
|
||||
HenHouse *float64 `gorm:"column:hen_house"`
|
||||
EggMass *float64 `gorm:"column:egg_mass"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
var (
|
||||
apply bool
|
||||
recordingID uint
|
||||
projectFlockKandangID uint
|
||||
fromRaw string
|
||||
toRaw string
|
||||
batchSize int
|
||||
limit int
|
||||
)
|
||||
|
||||
flag.BoolVar(&apply, "apply", false, "Apply update. If false, run as dry-run")
|
||||
flag.UintVar(&recordingID, "recording-id", 0, "Target a single recording ID")
|
||||
flag.UintVar(&projectFlockKandangID, "project-flock-kandang-id", 0, "Filter by project_flock_kandangs_id")
|
||||
flag.StringVar(&fromRaw, "from", "", "Lower bound record_datetime (RFC3339 / YYYY-MM-DD)")
|
||||
flag.StringVar(&toRaw, "to", "", "Upper bound record_datetime (RFC3339 / YYYY-MM-DD)")
|
||||
flag.IntVar(&batchSize, "batch-size", 200, "Batch size when scanning recordings")
|
||||
flag.IntVar(&limit, "limit", 0, "Max recordings to process (0 = no limit)")
|
||||
flag.Parse()
|
||||
|
||||
if batchSize <= 0 {
|
||||
log.Fatal("--batch-size must be > 0")
|
||||
}
|
||||
if limit < 0 {
|
||||
log.Fatal("--limit cannot be negative")
|
||||
}
|
||||
|
||||
from, err := parseTimeBound(strings.TrimSpace(fromRaw), false)
|
||||
if err != nil {
|
||||
log.Fatalf("invalid --from: %v", err)
|
||||
}
|
||||
to, err := parseTimeBound(strings.TrimSpace(toRaw), true)
|
||||
if err != nil {
|
||||
log.Fatalf("invalid --to: %v", err)
|
||||
}
|
||||
if from != nil && to != nil && to.Before(*from) {
|
||||
log.Fatal("--to cannot be before --from")
|
||||
}
|
||||
|
||||
opts := normalizeOptions{
|
||||
Apply: apply,
|
||||
RecordingID: recordingID,
|
||||
ProjectFlockKandangID: projectFlockKandangID,
|
||||
From: from,
|
||||
To: to,
|
||||
BatchSize: batchSize,
|
||||
Limit: limit,
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
db := database.Connect(config.DBHost, config.DBName)
|
||||
repo := recordingRepo.NewRecordingRepository(db)
|
||||
|
||||
fmt.Printf("Mode: %s\n", modeLabel(opts.Apply))
|
||||
fmt.Printf("Filter recording_id: %s\n", displayUint(opts.RecordingID))
|
||||
fmt.Printf("Filter project_flock_kandangs_id: %s\n", displayUint(opts.ProjectFlockKandangID))
|
||||
fmt.Printf("Filter from: %s\n", displayTime(opts.From))
|
||||
fmt.Printf("Filter to: %s\n", displayTime(opts.To))
|
||||
fmt.Printf("Batch size: %d\n", opts.BatchSize)
|
||||
fmt.Printf("Limit: %d\n\n", opts.Limit)
|
||||
|
||||
stats, err := normalizeRecordings(ctx, db, repo, opts)
|
||||
if err != nil {
|
||||
log.Fatalf("normalize failed: %v", err)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Printf(
|
||||
"Summary: processed=%d changed=%d updated=%d skipped=%d failed=%d\n",
|
||||
stats.Processed,
|
||||
stats.Changed,
|
||||
stats.Updated,
|
||||
stats.Skipped,
|
||||
stats.Failed,
|
||||
)
|
||||
|
||||
if stats.Failed > 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeRecordings(
|
||||
ctx context.Context,
|
||||
db *gorm.DB,
|
||||
repo recordingRepo.RecordingRepository,
|
||||
opts normalizeOptions,
|
||||
) (normalizeStats, error) {
|
||||
stats := normalizeStats{}
|
||||
lastID := uint(0)
|
||||
initialChickCache := make(map[uint]float64)
|
||||
|
||||
for {
|
||||
batchLimit := opts.BatchSize
|
||||
if opts.Limit > 0 {
|
||||
remaining := opts.Limit - stats.Processed
|
||||
if remaining <= 0 {
|
||||
break
|
||||
}
|
||||
if remaining < batchLimit {
|
||||
batchLimit = remaining
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := loadRecordingBatch(ctx, db, opts, lastID, batchLimit)
|
||||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
stats.Processed++
|
||||
lastID = row.ID
|
||||
|
||||
initialChick, ok := initialChickCache[row.ProjectFlockKandangID]
|
||||
if !ok {
|
||||
initialChick, err = repo.GetTotalChickinByProjectFlockKandang(db.WithContext(ctx), row.ProjectFlockKandangID)
|
||||
if err != nil {
|
||||
fmt.Printf("FAIL rec=%d error=getTotalChickinByProjectFlockKandang: %v\n", row.ID, err)
|
||||
stats.Failed++
|
||||
continue
|
||||
}
|
||||
initialChickCache[row.ProjectFlockKandangID] = initialChick
|
||||
}
|
||||
|
||||
_, totalEggWeightGrams, err := repo.GetEggSummaryByRecording(db.WithContext(ctx), row.ID)
|
||||
if err != nil {
|
||||
fmt.Printf("FAIL rec=%d error=getEggSummaryByRecording: %v\n", row.ID, err)
|
||||
stats.Failed++
|
||||
continue
|
||||
}
|
||||
|
||||
cumulativeEggQty, err := repo.GetCumulativeEggQtyByProjectFlockKandang(db.WithContext(ctx), row.ProjectFlockKandangID, row.RecordDatetime)
|
||||
if err != nil {
|
||||
fmt.Printf("FAIL rec=%d error=getCumulativeEggQtyByProjectFlockKandang: %v\n", row.ID, err)
|
||||
stats.Failed++
|
||||
continue
|
||||
}
|
||||
|
||||
newHenHouse, newEggMass := computeNormalizedMetrics(initialChick, cumulativeEggQty, totalEggWeightGrams)
|
||||
henHouseChanged := metricChanged(row.HenHouse, newHenHouse)
|
||||
eggMassChanged := metricChanged(row.EggMass, newEggMass)
|
||||
|
||||
if !henHouseChanged && !eggMassChanged {
|
||||
stats.Skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
stats.Changed++
|
||||
fmt.Printf(
|
||||
"PLAN rec=%d pfk=%d at=%s hen_house:%s->%s egg_mass:%s->%s\n",
|
||||
row.ID,
|
||||
row.ProjectFlockKandangID,
|
||||
row.RecordDatetime.UTC().Format(time.RFC3339),
|
||||
displayFloat(row.HenHouse),
|
||||
displayFloat(newHenHouse),
|
||||
displayFloat(row.EggMass),
|
||||
displayFloat(newEggMass),
|
||||
)
|
||||
|
||||
if !opts.Apply {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := updateRecordingMetrics(ctx, db, row.ID, newHenHouse, newEggMass); err != nil {
|
||||
fmt.Printf("FAIL rec=%d error=updateRecordingMetrics: %v\n", row.ID, err)
|
||||
stats.Failed++
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Printf(
|
||||
"DONE rec=%d hen_house=%s egg_mass=%s\n",
|
||||
row.ID,
|
||||
displayFloat(newHenHouse),
|
||||
displayFloat(newEggMass),
|
||||
)
|
||||
stats.Updated++
|
||||
}
|
||||
|
||||
if opts.RecordingID > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func loadRecordingBatch(
|
||||
ctx context.Context,
|
||||
db *gorm.DB,
|
||||
opts normalizeOptions,
|
||||
lastID uint,
|
||||
limit int,
|
||||
) ([]recordingMetricRow, error) {
|
||||
query := db.WithContext(ctx).
|
||||
Table("recordings").
|
||||
Select("id, project_flock_kandangs_id, record_datetime, hen_house, egg_mass").
|
||||
Where("recordings.deleted_at IS NULL")
|
||||
|
||||
if opts.RecordingID > 0 {
|
||||
query = query.Where("recordings.id = ?", opts.RecordingID)
|
||||
}
|
||||
if opts.ProjectFlockKandangID > 0 {
|
||||
query = query.Where("recordings.project_flock_kandangs_id = ?", opts.ProjectFlockKandangID)
|
||||
}
|
||||
if opts.From != nil {
|
||||
query = query.Where("recordings.record_datetime >= ?", *opts.From)
|
||||
}
|
||||
if opts.To != nil {
|
||||
query = query.Where("recordings.record_datetime <= ?", *opts.To)
|
||||
}
|
||||
if opts.RecordingID == 0 && lastID > 0 {
|
||||
query = query.Where("recordings.id > ?", lastID)
|
||||
}
|
||||
|
||||
var rows []recordingMetricRow
|
||||
err := query.
|
||||
Order("recordings.id ASC").
|
||||
Limit(limit).
|
||||
Scan(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func computeNormalizedMetrics(initialChick, cumulativeEggQty, totalEggWeightGrams float64) (*float64, *float64) {
|
||||
var henHouse *float64
|
||||
if initialChick > 0 && cumulativeEggQty >= 0 {
|
||||
value := cumulativeEggQty / initialChick
|
||||
henHouse = &value
|
||||
}
|
||||
|
||||
var eggMass *float64
|
||||
if initialChick > 0 && totalEggWeightGrams > 0 {
|
||||
value := totalEggWeightGrams / initialChick
|
||||
eggMass = &value
|
||||
}
|
||||
|
||||
return henHouse, eggMass
|
||||
}
|
||||
|
||||
func updateRecordingMetrics(ctx context.Context, db *gorm.DB, recordingID uint, henHouse, eggMass *float64) error {
|
||||
updates := map[string]any{}
|
||||
if henHouse == nil {
|
||||
updates["hen_house"] = gorm.Expr("NULL")
|
||||
} else {
|
||||
updates["hen_house"] = *henHouse
|
||||
}
|
||||
if eggMass == nil {
|
||||
updates["egg_mass"] = gorm.Expr("NULL")
|
||||
} else {
|
||||
updates["egg_mass"] = *eggMass
|
||||
}
|
||||
|
||||
return db.WithContext(ctx).
|
||||
Table("recordings").
|
||||
Where("id = ?", recordingID).
|
||||
Updates(updates).Error
|
||||
}
|
||||
|
||||
func metricChanged(oldValue, newValue *float64) bool {
|
||||
if oldValue == nil && newValue == nil {
|
||||
return false
|
||||
}
|
||||
if oldValue == nil || newValue == nil {
|
||||
return true
|
||||
}
|
||||
return !nearlyEqual(*oldValue, *newValue)
|
||||
}
|
||||
|
||||
func nearlyEqual(a, b float64) bool {
|
||||
scale := math.Max(1, math.Max(math.Abs(a), math.Abs(b)))
|
||||
return math.Abs(a-b) <= metricEpsilon*scale
|
||||
}
|
||||
|
||||
func parseTimeBound(raw string, isUpper bool) (*time.Time, error) {
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
layouts := []string{
|
||||
time.RFC3339Nano,
|
||||
time.RFC3339,
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02",
|
||||
}
|
||||
|
||||
for _, layout := range layouts {
|
||||
parsed, err := time.Parse(layout, raw)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if layout == "2006-01-02" {
|
||||
if isUpper {
|
||||
endOfDay := time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 23, 59, 59, int(time.Second-time.Nanosecond), time.UTC)
|
||||
return &endOfDay, nil
|
||||
}
|
||||
startOfDay := time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 0, 0, 0, 0, time.UTC)
|
||||
return &startOfDay, nil
|
||||
}
|
||||
|
||||
t := parsed.UTC()
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("unsupported format %q", raw)
|
||||
}
|
||||
|
||||
func modeLabel(apply bool) string {
|
||||
if apply {
|
||||
return "APPLY"
|
||||
}
|
||||
return "DRY-RUN"
|
||||
}
|
||||
|
||||
func displayFloat(v *float64) string {
|
||||
if v == nil {
|
||||
return "NULL"
|
||||
}
|
||||
return fmt.Sprintf("%.6f", *v)
|
||||
}
|
||||
|
||||
func displayTime(v *time.Time) string {
|
||||
if v == nil {
|
||||
return "<nil>"
|
||||
}
|
||||
return v.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func displayUint(v uint) string {
|
||||
if v == 0 {
|
||||
return "<all>"
|
||||
}
|
||||
return fmt.Sprintf("%d", v)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,174 +0,0 @@
|
||||
{
|
||||
"_postman_exported_at": "2026-04-14T00:00:00Z",
|
||||
"_postman_exported_using": "Codex",
|
||||
"_postman_variable_scope": "environment",
|
||||
"id": "lti-read-api-local",
|
||||
"name": "LTI ERP Read API.local",
|
||||
"values": [
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "adjustment_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "api_key",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "area_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "bank_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "base_url",
|
||||
"value": "http://localhost:8081"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "bearer_token",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "chickin_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "customer_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "employee_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "expense_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "flock_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "idDailyChecklist",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "idProjectFlockKandang",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "initial_balance_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "injection_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "location_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "nonstock_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "payment_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "product_category_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "product_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "projectFlockId",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "project_flock_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "project_flock_kandang_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "purchase_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "recording_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "supplier_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "transaction_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "transfer_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "uniformity_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "uom_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "user_id",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"enabled": true,
|
||||
"key": "warehouse_id",
|
||||
"value": "1"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
package apikeys
|
||||
|
||||
func DefaultDashboardPermissions() []string {
|
||||
return []string{
|
||||
"lti.approval.list",
|
||||
"lti.closing.list",
|
||||
"lti.closing.detail",
|
||||
"lti.daily_checklist.create",
|
||||
"lti.daily_checklist.dashboard.list",
|
||||
"lti.daily_checklist.detail",
|
||||
"lti.daily_checklist.list",
|
||||
"lti.daily_checklist.master_data.activity",
|
||||
"lti.daily_checklist.master_data.configuration",
|
||||
"lti.daily_checklist.master_data.employee",
|
||||
"lti.daily_checklist.reports",
|
||||
"lti.dashboard.list",
|
||||
"lti.expense.detail",
|
||||
"lti.expense.list",
|
||||
"lti.finance.initial_balances.detail",
|
||||
"lti.finance.injections.detail",
|
||||
"lti.finance.payments.detail",
|
||||
"lti.finance.transactions.detail",
|
||||
"lti.finance.transactions.list",
|
||||
"lti.inventory.detail",
|
||||
"lti.inventory.list",
|
||||
"lti.inventory.product_stock.detail",
|
||||
"lti.inventory.product_stock.list",
|
||||
"lti.inventory.product_warehouses.detail",
|
||||
"lti.inventory.product_warehouses.list",
|
||||
"lti.inventory.transfer.detail",
|
||||
"lti.inventory.transfer.list",
|
||||
"lti.marketing.delivery_order.detail",
|
||||
"lti.marketing.delivery_order.list",
|
||||
"lti.master.area.detail",
|
||||
"lti.master.area.list",
|
||||
"lti.master.banks.detail",
|
||||
"lti.master.banks.list",
|
||||
"lti.master.customer.detail",
|
||||
"lti.master.customer.list",
|
||||
"lti.master.fcr.detail",
|
||||
"lti.master.fcr.list",
|
||||
"lti.master.flocks.detail",
|
||||
"lti.master.flocks.list",
|
||||
"lti.master.kandangs.detail",
|
||||
"lti.master.kandangs.list",
|
||||
"lti.master.locations.detail",
|
||||
"lti.master.locations.list",
|
||||
"lti.master.nonstocks.detail",
|
||||
"lti.master.nonstocks.list",
|
||||
"lti.master.product_categories.detail",
|
||||
"lti.master.product_categories.list",
|
||||
"lti.master.products.detail",
|
||||
"lti.master.products.list",
|
||||
"lti.master.production_standards.detail",
|
||||
"lti.master.production_standards.list",
|
||||
"lti.master.suppliers.detail",
|
||||
"lti.master.suppliers.list",
|
||||
"lti.master.uoms.detail",
|
||||
"lti.master.uoms.list",
|
||||
"lti.master.warehouses.detail",
|
||||
"lti.master.warehouses.list",
|
||||
"lti.production.chickins.detail",
|
||||
"lti.production.project_flock_kandangs.closing.detail",
|
||||
"lti.production.project_flock_kandangs.detail",
|
||||
"lti.production.project_flock_kandangs.list",
|
||||
"lti.production.project_flocks.detail",
|
||||
"lti.production.project_flocks.list",
|
||||
"lti.production.project_flocks.lookup",
|
||||
"lti.production.project_flocks.next_period",
|
||||
"lti.production.recording.detail",
|
||||
"lti.production.recording.list",
|
||||
"lti.production.recording.next_day",
|
||||
"lti.production.transfer_to_laying.create",
|
||||
"lti.production.transfer_to_laying.detail",
|
||||
"lti.production.transfer_to_laying.getavailableqty",
|
||||
"lti.production.transfer_to_laying.list",
|
||||
"lti.production.uniformity.detail",
|
||||
"lti.production.uniformity.list",
|
||||
"lti.purchase.detail",
|
||||
"lti.purchase.list",
|
||||
"lti.repport.customerpayment.list",
|
||||
"lti.repport.debtsupplier.list",
|
||||
"lti.repport.delivery.list",
|
||||
"lti.repport.expense.list",
|
||||
"lti.repport.gethppperkandang.list",
|
||||
"lti.repport.production_result.list",
|
||||
"lti.repport.purchasesupplier.list",
|
||||
"lti.users.detail",
|
||||
"lti.users.list",
|
||||
"lti.daily_checklist.master_data.kandang",
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
package apikeys
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
Create(ctx context.Context, record *entity.IntegrationAPIKey) error
|
||||
GetByEnvironmentAndPrefix(ctx context.Context, environment, prefix string) (*entity.IntegrationAPIKey, error)
|
||||
List(ctx context.Context, environment string) ([]entity.IntegrationAPIKey, error)
|
||||
Revoke(ctx context.Context, environment, prefix string, revokedAt time.Time) error
|
||||
TouchLastUsed(ctx context.Context, id uint, usedAt time.Time, usedFrom string) error
|
||||
}
|
||||
|
||||
type repository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewRepository(db *gorm.DB) Repository {
|
||||
return &repository{db: db}
|
||||
}
|
||||
|
||||
func (r *repository) Create(ctx context.Context, record *entity.IntegrationAPIKey) error {
|
||||
if r.db == nil {
|
||||
return errors.New("database not configured")
|
||||
}
|
||||
return r.db.WithContext(ctx).Create(record).Error
|
||||
}
|
||||
|
||||
func (r *repository) GetByEnvironmentAndPrefix(ctx context.Context, environment, prefix string) (*entity.IntegrationAPIKey, error) {
|
||||
if r.db == nil {
|
||||
return nil, errors.New("database not configured")
|
||||
}
|
||||
|
||||
var record entity.IntegrationAPIKey
|
||||
if err := r.db.WithContext(ctx).
|
||||
Where("environment = ?", environment).
|
||||
Where("key_prefix = ?", prefix).
|
||||
First(&record).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
func (r *repository) List(ctx context.Context, environment string) ([]entity.IntegrationAPIKey, error) {
|
||||
if r.db == nil {
|
||||
return nil, errors.New("database not configured")
|
||||
}
|
||||
|
||||
query := r.db.WithContext(ctx).Model(&entity.IntegrationAPIKey{})
|
||||
if environment != "" {
|
||||
query = query.Where("environment = ?", environment)
|
||||
}
|
||||
|
||||
var records []entity.IntegrationAPIKey
|
||||
if err := query.Order("environment ASC").Order("name ASC").Find(&records).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func (r *repository) Revoke(ctx context.Context, environment, prefix string, revokedAt time.Time) error {
|
||||
if r.db == nil {
|
||||
return errors.New("database not configured")
|
||||
}
|
||||
|
||||
updates := map[string]any{
|
||||
"status": entity.IntegrationAPIKeyStatusRevoked,
|
||||
"revoked_at": revokedAt,
|
||||
"updated_at": revokedAt,
|
||||
}
|
||||
|
||||
result := r.db.WithContext(ctx).
|
||||
Model(&entity.IntegrationAPIKey{}).
|
||||
Where("environment = ?", environment).
|
||||
Where("key_prefix = ?", prefix).
|
||||
Updates(updates)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *repository) TouchLastUsed(ctx context.Context, id uint, usedAt time.Time, usedFrom string) error {
|
||||
if r.db == nil {
|
||||
return errors.New("database not configured")
|
||||
}
|
||||
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&entity.IntegrationAPIKey{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]any{
|
||||
"last_used_at": usedAt,
|
||||
"last_used_from": usedFrom,
|
||||
"updated_at": usedAt,
|
||||
}).Error
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
package apikeys
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base32"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"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/secure"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidAPIKey = errors.New("invalid api key")
|
||||
ErrInactiveKey = errors.New("inactive api key")
|
||||
)
|
||||
|
||||
type Principal struct {
|
||||
ID uint
|
||||
Name string
|
||||
Environment string
|
||||
Permissions []string
|
||||
AllArea bool
|
||||
AreaIDs []uint
|
||||
AllLocation bool
|
||||
LocationIDs []uint
|
||||
}
|
||||
|
||||
type Authenticator interface {
|
||||
Authenticate(ctx context.Context, rawKey, source string) (*Principal, error)
|
||||
}
|
||||
|
||||
type Service interface {
|
||||
Authenticator
|
||||
Create(ctx context.Context, input CreateInput) (*IssuedKey, error)
|
||||
List(ctx context.Context, environment string) ([]entity.IntegrationAPIKey, error)
|
||||
Revoke(ctx context.Context, environment, prefix string) error
|
||||
}
|
||||
|
||||
type CreateInput struct {
|
||||
Name string
|
||||
Environment string
|
||||
PermissionCodes []string
|
||||
AllArea bool
|
||||
AreaIDs []uint
|
||||
AllLocation bool
|
||||
LocationIDs []uint
|
||||
}
|
||||
|
||||
type IssuedKey struct {
|
||||
Key string
|
||||
Record *entity.IntegrationAPIKey
|
||||
}
|
||||
|
||||
type service struct {
|
||||
repo Repository
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewService(db *gorm.DB) Service {
|
||||
return &service{
|
||||
repo: NewRepository(db),
|
||||
now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *service) Authenticate(ctx context.Context, rawKey, source string) (*Principal, error) {
|
||||
environment, prefix, secret, err := parseRawKey(rawKey)
|
||||
if err != nil {
|
||||
return nil, ErrInvalidAPIKey
|
||||
}
|
||||
|
||||
record, err := s.repo.GetByEnvironmentAndPrefix(ctx, environment, prefix)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrInvalidAPIKey
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !strings.EqualFold(record.Status, entity.IntegrationAPIKeyStatusActive) || record.RevokedAt != nil {
|
||||
return nil, ErrInactiveKey
|
||||
}
|
||||
if !secure.Verify(record.KeyHash, secret) {
|
||||
return nil, ErrInvalidAPIKey
|
||||
}
|
||||
|
||||
usedAt := s.now().UTC()
|
||||
if err := s.repo.TouchLastUsed(ctx, record.ID, usedAt, strings.TrimSpace(source)); err != nil {
|
||||
utils.Log.WithError(err).Warn("api key: failed to update last_used fields")
|
||||
}
|
||||
|
||||
return &Principal{
|
||||
ID: record.ID,
|
||||
Name: record.Name,
|
||||
Environment: record.Environment,
|
||||
Permissions: canonicalPermissions(record.PermissionCodes),
|
||||
AllArea: record.AllArea,
|
||||
AreaIDs: uniqueUint(record.AreaIDs),
|
||||
AllLocation: record.AllLocation,
|
||||
LocationIDs: uniqueUint(record.LocationIDs),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *service) Create(ctx context.Context, input CreateInput) (*IssuedKey, error) {
|
||||
name := strings.TrimSpace(input.Name)
|
||||
environment := strings.ToLower(strings.TrimSpace(input.Environment))
|
||||
if name == "" || environment == "" {
|
||||
return nil, fmt.Errorf("name and environment are required")
|
||||
}
|
||||
|
||||
prefix, err := randomToken(10)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
secret, err := randomToken(24)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
hash, err := secure.Hash(secret, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
record := &entity.IntegrationAPIKey{
|
||||
Name: name,
|
||||
Environment: environment,
|
||||
Status: entity.IntegrationAPIKeyStatusActive,
|
||||
KeyPrefix: prefix,
|
||||
KeyHash: hash,
|
||||
PermissionCodes: canonicalPermissions(input.PermissionCodes),
|
||||
AllArea: input.AllArea,
|
||||
AreaIDs: uniqueUint(input.AreaIDs),
|
||||
AllLocation: input.AllLocation,
|
||||
LocationIDs: uniqueUint(input.LocationIDs),
|
||||
}
|
||||
|
||||
if err := s.repo.Create(ctx, record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &IssuedKey{
|
||||
Key: fmt.Sprintf("lti_%s_%s_%s", environment, prefix, secret),
|
||||
Record: record,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *service) List(ctx context.Context, environment string) ([]entity.IntegrationAPIKey, error) {
|
||||
return s.repo.List(ctx, strings.ToLower(strings.TrimSpace(environment)))
|
||||
}
|
||||
|
||||
func (s *service) Revoke(ctx context.Context, environment, prefix string) error {
|
||||
environment = strings.ToLower(strings.TrimSpace(environment))
|
||||
prefix = strings.TrimSpace(prefix)
|
||||
if environment == "" || prefix == "" {
|
||||
return fmt.Errorf("environment and prefix are required")
|
||||
}
|
||||
return s.repo.Revoke(ctx, environment, prefix, s.now().UTC())
|
||||
}
|
||||
|
||||
func parseRawKey(rawKey string) (environment string, prefix string, secret string, err error) {
|
||||
rawKey = strings.TrimSpace(rawKey)
|
||||
parts := strings.Split(rawKey, "_")
|
||||
if len(parts) != 4 || parts[0] != "lti" {
|
||||
return "", "", "", ErrInvalidAPIKey
|
||||
}
|
||||
|
||||
environment = strings.ToLower(strings.TrimSpace(parts[1]))
|
||||
prefix = strings.TrimSpace(parts[2])
|
||||
secret = strings.TrimSpace(parts[3])
|
||||
if environment == "" || prefix == "" || secret == "" {
|
||||
return "", "", "", ErrInvalidAPIKey
|
||||
}
|
||||
|
||||
return environment, prefix, secret, nil
|
||||
}
|
||||
|
||||
func randomToken(size int) (string, error) {
|
||||
buf := make([]byte, size)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
encoder := base32.StdEncoding.WithPadding(base32.NoPadding)
|
||||
return strings.ToLower(encoder.EncodeToString(buf)), nil
|
||||
}
|
||||
|
||||
func canonicalPermissions(perms []string) []string {
|
||||
if len(perms) == 0 {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(perms))
|
||||
result := make([]string, 0, len(perms))
|
||||
for _, perm := range perms {
|
||||
perm = strings.ToLower(strings.TrimSpace(perm))
|
||||
if perm == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[perm]; ok {
|
||||
continue
|
||||
}
|
||||
seen[perm] = struct{}{}
|
||||
result = append(result, perm)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func uniqueUint(values []uint) []uint {
|
||||
if len(values) == 0 {
|
||||
return []uint{}
|
||||
}
|
||||
|
||||
seen := make(map[uint]struct{}, len(values))
|
||||
result := make([]uint, 0, len(values))
|
||||
for _, value := range values {
|
||||
if value == 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -23,7 +23,6 @@ type SSOClientConfig struct {
|
||||
|
||||
var (
|
||||
IsProd bool
|
||||
AppEnv string
|
||||
AppHost string
|
||||
Version string
|
||||
LogLevel string
|
||||
@@ -85,8 +84,7 @@ func init() {
|
||||
loadConfig()
|
||||
|
||||
// server configuration
|
||||
AppEnv = defaultString(strings.TrimSpace(viper.GetString("APP_ENV")), "development")
|
||||
IsProd = AppEnv == "prod"
|
||||
IsProd = viper.GetString("APP_ENV") == "prod"
|
||||
AppHost = viper.GetString("APP_HOST")
|
||||
AppPort = viper.GetInt("APP_PORT")
|
||||
Version = viper.GetString("VERSION")
|
||||
@@ -113,7 +111,7 @@ func init() {
|
||||
// Cors
|
||||
CORSAllowOrigins = parseList("CORS_ALLOW_ORIGINS")
|
||||
CORSAllowMethods = parseListWithDefault("CORS_ALLOW_METHODS", "GET,POST,PUT,PATCH,DELETE,OPTIONS")
|
||||
CORSAllowHeaders = parseListWithDefault("CORS_ALLOW_HEADERS", "Content-Type,Authorization,X-API-Key,X-Requested-With")
|
||||
CORSAllowHeaders = parseListWithDefault("CORS_ALLOW_HEADERS", "Content-Type,Authorization,X-Requested-With")
|
||||
CORSExposeHeaders = parseList("CORS_EXPOSE_HEADERS")
|
||||
CORSAllowCredentials = viper.GetBool("CORS_ALLOW_CREDENTIALS")
|
||||
CORSMaxAge = viper.GetInt("CORS_MAX_AGE")
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
-- Remove convertion fields from marketing_delivery_products table
|
||||
ALTER TABLE marketing_delivery_products
|
||||
DROP COLUMN IF EXISTS weight_per_convertion;
|
||||
@@ -1,4 +0,0 @@
|
||||
-- Add convertion fields to marketing_delivery_products table
|
||||
ALTER TABLE marketing_delivery_products
|
||||
ADD COLUMN IF NOT EXISTS weight_per_convertion NUMERIC(15, 3);
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
DROP TABLE IF EXISTS integration_api_keys;
|
||||
@@ -1,23 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS integration_api_keys (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
environment VARCHAR(50) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'active',
|
||||
key_prefix VARCHAR(64) NOT NULL,
|
||||
key_hash TEXT NOT NULL,
|
||||
permission_codes JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
all_area BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
area_ids JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
all_location BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
location_ids JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
last_used_at TIMESTAMPTZ NULL,
|
||||
last_used_from VARCHAR(128) NULL,
|
||||
revoked_at TIMESTAMPTZ NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ NULL,
|
||||
CONSTRAINT uq_integration_api_keys_environment_prefix UNIQUE (environment, key_prefix)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_integration_api_keys_status ON integration_api_keys (status);
|
||||
CREATE INDEX idx_integration_api_keys_deleted_at ON integration_api_keys (deleted_at);
|
||||
@@ -1,36 +0,0 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
IntegrationAPIKeyStatusActive = "active"
|
||||
IntegrationAPIKeyStatusRevoked = "revoked"
|
||||
)
|
||||
|
||||
type IntegrationAPIKey struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Name string `gorm:"type:varchar(100);not null"`
|
||||
Environment string `gorm:"type:varchar(50);not null;uniqueIndex:idx_integration_api_keys_env_prefix,priority:1"`
|
||||
Status string `gorm:"type:varchar(20);not null;default:active;index"`
|
||||
KeyPrefix string `gorm:"type:varchar(64);not null;uniqueIndex:idx_integration_api_keys_env_prefix,priority:2"`
|
||||
KeyHash string `gorm:"type:text;not null"`
|
||||
PermissionCodes []string `gorm:"type:jsonb;serializer:json;not null"`
|
||||
AllArea bool `gorm:"not null;default:false"`
|
||||
AreaIDs []uint `gorm:"type:jsonb;serializer:json;not null"`
|
||||
AllLocation bool `gorm:"not null;default:false"`
|
||||
LocationIDs []uint `gorm:"type:jsonb;serializer:json;not null"`
|
||||
LastUsedAt *time.Time
|
||||
LastUsedFrom string `gorm:"type:varchar(128)"`
|
||||
RevokedAt *time.Time
|
||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
func (IntegrationAPIKey) TableName() string {
|
||||
return "integration_api_keys"
|
||||
}
|
||||
@@ -12,7 +12,6 @@ type MarketingDeliveryProduct struct {
|
||||
UnitPrice float64 `gorm:"type:numeric(15,3)"`
|
||||
TotalWeight float64 `gorm:"type:numeric(15,3)"`
|
||||
AvgWeight float64 `gorm:"type:numeric(15,3)"`
|
||||
WeightPerConvertion *float64 `gorm:"type:numeric(15,3)"`
|
||||
TotalPrice float64 `gorm:"type:numeric(15,3)"`
|
||||
DeliveryDate *time.Time `gorm:"type:timestamptz"`
|
||||
VehicleNumber string `gorm:"type:varchar(50)"`
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/apikeys"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/config"
|
||||
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session"
|
||||
@@ -21,21 +17,11 @@ const (
|
||||
authUserLocalsKey = "auth.user"
|
||||
)
|
||||
|
||||
var (
|
||||
verifyAccessTokenFunc = sso.VerifyAccessToken
|
||||
fetchProfileFunc = sso.FetchProfile
|
||||
|
||||
apiKeyAuthMu sync.RWMutex
|
||||
apiKeyAuthenticator apikeys.Authenticator
|
||||
)
|
||||
|
||||
// AuthContext keeps authentication details captured by the middleware.
|
||||
type AuthContext struct {
|
||||
Token string
|
||||
Verification *sso.VerificationResult
|
||||
User *entity.User
|
||||
PrincipalType string
|
||||
PrincipalName string
|
||||
Roles []sso.Role
|
||||
Permissions map[string]struct{}
|
||||
UserAreaIDs []uint
|
||||
@@ -44,13 +30,6 @@ type AuthContext struct {
|
||||
UserAllLocation bool
|
||||
}
|
||||
|
||||
func SetAPIKeyAuthenticator(authenticator apikeys.Authenticator) {
|
||||
apiKeyAuthMu.Lock()
|
||||
defer apiKeyAuthMu.Unlock()
|
||||
|
||||
apiKeyAuthenticator = authenticator
|
||||
}
|
||||
|
||||
// Auth validates the incoming request against the central SSO access token and
|
||||
// loads the corresponding local user. Optional scopes can be provided to enforce
|
||||
// fine-grained authorization using the SSO access token scopes.
|
||||
@@ -83,20 +62,10 @@ func Auth(userService service.UserService, requiredScopes ...string) fiber.Handl
|
||||
}
|
||||
}
|
||||
if token == "" {
|
||||
if c.Method() == fiber.MethodGet {
|
||||
if err := authenticateAPIKey(c); err == nil {
|
||||
if len(requiredScopes) > 0 {
|
||||
return fiber.NewError(fiber.StatusForbidden, "Insufficient scope")
|
||||
}
|
||||
return c.Next()
|
||||
} else if err != nil && !errors.Is(err, apikeys.ErrInvalidAPIKey) && !errors.Is(err, apikeys.ErrInactiveKey) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate")
|
||||
}
|
||||
|
||||
verification, err := verifyAccessTokenFunc(token)
|
||||
verification, err := sso.VerifyAccessToken(token)
|
||||
if err != nil {
|
||||
if sso.IsSignatureError(err) {
|
||||
logSignatureError("auth", tokenSource, token, err)
|
||||
@@ -130,7 +99,7 @@ func Auth(userService service.UserService, requiredScopes ...string) fiber.Handl
|
||||
permissions := make(map[string]struct{})
|
||||
var profile *sso.UserProfile
|
||||
if verification.UserID != 0 {
|
||||
if p, err := fetchProfileFunc(c.Context(), token, verification); err != nil {
|
||||
if p, err := sso.FetchProfile(c.Context(), token, verification); err != nil {
|
||||
utils.Log.WithError(err).Warn("auth: failed to fetch sso profile")
|
||||
} else {
|
||||
profile = p
|
||||
@@ -149,8 +118,6 @@ func Auth(userService service.UserService, requiredScopes ...string) fiber.Handl
|
||||
Token: token,
|
||||
Verification: verification,
|
||||
User: user,
|
||||
PrincipalType: "user",
|
||||
PrincipalName: user.Name,
|
||||
Roles: roles,
|
||||
Permissions: permissions,
|
||||
UserAreaIDs: nil,
|
||||
@@ -252,57 +219,6 @@ func bearerToken(c *fiber.Ctx) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func authenticateAPIKey(c *fiber.Ctx) error {
|
||||
rawKey := strings.TrimSpace(c.Get("X-API-Key"))
|
||||
if rawKey == "" {
|
||||
return apikeys.ErrInvalidAPIKey
|
||||
}
|
||||
|
||||
authenticator := currentAPIKeyAuthenticator()
|
||||
if authenticator == nil {
|
||||
return apikeys.ErrInvalidAPIKey
|
||||
}
|
||||
|
||||
principal, err := authenticator.Authenticate(context.Background(), rawKey, c.IP())
|
||||
if err != nil {
|
||||
if errors.Is(err, apikeys.ErrInvalidAPIKey) || errors.Is(err, apikeys.ErrInactiveKey) {
|
||||
return apikeys.ErrInvalidAPIKey
|
||||
}
|
||||
utils.Log.WithError(err).Warn("auth: api key authentication failed")
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to authenticate request")
|
||||
}
|
||||
|
||||
permissions := make(map[string]struct{}, len(principal.Permissions))
|
||||
for _, perm := range principal.Permissions {
|
||||
if canonical := canonicalPermission(perm); canonical != "" {
|
||||
permissions[canonical] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
c.Locals(authContextLocalsKey, &AuthContext{
|
||||
Token: "",
|
||||
Verification: nil,
|
||||
User: nil,
|
||||
PrincipalType: "api_key",
|
||||
PrincipalName: principal.Name,
|
||||
Roles: nil,
|
||||
Permissions: permissions,
|
||||
UserAreaIDs: principal.AreaIDs,
|
||||
UserLocationIDs: principal.LocationIDs,
|
||||
UserAllArea: principal.AllArea,
|
||||
UserAllLocation: principal.AllLocation,
|
||||
})
|
||||
c.Locals(authUserLocalsKey, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func currentAPIKeyAuthenticator() apikeys.Authenticator {
|
||||
apiKeyAuthMu.RLock()
|
||||
defer apiKeyAuthMu.RUnlock()
|
||||
|
||||
return apiKeyAuthenticator
|
||||
}
|
||||
|
||||
func hasAllScopes(have, required []string) bool {
|
||||
if len(required) == 0 {
|
||||
return true
|
||||
|
||||
@@ -1,239 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/apikeys"
|
||||
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||
sso "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/verifier"
|
||||
userValidation "gitlab.com/mbugroup/lti-api.git/internal/modules/users/validations"
|
||||
)
|
||||
|
||||
type stubUserService struct {
|
||||
user *entity.User
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *stubUserService) GetAll(_ *fiber.Ctx, _ *userValidation.Query) ([]entity.User, int64, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
func (s *stubUserService) GetOne(_ *fiber.Ctx, _ uint) (*entity.User, error) {
|
||||
return s.user, s.err
|
||||
}
|
||||
|
||||
func (s *stubUserService) CreateOne(_ *fiber.Ctx, _ *userValidation.Create) (*entity.User, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *stubUserService) UpdateOne(_ *fiber.Ctx, _ *userValidation.Update, _ uint) (*entity.User, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *stubUserService) DeleteOne(_ *fiber.Ctx, _ uint) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubUserService) GetBySSOUserID(_ *fiber.Ctx, _ uint) (*entity.User, error) {
|
||||
return s.user, s.err
|
||||
}
|
||||
|
||||
type stubAPIKeyAuthenticator struct {
|
||||
principal *apikeys.Principal
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *stubAPIKeyAuthenticator) Authenticate(_ context.Context, _ string, _ string) (*apikeys.Principal, error) {
|
||||
return s.principal, s.err
|
||||
}
|
||||
|
||||
func TestAuthAllowsAPIKeyOnGet(t *testing.T) {
|
||||
SetAPIKeyAuthenticator(&stubAPIKeyAuthenticator{
|
||||
principal: &apikeys.Principal{
|
||||
Name: "dashboard",
|
||||
Permissions: []string{"perm.read"},
|
||||
LocationIDs: []uint{3, 5},
|
||||
},
|
||||
})
|
||||
defer SetAPIKeyAuthenticator(nil)
|
||||
|
||||
app := fiber.New()
|
||||
app.Get("/reports", Auth(&stubUserService{}), RequirePermissions("perm.read"), func(c *fiber.Ctx) error {
|
||||
scope, err := ResolveLocationScope(c, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.JSON(fiber.Map{
|
||||
"principal": c.Locals(authContextLocalsKey).(*AuthContext).PrincipalType,
|
||||
"restrict": scope.Restrict,
|
||||
"ids": scope.IDs,
|
||||
})
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(fiber.MethodGet, "/reports", nil)
|
||||
req.Header.Set("X-API-Key", "lti_dev_prefix_secret")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.StatusCode != fiber.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthRejectsAPIKeyOnPost(t *testing.T) {
|
||||
SetAPIKeyAuthenticator(&stubAPIKeyAuthenticator{
|
||||
principal: &apikeys.Principal{
|
||||
Name: "dashboard",
|
||||
Permissions: []string{"perm.write"},
|
||||
},
|
||||
})
|
||||
defer SetAPIKeyAuthenticator(nil)
|
||||
|
||||
app := fiber.New()
|
||||
app.Post("/reports", Auth(&stubUserService{}), RequirePermissions("perm.write"), func(c *fiber.Ctx) error {
|
||||
return c.SendStatus(fiber.StatusOK)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(fiber.MethodPost, "/reports", nil)
|
||||
req.Header.Set("X-API-Key", "lti_dev_prefix_secret")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.StatusCode != fiber.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthRejectsInvalidAPIKey(t *testing.T) {
|
||||
SetAPIKeyAuthenticator(&stubAPIKeyAuthenticator{err: apikeys.ErrInvalidAPIKey})
|
||||
defer SetAPIKeyAuthenticator(nil)
|
||||
|
||||
app := fiber.New()
|
||||
app.Get("/reports", Auth(&stubUserService{}), func(c *fiber.Ctx) error {
|
||||
return c.SendStatus(fiber.StatusOK)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(fiber.MethodGet, "/reports", nil)
|
||||
req.Header.Set("X-API-Key", "lti_dev_prefix_secret")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.StatusCode != fiber.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthRejectsInactiveAPIKey(t *testing.T) {
|
||||
SetAPIKeyAuthenticator(&stubAPIKeyAuthenticator{err: apikeys.ErrInactiveKey})
|
||||
defer SetAPIKeyAuthenticator(nil)
|
||||
|
||||
app := fiber.New()
|
||||
app.Get("/reports", Auth(&stubUserService{}), func(c *fiber.Ctx) error {
|
||||
return c.SendStatus(fiber.StatusOK)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(fiber.MethodGet, "/reports", nil)
|
||||
req.Header.Set("X-API-Key", "lti_dev_prefix_secret")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.StatusCode != fiber.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthRejectsMissingPermission(t *testing.T) {
|
||||
SetAPIKeyAuthenticator(&stubAPIKeyAuthenticator{
|
||||
principal: &apikeys.Principal{
|
||||
Name: "dashboard",
|
||||
Permissions: []string{"perm.other"},
|
||||
},
|
||||
})
|
||||
defer SetAPIKeyAuthenticator(nil)
|
||||
|
||||
app := fiber.New()
|
||||
app.Get("/reports", Auth(&stubUserService{}), RequirePermissions("perm.read"), func(c *fiber.Ctx) error {
|
||||
return c.SendStatus(fiber.StatusOK)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(fiber.MethodGet, "/reports", nil)
|
||||
req.Header.Set("X-API-Key", "lti_dev_prefix_secret")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.StatusCode != fiber.StatusForbidden {
|
||||
t.Fatalf("expected 403, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthAllowsBearerOnGet(t *testing.T) {
|
||||
previousVerify := verifyAccessTokenFunc
|
||||
previousProfile := fetchProfileFunc
|
||||
defer func() {
|
||||
verifyAccessTokenFunc = previousVerify
|
||||
fetchProfileFunc = previousProfile
|
||||
}()
|
||||
|
||||
verifyAccessTokenFunc = func(_ string) (*sso.VerificationResult, error) {
|
||||
return &sso.VerificationResult{
|
||||
UserID: 1,
|
||||
Claims: &sso.AccessTokenClaims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
IssuedAt: jwt.NewNumericDate(time.Now().UTC()),
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
fetchProfileFunc = func(_ context.Context, _ string, _ *sso.VerificationResult) (*sso.UserProfile, error) {
|
||||
return &sso.UserProfile{
|
||||
Permissions: []sso.Permission{{Name: "perm.read"}},
|
||||
LocationIDs: []uint{7},
|
||||
}, nil
|
||||
}
|
||||
|
||||
app := fiber.New()
|
||||
app.Get("/reports", Auth(&stubUserService{user: &entity.User{Id: 9, Name: "API User"}}), RequirePermissions("perm.read"), func(c *fiber.Ctx) error {
|
||||
return c.JSON(fiber.Map{"principal": c.Locals(authContextLocalsKey).(*AuthContext).PrincipalType})
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(fiber.MethodGet, "/reports", nil)
|
||||
req.Header.Set("Authorization", "Bearer test-token")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.StatusCode != fiber.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthReturnsServerErrorWhenAPIKeyVerifierFailsUnexpectedly(t *testing.T) {
|
||||
SetAPIKeyAuthenticator(&stubAPIKeyAuthenticator{err: errors.New("boom")})
|
||||
defer SetAPIKeyAuthenticator(nil)
|
||||
|
||||
app := fiber.New()
|
||||
app.Get("/reports", Auth(&stubUserService{}), func(c *fiber.Ctx) error {
|
||||
return c.SendStatus(fiber.StatusOK)
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(fiber.MethodGet, "/reports", nil)
|
||||
req.Header.Set("X-API-Key", "lti_dev_prefix_secret")
|
||||
resp, err := app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.StatusCode != fiber.StatusInternalServerError {
|
||||
t.Fatalf("expected 500, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/config"
|
||||
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/validations"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
||||
@@ -36,7 +35,6 @@ type UniformityWeeklyMetric struct {
|
||||
Week int
|
||||
Uniformity float64
|
||||
AverageWeight float64
|
||||
UniformDate time.Time
|
||||
}
|
||||
|
||||
type StandardWeeklyMetric struct {
|
||||
@@ -106,15 +104,6 @@ func applyDashboardFilters(db *gorm.DB, filters *validation.DashboardFilter) *go
|
||||
return db
|
||||
}
|
||||
|
||||
func dashboardUniformityWeekExpr() string {
|
||||
return fmt.Sprintf(`CASE
|
||||
WHEN u.uniform_date IS NULL OR pc.chick_in_date IS NULL THEN 0
|
||||
WHEN u.uniform_date::date < pc.chick_in_date THEN 0
|
||||
WHEN UPPER(pf.category) = 'LAYING' THEN (((u.uniform_date::date - pc.chick_in_date)::int) / 7) + %d
|
||||
ELSE (((u.uniform_date::date - pc.chick_in_date)::int) / 7) + 1
|
||||
END`, config.LayingWeekStart())
|
||||
}
|
||||
|
||||
func (r *DashboardRepositoryImpl) GetRecordingWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]RecordingWeeklyMetric, error) {
|
||||
var rows []RecordingWeeklyMetric
|
||||
|
||||
@@ -150,29 +139,20 @@ func (r *DashboardRepositoryImpl) GetRecordingWeeklyMetrics(ctx context.Context,
|
||||
|
||||
func (r *DashboardRepositoryImpl) GetUniformityWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]UniformityWeeklyMetric, error) {
|
||||
var rows []UniformityWeeklyMetric
|
||||
weekExpr := dashboardUniformityWeekExpr()
|
||||
|
||||
db := r.DB().WithContext(ctx).
|
||||
Table("project_flock_kandang_uniformity AS u").
|
||||
Select(fmt.Sprintf(`%s AS week,
|
||||
Select(`u.week AS week,
|
||||
COALESCE(AVG(u.uniformity), 0) AS uniformity,
|
||||
COALESCE(AVG((u.chart_data->'statistics'->>'average_weight')::numeric), 0) AS average_weight`, weekExpr)).
|
||||
COALESCE(AVG((u.chart_data->'statistics'->>'average_weight')::numeric), 0) AS average_weight`).
|
||||
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = u.project_flock_kandang_id").
|
||||
Joins("JOIN project_flocks AS pf ON pf.id = pfk.project_flock_id").
|
||||
Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id").
|
||||
Joins(`JOIN (
|
||||
SELECT project_flock_kandang_id, MIN(chick_in_date)::date AS chick_in_date
|
||||
FROM project_chickins
|
||||
WHERE deleted_at IS NULL
|
||||
GROUP BY project_flock_kandang_id
|
||||
) AS pc ON pc.project_flock_kandang_id = u.project_flock_kandang_id`).
|
||||
Where("u.uniform_date IS NOT NULL").
|
||||
Where("u.uniform_date >= ? AND u.uniform_date < ?", start, end).
|
||||
Where("u.uniform_date::date >= pc.chick_in_date")
|
||||
Where("u.uniform_date >= ? AND u.uniform_date < ?", start, end)
|
||||
|
||||
db = applyDashboardFilters(db, filters)
|
||||
|
||||
if err := db.Group("week").Order("week ASC").Scan(&rows).Error; err != nil {
|
||||
if err := db.Group("u.week").Order("u.week ASC").Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -538,31 +518,23 @@ func (r *DashboardRepositoryImpl) GetComparisonWeeklyUniformityMetrics(ctx conte
|
||||
}
|
||||
|
||||
var rows []ComparisonUniformityMetric
|
||||
weekExpr := dashboardUniformityWeekExpr()
|
||||
db := r.DB().WithContext(ctx).
|
||||
Table("project_flock_kandang_uniformity AS u").
|
||||
Select(fmt.Sprintf(`%s AS week,
|
||||
Select(fmt.Sprintf(`u.week AS week,
|
||||
%s AS series_id,
|
||||
COALESCE(AVG(u.uniformity), 0) AS uniformity,
|
||||
COALESCE(AVG((u.chart_data->'statistics'->>'average_weight')::numeric), 0) AS average_weight`, weekExpr, seriesExpr)).
|
||||
COALESCE(AVG((u.chart_data->'statistics'->>'average_weight')::numeric), 0) AS average_weight`, seriesExpr)).
|
||||
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = u.project_flock_kandang_id").
|
||||
Joins("JOIN kandangs AS k ON k.id = pfk.kandang_id").
|
||||
Joins("JOIN project_flocks AS pf ON pf.id = pfk.project_flock_id").
|
||||
Joins("JOIN locations AS loc ON loc.id = k.location_id").
|
||||
Joins(`JOIN (
|
||||
SELECT project_flock_kandang_id, MIN(chick_in_date)::date AS chick_in_date
|
||||
FROM project_chickins
|
||||
WHERE deleted_at IS NULL
|
||||
GROUP BY project_flock_kandang_id
|
||||
) AS pc ON pc.project_flock_kandang_id = u.project_flock_kandang_id`).
|
||||
Where("u.uniform_date IS NOT NULL").
|
||||
Where("u.uniform_date >= ? AND u.uniform_date < ?", start, end).
|
||||
Where("u.uniform_date::date >= pc.chick_in_date")
|
||||
Where("u.uniform_date >= ? AND u.uniform_date < ?", start, end)
|
||||
|
||||
db = applyDashboardFilters(db, filters)
|
||||
|
||||
groupBy := fmt.Sprintf("week, %s", groupExpr)
|
||||
orderBy := fmt.Sprintf("week ASC, %s", orderExpr)
|
||||
groupBy := fmt.Sprintf("u.week, %s", groupExpr)
|
||||
orderBy := fmt.Sprintf("u.week ASC, %s", orderExpr)
|
||||
if err := db.Group(groupBy).Order(orderBy).Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -265,7 +265,6 @@ func (s dashboardService) buildPerformanceCharts(ctx context.Context, params *va
|
||||
}
|
||||
|
||||
bodyWeightDataset := make([]map[string]interface{}, 0, len(weeks))
|
||||
bodyWeightDatasetIndexByWeek := make(map[int]int, len(weeks))
|
||||
performanceDataset := make([]map[string]interface{}, 0, len(weeks))
|
||||
fcrDataset := make([]map[string]interface{}, 0, len(weeks))
|
||||
deplesiDataset := make([]map[string]interface{}, 0, len(weeks))
|
||||
@@ -275,10 +274,10 @@ func (s dashboardService) buildPerformanceCharts(ctx context.Context, params *va
|
||||
cumFeed := 0.0
|
||||
|
||||
for _, week := range weeks {
|
||||
rec, hasRec := recordingMap[week]
|
||||
uni, hasUni := uniformityMap[week]
|
||||
std, hasStd := standardMap[week]
|
||||
stdFcr, hasStdFcr := standardFcrMap[week]
|
||||
rec := recordingMap[week]
|
||||
uni := uniformityMap[week]
|
||||
std := standardMap[week]
|
||||
stdFcr := standardFcrMap[week]
|
||||
weekEgg := weeklyEggMap[week]
|
||||
weekFeed := weeklyFeedMap[week]
|
||||
|
||||
@@ -294,80 +293,39 @@ func (s dashboardService) buildPerformanceCharts(ctx context.Context, params *va
|
||||
actFcrCum = cumFeed / cumEgg
|
||||
}
|
||||
|
||||
bodyWeightRow := map[string]interface{}{
|
||||
"week": week,
|
||||
}
|
||||
if hasUni {
|
||||
bodyWeightRow["body_weight"] = roundTo(uni.AverageWeight, 2)
|
||||
}
|
||||
if hasStd {
|
||||
bodyWeightRow["std_body_weight"] = roundTo(std.StdBodyWeight, 2)
|
||||
}
|
||||
if len(bodyWeightRow) > 1 {
|
||||
bodyWeightDataset = append(bodyWeightDataset, bodyWeightRow)
|
||||
}
|
||||
bodyWeightDataset = append(bodyWeightDataset, map[string]interface{}{
|
||||
"week": week,
|
||||
"body_weight": roundTo(uni.AverageWeight, 2),
|
||||
"std_body_weight": roundTo(std.StdBodyWeight, 2),
|
||||
})
|
||||
|
||||
performanceRow := map[string]interface{}{
|
||||
"week": week,
|
||||
}
|
||||
if hasRec {
|
||||
performanceRow["act_laying"] = roundTo(rec.HenDay, 2)
|
||||
performanceRow["act_egg_weight"] = roundTo(rec.EggWeight, 2)
|
||||
performanceRow["act_feed_intake"] = roundTo(rec.FeedIntake, 2)
|
||||
}
|
||||
if hasUni {
|
||||
performanceRow["act_uniformity"] = roundTo(uni.Uniformity, 2)
|
||||
}
|
||||
if hasStd {
|
||||
performanceRow["std_laying"] = roundTo(std.StdLaying, 2)
|
||||
performanceRow["std_egg_weight"] = roundTo(std.StdEggWeight, 2)
|
||||
performanceRow["std_feed_intake"] = roundTo(std.StdFeedIntake, 2)
|
||||
performanceRow["std_uniformity"] = roundTo(std.StdUniformity, 2)
|
||||
}
|
||||
if len(performanceRow) > 1 {
|
||||
performanceDataset = append(performanceDataset, performanceRow)
|
||||
}
|
||||
performanceDataset = append(performanceDataset, map[string]interface{}{
|
||||
"week": week,
|
||||
"act_laying": roundTo(rec.HenDay, 2),
|
||||
"std_laying": roundTo(std.StdLaying, 2),
|
||||
"act_egg_weight": roundTo(rec.EggWeight, 2),
|
||||
"std_egg_weight": roundTo(std.StdEggWeight, 2),
|
||||
"act_feed_intake": roundTo(rec.FeedIntake, 2),
|
||||
"std_feed_intake": roundTo(std.StdFeedIntake, 2),
|
||||
"act_uniformity": roundTo(uni.Uniformity, 2),
|
||||
"std_uniformity": roundTo(std.StdUniformity, 2),
|
||||
})
|
||||
|
||||
fcrRow := map[string]interface{}{
|
||||
"week": week,
|
||||
}
|
||||
if weekEgg > 0 && weekFeed > 0 {
|
||||
fcrRow["act_fcr"] = roundTo(actFcr, 2)
|
||||
}
|
||||
if cumEgg > 0 && cumFeed > 0 {
|
||||
fcrRow["act_fcr_cum"] = roundTo(actFcrCum, 2)
|
||||
}
|
||||
if hasStdFcr {
|
||||
fcrRow["std_fcr"] = roundTo(stdFcr, 2)
|
||||
fcrRow["std_fcr_cum"] = roundTo(stdFcr, 2)
|
||||
}
|
||||
if len(fcrRow) > 1 {
|
||||
fcrDataset = append(fcrDataset, fcrRow)
|
||||
}
|
||||
fcrDataset = append(fcrDataset, map[string]interface{}{
|
||||
"week": week,
|
||||
"act_fcr": roundTo(actFcr, 2),
|
||||
"std_fcr": roundTo(stdFcr, 2),
|
||||
"act_fcr_cum": roundTo(actFcrCum, 2),
|
||||
"std_fcr_cum": roundTo(stdFcr, 2),
|
||||
})
|
||||
|
||||
deplesiRow := map[string]interface{}{
|
||||
"week": week,
|
||||
}
|
||||
if hasRec {
|
||||
deplesiRow["act_deplesi"] = roundTo(rec.CumDepletionRate, 2)
|
||||
}
|
||||
if hasStd {
|
||||
deplesiRow["std_deplesi"] = roundTo(std.StdDepletion, 2)
|
||||
}
|
||||
if len(deplesiRow) > 1 {
|
||||
deplesiDataset = append(deplesiDataset, deplesiRow)
|
||||
}
|
||||
deplesiDataset = append(deplesiDataset, map[string]interface{}{
|
||||
"week": week,
|
||||
"act_deplesi": roundTo(rec.CumDepletionRate, 2),
|
||||
"std_deplesi": roundTo(std.StdDepletion, 2),
|
||||
})
|
||||
}
|
||||
|
||||
bodyWeightDataset = extendBodyWeightDatasetUntilEndDate(
|
||||
bodyWeightDataset,
|
||||
bodyWeightDatasetIndexByWeek,
|
||||
uniformities,
|
||||
uniformityMap,
|
||||
standardMap,
|
||||
params.PeriodEnd,
|
||||
)
|
||||
|
||||
qualityRows, err := s.Repository.GetEggQualityWeeklyMetrics(ctx, startDate, endExclusive, filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1091,69 +1049,6 @@ func (s dashboardService) avgSellingPrice(ctx context.Context, filter *validatio
|
||||
return result.TotalPrice / result.TotalWeight, nil
|
||||
}
|
||||
|
||||
func extendBodyWeightDatasetUntilEndDate(
|
||||
dataset []map[string]interface{},
|
||||
indexByWeek map[int]int,
|
||||
uniformities []repository.UniformityWeeklyMetric,
|
||||
uniformityMap map[int]repository.UniformityWeeklyMetric,
|
||||
standardMap map[int]repository.StandardWeeklyMetric,
|
||||
periodEnd time.Time,
|
||||
) []map[string]interface{} {
|
||||
latestUniformityWeek := 0
|
||||
var latestUniformityDate time.Time
|
||||
for _, row := range uniformities {
|
||||
if row.Week <= 0 || row.UniformDate.IsZero() {
|
||||
continue
|
||||
}
|
||||
if latestUniformityDate.IsZero() || row.UniformDate.After(latestUniformityDate) || (row.UniformDate.Equal(latestUniformityDate) && row.Week > latestUniformityWeek) {
|
||||
latestUniformityDate = row.UniformDate
|
||||
latestUniformityWeek = row.Week
|
||||
}
|
||||
}
|
||||
|
||||
if latestUniformityWeek <= 0 || latestUniformityDate.IsZero() || periodEnd.IsZero() || !periodEnd.After(latestUniformityDate) {
|
||||
return dataset
|
||||
}
|
||||
|
||||
additionalWeeks := int(math.Ceil(periodEnd.Sub(latestUniformityDate).Hours() / (24 * 7)))
|
||||
if additionalWeeks <= 0 {
|
||||
return dataset
|
||||
}
|
||||
|
||||
lastUniformity := uniformityMap[latestUniformityWeek]
|
||||
lastStandard := standardMap[latestUniformityWeek]
|
||||
latestBodyWeight := roundTo(lastUniformity.AverageWeight, 2)
|
||||
latestStdBodyWeight := roundTo(lastStandard.StdBodyWeight, 2)
|
||||
|
||||
targetWeek := latestUniformityWeek + additionalWeeks
|
||||
for week := latestUniformityWeek + 1; week <= targetWeek; week++ {
|
||||
row := map[string]interface{}{
|
||||
"week": week,
|
||||
"body_weight": latestBodyWeight,
|
||||
"std_body_weight": latestStdBodyWeight,
|
||||
}
|
||||
|
||||
if idx, ok := indexByWeek[week]; ok {
|
||||
dataset[idx] = row
|
||||
continue
|
||||
}
|
||||
|
||||
dataset = append(dataset, row)
|
||||
indexByWeek[week] = len(dataset) - 1
|
||||
}
|
||||
|
||||
sort.Slice(dataset, func(i, j int) bool {
|
||||
return datasetWeek(dataset[i]) < datasetWeek(dataset[j])
|
||||
})
|
||||
|
||||
return dataset
|
||||
}
|
||||
|
||||
func datasetWeek(row map[string]interface{}) int {
|
||||
week, _ := row["week"].(int)
|
||||
return week
|
||||
}
|
||||
|
||||
func feedUsageToGrams(rows []repository.FeedUsageByUom) float64 {
|
||||
total := 0.0
|
||||
for _, row := range rows {
|
||||
|
||||
@@ -49,30 +49,26 @@ type MarketingDetailDTO struct {
|
||||
}
|
||||
|
||||
type MarketingDeliveryProductDTO struct {
|
||||
Id uint `json:"id"`
|
||||
MarketingProductId uint `json:"marketing_product_id"`
|
||||
Qty float64 `json:"qty"`
|
||||
UnitPrice float64 `json:"unit_price"`
|
||||
TotalWeight float64 `json:"total_weight"`
|
||||
AvgWeight float64 `json:"avg_weight"`
|
||||
TotalPrice float64 `json:"total_price"`
|
||||
DeliveryDate *time.Time `json:"delivery_date"`
|
||||
VehicleNumber string `json:"vehicle_number"`
|
||||
ConvertionUnit *string `json:"-"`
|
||||
WeightPerConvertion *float64 `json:"-"`
|
||||
ProductWarehouse *productwarehouseDTO.ProductWarehousNestedDTO `json:"product_warehouse,omitempty"`
|
||||
Id uint `json:"id"`
|
||||
MarketingProductId uint `json:"marketing_product_id"`
|
||||
Qty float64 `json:"qty"`
|
||||
UnitPrice float64 `json:"unit_price"`
|
||||
TotalWeight float64 `json:"total_weight"`
|
||||
AvgWeight float64 `json:"avg_weight"`
|
||||
TotalPrice float64 `json:"total_price"`
|
||||
DeliveryDate *time.Time `json:"delivery_date"`
|
||||
VehicleNumber string `json:"vehicle_number"`
|
||||
ProductWarehouse *productwarehouseDTO.ProductWarehousNestedDTO `json:"product_warehouse,omitempty"`
|
||||
}
|
||||
|
||||
type DeliveryItemDTO struct {
|
||||
ProductWarehouse *productwarehouseDTO.ProductWarehousNestedDTO `json:"product_warehouse"`
|
||||
Qty float64 `json:"qty"`
|
||||
UnitPrice float64 `json:"unit_price"`
|
||||
TotalWeight float64 `json:"total_weight"`
|
||||
AvgWeight float64 `json:"avg_weight"`
|
||||
WeightPerConvertion *float64 `json:"weight_per_convertion"`
|
||||
TotalPeti *float64 `json:"total_peti"`
|
||||
TotalPrice float64 `json:"total_price"`
|
||||
VehicleNumber string `json:"vehicle_number"`
|
||||
ProductWarehouse *productwarehouseDTO.ProductWarehousNestedDTO `json:"product_warehouse"`
|
||||
Qty float64 `json:"qty"`
|
||||
UnitPrice float64 `json:"unit_price"`
|
||||
TotalWeight float64 `json:"total_weight"`
|
||||
AvgWeight float64 `json:"avg_weight"`
|
||||
TotalPrice float64 `json:"total_price"`
|
||||
VehicleNumber string `json:"vehicle_number"`
|
||||
}
|
||||
|
||||
type DeliveryGroupDTO struct {
|
||||
@@ -151,16 +147,15 @@ func ToDeliveryMarketingProductDTO(e entity.MarketingProduct, marketingType stri
|
||||
|
||||
func ToMarketingDeliveryProductDTO(e entity.MarketingDeliveryProduct) MarketingDeliveryProductDTO {
|
||||
return MarketingDeliveryProductDTO{
|
||||
Id: e.Id,
|
||||
MarketingProductId: e.MarketingProductId,
|
||||
Qty: e.UsageQty,
|
||||
UnitPrice: e.UnitPrice,
|
||||
TotalWeight: e.TotalWeight,
|
||||
AvgWeight: e.AvgWeight,
|
||||
TotalPrice: e.TotalPrice,
|
||||
DeliveryDate: e.DeliveryDate,
|
||||
VehicleNumber: e.VehicleNumber,
|
||||
WeightPerConvertion: e.WeightPerConvertion,
|
||||
Id: e.Id,
|
||||
MarketingProductId: e.MarketingProductId,
|
||||
Qty: e.UsageQty,
|
||||
UnitPrice: e.UnitPrice,
|
||||
TotalWeight: e.TotalWeight,
|
||||
AvgWeight: e.AvgWeight,
|
||||
TotalPrice: e.TotalPrice,
|
||||
DeliveryDate: e.DeliveryDate,
|
||||
VehicleNumber: e.VehicleNumber,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,7 +285,6 @@ func enrichDeliveryProductDTOsWithWarehouse(deliveryProductDTOs []MarketingDeliv
|
||||
mapped := productwarehouseDTO.ToProductWarehouseNestedDTO(product.ProductWarehouse)
|
||||
deliveryProductDTOs[i].ProductWarehouse = &mapped
|
||||
}
|
||||
deliveryProductDTOs[i].ConvertionUnit = product.ConvertionUnit
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,21 +322,13 @@ func groupDeliveryProducts(products []MarketingDeliveryProductDTO, soNumber stri
|
||||
}
|
||||
|
||||
deliveryItem := DeliveryItemDTO{
|
||||
ProductWarehouse: product.ProductWarehouse,
|
||||
Qty: product.Qty,
|
||||
UnitPrice: product.UnitPrice,
|
||||
TotalWeight: product.TotalWeight,
|
||||
AvgWeight: product.AvgWeight,
|
||||
WeightPerConvertion: product.WeightPerConvertion,
|
||||
TotalPrice: product.TotalPrice,
|
||||
VehicleNumber: product.VehicleNumber,
|
||||
}
|
||||
if product.ConvertionUnit != nil &&
|
||||
strings.EqualFold(*product.ConvertionUnit, "PETI") &&
|
||||
product.WeightPerConvertion != nil &&
|
||||
*product.WeightPerConvertion > 0 {
|
||||
totalPeti := product.TotalWeight / *product.WeightPerConvertion
|
||||
deliveryItem.TotalPeti = &totalPeti
|
||||
ProductWarehouse: product.ProductWarehouse,
|
||||
Qty: product.Qty,
|
||||
UnitPrice: product.UnitPrice,
|
||||
TotalWeight: product.TotalWeight,
|
||||
AvgWeight: product.AvgWeight,
|
||||
TotalPrice: product.TotalPrice,
|
||||
VehicleNumber: product.VehicleNumber,
|
||||
}
|
||||
group.Deliveries = append(group.Deliveries, deliveryItem)
|
||||
}
|
||||
|
||||
@@ -31,8 +31,6 @@ type MarketingDeliveryProductRepositoryImpl struct {
|
||||
*commonRepo.BaseRepositoryImpl[entity.MarketingDeliveryProduct]
|
||||
}
|
||||
|
||||
const marketingDeliveryProductSelectWithNullAttributed = "marketing_delivery_products.*, NULL AS attributed_project_flock_kandang_id"
|
||||
|
||||
func NewMarketingDeliveryProductRepository(db *gorm.DB) MarketingDeliveryProductRepository {
|
||||
return &MarketingDeliveryProductRepositoryImpl{
|
||||
BaseRepositoryImpl: commonRepo.NewBaseRepository[entity.MarketingDeliveryProduct](db),
|
||||
@@ -45,9 +43,9 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetDeliveryProductsByProjectFlo
|
||||
attributionQuery := commonRepo.MarketingDeliveryAttributionRowsQuery(r.DB().WithContext(ctx))
|
||||
|
||||
db := r.DB().WithContext(ctx).
|
||||
Select("DISTINCT "+marketingDeliveryProductSelectWithNullAttributed).
|
||||
Joins("JOIN (?) AS mda ON mda.marketing_delivery_product_id = marketing_delivery_products.id", attributionQuery).
|
||||
Where("mda.project_flock_id = ?", projectFlockID)
|
||||
Where("mda.project_flock_id = ?", projectFlockID).
|
||||
Distinct("marketing_delivery_products.*")
|
||||
|
||||
if callback != nil {
|
||||
db = callback(db)
|
||||
@@ -112,7 +110,6 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetByMarketingId(ctx context.Co
|
||||
|
||||
// JOIN untuk filter by marketing_id yang ada di related table
|
||||
db := r.DB().WithContext(ctx).
|
||||
Select(marketingDeliveryProductSelectWithNullAttributed).
|
||||
Joins("JOIN marketing_products ON marketing_products.id = marketing_delivery_products.marketing_product_id").
|
||||
Where("marketing_products.marketing_id = ?", marketingId)
|
||||
|
||||
@@ -127,8 +124,6 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetByMarketingProductID(ctx con
|
||||
var deliveryProduct entity.MarketingDeliveryProduct
|
||||
|
||||
if err := r.DB().WithContext(ctx).
|
||||
Model(&entity.MarketingDeliveryProduct{}).
|
||||
Select(marketingDeliveryProductSelectWithNullAttributed).
|
||||
Where("marketing_product_id = ?", marketingProductID).
|
||||
First(&deliveryProduct).Error; err != nil {
|
||||
return nil, err
|
||||
@@ -137,27 +132,6 @@ func (r *MarketingDeliveryProductRepositoryImpl) GetByMarketingProductID(ctx con
|
||||
return &deliveryProduct, nil
|
||||
}
|
||||
|
||||
func (r *MarketingDeliveryProductRepositoryImpl) GetByID(
|
||||
ctx context.Context,
|
||||
id uint,
|
||||
modifier func(*gorm.DB) *gorm.DB,
|
||||
) (*entity.MarketingDeliveryProduct, error) {
|
||||
var deliveryProduct entity.MarketingDeliveryProduct
|
||||
|
||||
q := r.DB().WithContext(ctx).
|
||||
Model(&entity.MarketingDeliveryProduct{}).
|
||||
Select(marketingDeliveryProductSelectWithNullAttributed)
|
||||
if modifier != nil {
|
||||
q = modifier(q)
|
||||
}
|
||||
|
||||
if err := q.First(&deliveryProduct, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &deliveryProduct, nil
|
||||
}
|
||||
|
||||
func (r *MarketingDeliveryProductRepositoryImpl) GetAttributionRowsByDeliveryProductIDs(ctx context.Context, deliveryProductIDs []uint) ([]commonRepo.MarketingDeliveryAttributionRow, error) {
|
||||
if len(deliveryProductIDs) == 0 {
|
||||
return []commonRepo.MarketingDeliveryAttributionRow{}, nil
|
||||
@@ -237,7 +211,6 @@ func (r *MarketingDeliveryProductRepositoryImpl) fetchClosingDeliveryProducts(
|
||||
}
|
||||
|
||||
query := r.closingDeliveryProductsQuery(ctx).
|
||||
Select(marketingDeliveryProductSelectWithNullAttributed).
|
||||
Where("marketing_delivery_products.id IN ?", deliveryIDs).
|
||||
Order("marketing_delivery_products.delivery_date DESC")
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -376,12 +375,11 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Delivery
|
||||
itemDeliveryDate = &parsedDate
|
||||
}
|
||||
|
||||
totalWeight, totalPrice := s.resolveDeliveryTotals(marketing.MarketingType, requestedProduct, foundMarketingProduct)
|
||||
totalWeight, totalPrice := s.calculatePriceByMarketingType(marketing.MarketingType, requestedProduct.Qty, requestedProduct.AvgWeight, requestedProduct.UnitPrice, foundMarketingProduct.Week)
|
||||
|
||||
deliveryProduct.ProductWarehouseId = foundMarketingProduct.ProductWarehouseId
|
||||
deliveryProduct.UnitPrice = requestedProduct.UnitPrice
|
||||
deliveryProduct.AvgWeight = requestedProduct.AvgWeight
|
||||
deliveryProduct.WeightPerConvertion = requestedProduct.WeightPerConvertion
|
||||
deliveryProduct.TotalWeight = totalWeight
|
||||
deliveryProduct.TotalPrice = totalPrice
|
||||
deliveryProduct.DeliveryDate = itemDeliveryDate
|
||||
@@ -500,12 +498,11 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO
|
||||
itemDeliveryDate = deliveryProduct.DeliveryDate
|
||||
}
|
||||
|
||||
totalWeight, totalPrice := s.resolveDeliveryTotals(marketing.MarketingType, requestedProduct, foundMarketingProduct)
|
||||
totalWeight, totalPrice := s.calculatePriceByMarketingType(marketing.MarketingType, requestedProduct.Qty, requestedProduct.AvgWeight, requestedProduct.UnitPrice, foundMarketingProduct.Week)
|
||||
|
||||
deliveryProduct.ProductWarehouseId = foundMarketingProduct.ProductWarehouseId
|
||||
deliveryProduct.UnitPrice = requestedProduct.UnitPrice
|
||||
deliveryProduct.AvgWeight = requestedProduct.AvgWeight
|
||||
deliveryProduct.WeightPerConvertion = requestedProduct.WeightPerConvertion
|
||||
deliveryProduct.TotalWeight = totalWeight
|
||||
deliveryProduct.TotalPrice = totalPrice
|
||||
deliveryProduct.DeliveryDate = itemDeliveryDate
|
||||
@@ -544,53 +541,20 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO
|
||||
return s.getMarketingWithDeliveries(c, id)
|
||||
}
|
||||
|
||||
func (s *deliveryOrdersService) calculatePriceByMarketingType(marketingType string, qty, avgWeight, unitPrice float64, week *int, convertionUnit *string, _ *float64) (totalWeight, totalPrice float64) {
|
||||
func (s *deliveryOrdersService) calculatePriceByMarketingType(marketingType string, qty, avgWeight, unitPrice float64, week *int) (totalWeight, totalPrice float64) {
|
||||
if marketingType == string(utils.MarketingTypeTrading) {
|
||||
totalWeight = 0
|
||||
totalPrice = math.Round(qty*unitPrice*100) / 100
|
||||
totalPrice = qty * unitPrice
|
||||
} else if marketingType == string(utils.MarketingTypeAyamPullet) && week != nil && *week > 0 {
|
||||
totalWeight = math.Round(qty*avgWeight*100) / 100
|
||||
totalPrice = math.Round(unitPrice*float64(*week)*qty*100) / 100
|
||||
totalWeight = qty * avgWeight
|
||||
totalPrice = unitPrice * float64(*week) * qty
|
||||
} else {
|
||||
totalWeight = math.Round(qty*avgWeight*100) / 100
|
||||
|
||||
if marketingType == string(utils.MarketingTypeTelur) && convertionUnit != nil {
|
||||
switch *convertionUnit {
|
||||
case string(utils.ConvertionUnitQty):
|
||||
totalPrice = math.Round(qty*unitPrice*100) / 100
|
||||
return totalWeight, totalPrice
|
||||
case string(utils.ConvertionUnitPeti):
|
||||
totalPrice = math.Round(totalWeight*unitPrice*100) / 100
|
||||
return totalWeight, totalPrice
|
||||
}
|
||||
}
|
||||
|
||||
totalPrice = math.Round(totalWeight*unitPrice*100) / 100
|
||||
totalWeight = qty * avgWeight
|
||||
totalPrice = totalWeight * unitPrice
|
||||
}
|
||||
return totalWeight, totalPrice
|
||||
}
|
||||
|
||||
func (s *deliveryOrdersService) resolveDeliveryTotals(marketingType string, requestedProduct validation.DeliveryProduct, marketingProduct *entity.MarketingProduct) (totalWeight, totalPrice float64) {
|
||||
totalWeight, totalPrice = s.calculatePriceByMarketingType(
|
||||
marketingType,
|
||||
requestedProduct.Qty,
|
||||
requestedProduct.AvgWeight,
|
||||
requestedProduct.UnitPrice,
|
||||
marketingProduct.Week,
|
||||
marketingProduct.ConvertionUnit,
|
||||
marketingProduct.WeightPerConvertion,
|
||||
)
|
||||
|
||||
if requestedProduct.TotalWeight != nil {
|
||||
totalWeight = *requestedProduct.TotalWeight
|
||||
}
|
||||
if requestedProduct.TotalPrice != nil {
|
||||
totalPrice = *requestedProduct.TotalPrice
|
||||
}
|
||||
|
||||
return totalWeight, totalPrice
|
||||
}
|
||||
|
||||
func (s deliveryOrdersService) consumeDeliveryStock(ctx context.Context, tx *gorm.DB, deliveryProduct *entity.MarketingDeliveryProduct, marketingProduct *entity.MarketingProduct, requestedQty float64, actorID uint) error {
|
||||
if marketingProduct == nil || marketingProduct.ProductWarehouseId == 0 {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "Product warehouse not found")
|
||||
|
||||
@@ -815,7 +815,7 @@ func (s *salesOrdersService) createMarketingProductWithDelivery(ctx context.Cont
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *salesOrdersService) calculatePriceByMarketingType(marketingType string, qty, avgWeight, unitPrice float64, week *int, convertionUnit *string, _ *float64) (totalWeight, totalPrice float64) {
|
||||
func (s *salesOrdersService) calculatePriceByMarketingType(marketingType string, qty, avgWeight, unitPrice float64, week *int, convertionUnit *string, weightPerConvertion *float64) (totalWeight, totalPrice float64) {
|
||||
if marketingType == string(utils.MarketingTypeTrading) {
|
||||
totalWeight = 0
|
||||
totalPrice = math.Round(qty*unitPrice*100) / 100
|
||||
@@ -831,8 +831,11 @@ func (s *salesOrdersService) calculatePriceByMarketingType(marketingType string,
|
||||
totalPrice = math.Round(qty*unitPrice*100) / 100
|
||||
return totalWeight, totalPrice
|
||||
case string(utils.ConvertionUnitPeti):
|
||||
totalPrice = math.Round(totalWeight*unitPrice*100) / 100
|
||||
return totalWeight, totalPrice
|
||||
if weightPerConvertion != nil && *weightPerConvertion > 0 {
|
||||
totalPeti := totalWeight / *weightPerConvertion
|
||||
totalPrice = math.Round(totalPeti*unitPrice*100) / 100
|
||||
return totalWeight, totalPrice
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
package validation
|
||||
|
||||
type DeliveryProduct struct {
|
||||
MarketingProductId uint `json:"marketing_product_id" validate:"required,gt=0"`
|
||||
Qty float64 `json:"qty" validate:"omitempty,gte=0"`
|
||||
UnitPrice float64 `json:"unit_price" validate:"omitempty,gte=0"`
|
||||
AvgWeight float64 `json:"avg_weight" validate:"omitempty,gte=0"`
|
||||
WeightPerConvertion *float64 `json:"weight_per_convertion" validate:"omitempty,gt=0"`
|
||||
TotalWeight *float64 `json:"total_weight" validate:"omitempty,gte=0"`
|
||||
TotalPrice *float64 `json:"total_price" validate:"omitempty,gte=0"`
|
||||
DeliveryDate string `json:"delivery_date" validate:"omitempty,datetime=2006-01-02"`
|
||||
VehicleNumber string `json:"vehicle_number" validate:"omitempty,max=50"`
|
||||
MarketingProductId uint `json:"marketing_product_id" validate:"required,gt=0"`
|
||||
Qty float64 `json:"qty" validate:"omitempty,gte=0"`
|
||||
UnitPrice float64 `json:"unit_price" validate:"omitempty,gte=0"`
|
||||
AvgWeight float64 `json:"avg_weight" validate:"omitempty,gte=0"`
|
||||
DeliveryDate string `json:"delivery_date" validate:"omitempty,datetime=2006-01-02"`
|
||||
VehicleNumber string `json:"vehicle_number" validate:"omitempty,max=50"`
|
||||
}
|
||||
|
||||
type DeliveryOrderCreate struct {
|
||||
|
||||
@@ -55,9 +55,9 @@ func (s areaService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entity.Ar
|
||||
db = s.withRelations(db)
|
||||
db, scopeErr = m.ApplyAreaScope(c, db, "id")
|
||||
if params.Search != "" {
|
||||
db = db.Where("name ILIKE ?", "%"+params.Search+"%")
|
||||
return db.Where("name ILIKE ?", "%"+params.Search+"%")
|
||||
}
|
||||
return db.Order("name ASC").Order("id ASC")
|
||||
return db.Order("created_at DESC").Order("updated_at DESC")
|
||||
})
|
||||
|
||||
if scopeErr != nil {
|
||||
|
||||
@@ -33,14 +33,6 @@ func (u *CustomerController) GetAll(c *fiber.Ctx) error {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0")
|
||||
}
|
||||
|
||||
if hasMarketingParam := c.Query("has_marketing", ""); hasMarketingParam != "" {
|
||||
value, err := strconv.ParseBool(hasMarketingParam)
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "invalid has_marketing value")
|
||||
}
|
||||
query.HasMarketing = &value
|
||||
}
|
||||
|
||||
result, totalResults, err := u.CustomerService.GetAll(c, query)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -53,28 +53,7 @@ func (s customerService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit
|
||||
customers, total, err := s.Repository.GetAll(c.Context(), offset, params.Limit, func(db *gorm.DB) *gorm.DB {
|
||||
db = s.withRelations(db)
|
||||
if params.Search != "" {
|
||||
db = db.Where("name ILIKE ?", "%"+params.Search+"%")
|
||||
if params.HasMarketing != nil && *params.HasMarketing {
|
||||
db = db.Where(`
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM marketings
|
||||
WHERE marketings.customer_id = customers.id
|
||||
AND marketings.deleted_at IS NULL
|
||||
)
|
||||
`)
|
||||
}
|
||||
return db
|
||||
}
|
||||
if params.HasMarketing != nil && *params.HasMarketing {
|
||||
db = db.Where(`
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM marketings
|
||||
WHERE marketings.customer_id = customers.id
|
||||
AND marketings.deleted_at IS NULL
|
||||
)
|
||||
`)
|
||||
return db.Where("name ILIKE ?", "%"+params.Search+"%")
|
||||
}
|
||||
return db.Order("created_at DESC").Order("updated_at DESC")
|
||||
})
|
||||
|
||||
@@ -21,8 +21,7 @@ type Update struct {
|
||||
}
|
||||
|
||||
type Query struct {
|
||||
Page int `query:"page" validate:"omitempty,number,min=1"`
|
||||
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"`
|
||||
Search string `query:"search" validate:"omitempty,max=50"`
|
||||
HasMarketing *bool `query:"has_marketing" validate:"omitempty"`
|
||||
Page int `query:"page" validate:"omitempty,number,min=1"`
|
||||
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"`
|
||||
Search string `query:"search" validate:"omitempty,max=50"`
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ func (s locationService) GetAll(c *fiber.Ctx, params *validation.Query) ([]entit
|
||||
)
|
||||
`, utils.ProjectFlockCategoryLaying)
|
||||
}
|
||||
return db.Order("locations.name ASC").Order("locations.id ASC")
|
||||
return db.Order("created_at DESC").Order("updated_at DESC")
|
||||
})
|
||||
|
||||
if scopeErr != nil {
|
||||
|
||||
+10
-38
@@ -24,50 +24,22 @@ func NewProjectFlockKandangController(projectFlockKandangService service.Project
|
||||
|
||||
func (u *ProjectFlockKandangController) GetAll(c *fiber.Ctx) error {
|
||||
query := &validation.Query{
|
||||
Page: c.QueryInt("page", 1),
|
||||
Limit: c.QueryInt("limit", 10),
|
||||
Search: c.Query("search", ""),
|
||||
NameWithPeriode: c.QueryBool("name_with_periode", false),
|
||||
ProjectFlockId: uint(c.QueryInt("project_flock_id", 0)),
|
||||
KandangId: uint(c.QueryInt("kandang_id", 0)),
|
||||
Category: c.Query("category", ""),
|
||||
AreaId: uint(c.QueryInt("area_id", 0)),
|
||||
LocationId: uint(c.QueryInt("location_id", 0)),
|
||||
SortBy: c.Query("sort_by", ""),
|
||||
SortOrder: c.Query("sort_order", ""),
|
||||
StepName: c.Query("step_name", ""),
|
||||
Page: c.QueryInt("page", 1),
|
||||
Limit: c.QueryInt("limit", 10),
|
||||
Search: c.Query("search", ""),
|
||||
ProjectFlockId: uint(c.QueryInt("project_flock_id", 0)),
|
||||
KandangId: uint(c.QueryInt("kandang_id", 0)),
|
||||
Category: c.Query("category", ""),
|
||||
AreaId: uint(c.QueryInt("area_id", 0)),
|
||||
SortBy: c.Query("sort_by", ""),
|
||||
SortOrder: c.Query("sort_order", ""),
|
||||
StepName: c.Query("step_name", ""),
|
||||
}
|
||||
|
||||
if query.Page < 1 || query.Limit < 1 {
|
||||
return fiber.NewError(fiber.StatusBadRequest, "page and limit must be greater than 0")
|
||||
}
|
||||
|
||||
if query.NameWithPeriode {
|
||||
results, totalResults, err := u.ProjectFlockKandangService.GetAllNameWithPeriode(c, query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data := make([]dto.ProjectFlockKandangNameWithPeriodDTO, 0, len(results))
|
||||
for _, result := range results {
|
||||
data = append(data, dto.ToProjectFlockKandangNameWithPeriodDTOValues(result.Id, result.KandangName, result.Period))
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusOK).
|
||||
JSON(response.SuccessWithPaginate[dto.ProjectFlockKandangNameWithPeriodDTO]{
|
||||
Code: fiber.StatusOK,
|
||||
Status: "success",
|
||||
Message: "Get all projectFlockKandangs successfully",
|
||||
Meta: response.Meta{
|
||||
Page: query.Page,
|
||||
Limit: query.Limit,
|
||||
TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))),
|
||||
TotalResults: totalResults,
|
||||
},
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
|
||||
results, totalResults, err := u.ProjectFlockKandangService.GetAll(c, query)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
+3
-26
@@ -60,11 +60,6 @@ type ProjectFlockKandangListDTO struct {
|
||||
ChickinApproval *approvalDTO.ApprovalRelationDTO `json:"chickin_approval,omitempty"`
|
||||
}
|
||||
|
||||
type ProjectFlockKandangNameWithPeriodDTO struct {
|
||||
Id uint `json:"id"`
|
||||
NameWithPeriod string `json:"name_with_period"`
|
||||
}
|
||||
|
||||
type ProjectFlockKandangDetailDTO struct {
|
||||
ProjectFlockKandangListDTO
|
||||
Chickins []chickinDTO.ChickinRelationDTO `json:"chickins,omitempty"`
|
||||
@@ -134,17 +129,13 @@ func toKandangRelation(kandang entity.Kandang) *kandangDTO.KandangRelationDTO {
|
||||
}
|
||||
|
||||
func toNameWithPeriod(kandang entity.Kandang, period int) string {
|
||||
return toNameWithPeriodValue(kandang.Name, period)
|
||||
}
|
||||
|
||||
func toNameWithPeriodValue(kandangName string, period int) string {
|
||||
if kandangName == "" {
|
||||
if kandang.Name == "" {
|
||||
return ""
|
||||
}
|
||||
if period == 0 {
|
||||
return kandangName
|
||||
return kandang.Name
|
||||
}
|
||||
return kandangName + " Period " + strconv.Itoa(period)
|
||||
return kandang.Name + " Period " + strconv.Itoa(period)
|
||||
}
|
||||
|
||||
func toApprovalDTOSelector(
|
||||
@@ -176,20 +167,6 @@ func ToProjectFlockKandangListDTO(e entity.ProjectFlockKandang) ProjectFlockKand
|
||||
}
|
||||
}
|
||||
|
||||
func ToProjectFlockKandangNameWithPeriodDTO(e entity.ProjectFlockKandang) ProjectFlockKandangNameWithPeriodDTO {
|
||||
return ProjectFlockKandangNameWithPeriodDTO{
|
||||
Id: e.Id,
|
||||
NameWithPeriod: toNameWithPeriod(e.Kandang, e.Period),
|
||||
}
|
||||
}
|
||||
|
||||
func ToProjectFlockKandangNameWithPeriodDTOValues(id uint, kandangName string, period int) ProjectFlockKandangNameWithPeriodDTO {
|
||||
return ProjectFlockKandangNameWithPeriodDTO{
|
||||
Id: id,
|
||||
NameWithPeriod: toNameWithPeriodValue(kandangName, period),
|
||||
}
|
||||
}
|
||||
|
||||
func toCreatedUserDTO(pf entity.ProjectFlock) *userDTO.UserRelationDTO {
|
||||
if pf.CreatedUser.Id != 0 {
|
||||
mapped := userDTO.ToUserRelationDTO(pf.CreatedUser)
|
||||
|
||||
-37
@@ -26,7 +26,6 @@ import (
|
||||
|
||||
type ProjectFlockKandangService interface {
|
||||
GetAll(ctx *fiber.Ctx, params *validation.Query) ([]entity.ProjectFlockKandang, int64, error)
|
||||
GetAllNameWithPeriode(ctx *fiber.Ctx, params *validation.Query) ([]ProjectFlockKandangNameWithPeriode, int64, error)
|
||||
GetOne(ctx *fiber.Ctx, id uint) (*entity.ProjectFlockKandang, map[uint]float64, []entity.ProductWarehouse, error)
|
||||
CheckClosing(ctx *fiber.Ctx, id uint) (*ClosingCheckResult, error)
|
||||
Closing(ctx *fiber.Ctx, id uint, req *validation.Closing) (*entity.ProjectFlockKandang, error)
|
||||
@@ -52,12 +51,6 @@ type ClosingCheckResult struct {
|
||||
Expenses []ExpenseSummary `json:"expenses"`
|
||||
}
|
||||
|
||||
type ProjectFlockKandangNameWithPeriode struct {
|
||||
Id uint
|
||||
KandangName string
|
||||
Period int
|
||||
}
|
||||
|
||||
type StockRemainingDetail struct {
|
||||
FlagName string `json:"flag_name"`
|
||||
ProductWarehouseId uint `json:"product_warehouse_id"`
|
||||
@@ -140,36 +133,6 @@ func (s projectFlockKandangService) GetAll(c *fiber.Ctx, params *validation.Quer
|
||||
return projectFlockKandangs, total, nil
|
||||
}
|
||||
|
||||
func (s projectFlockKandangService) GetAllNameWithPeriode(c *fiber.Ctx, params *validation.Query) ([]ProjectFlockKandangNameWithPeriode, int64, error) {
|
||||
if err := s.Validate.Struct(params); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
scope, err := m.ResolveLocationScope(c, s.Repository.DB())
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
offset := (params.Page - 1) * params.Limit
|
||||
|
||||
rows, total, err := s.Repository.GetAllNameWithPeriodeScoped(c.Context(), offset, params.Limit, params, scope.IDs, scope.Restrict)
|
||||
if err != nil {
|
||||
s.Log.Errorf("Failed to get projectFlockKandangs name_with_periode: %+v", err)
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
results := make([]ProjectFlockKandangNameWithPeriode, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
results = append(results, ProjectFlockKandangNameWithPeriode{
|
||||
Id: row.Id,
|
||||
KandangName: row.KandangName,
|
||||
Period: row.Period,
|
||||
})
|
||||
}
|
||||
|
||||
return results, total, nil
|
||||
}
|
||||
|
||||
func (s projectFlockKandangService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectFlockKandang, map[uint]float64, []entity.ProductWarehouse, error) {
|
||||
scope, err := m.ResolveLocationScope(c, s.Repository.DB())
|
||||
if err != nil {
|
||||
|
||||
+11
-13
@@ -11,21 +11,19 @@ type Update struct {
|
||||
}
|
||||
|
||||
type Query struct {
|
||||
Page int `query:"page" validate:"omitempty,number,min=1,gt=0"`
|
||||
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"`
|
||||
Search string `query:"search" validate:"omitempty,max=50"`
|
||||
NameWithPeriode bool `query:"name_with_periode"`
|
||||
ProjectFlockId uint `query:"project_flock_id" validate:"omitempty"`
|
||||
KandangId uint `query:"kandang_id" validate:"omitempty"`
|
||||
Category string `query:"category" validate:"omitempty,oneof=Growing Laying"`
|
||||
AreaId uint `query:"area_id" validate:"omitempty"`
|
||||
LocationId uint `query:"location_id" validate:"omitempty,number,gt=0"`
|
||||
SortBy string `query:"sort_by" validate:"omitempty,oneof=created_at period"`
|
||||
SortOrder string `query:"sort_order" validate:"omitempty,oneof=ASC DESC"`
|
||||
StepName string `query:"step_name" validate:"omitempty,max=50"`
|
||||
Page int `query:"page" validate:"omitempty,number,min=1,gt=0"`
|
||||
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100,gt=0"`
|
||||
Search string `query:"search" validate:"omitempty,max=50"`
|
||||
ProjectFlockId uint `query:"project_flock_id" validate:"omitempty"`
|
||||
KandangId uint `query:"kandang_id" validate:"omitempty"`
|
||||
Category string `query:"category" validate:"omitempty,oneof=Growing Laying"`
|
||||
AreaId uint `query:"area_id" validate:"omitempty"`
|
||||
SortBy string `query:"sort_by" validate:"omitempty,oneof=created_at period"`
|
||||
SortOrder string `query:"sort_order" validate:"omitempty,oneof=ASC DESC"`
|
||||
StepName string `query:"step_name" validate:"omitempty,max=50"`
|
||||
}
|
||||
|
||||
type Closing struct {
|
||||
Action string `json:"action" validate:"required,oneof=close unclose"`
|
||||
ClosedDate *string `json:"closed_date,omitempty"`
|
||||
}
|
||||
}
|
||||
@@ -322,8 +322,8 @@ func (r *ProjectflockRepositoryImpl) buildOrderExpressions(sortBy, sortOrder str
|
||||
}
|
||||
default:
|
||||
return []string{
|
||||
"project_flocks.flock_name ASC",
|
||||
"project_flocks.id ASC",
|
||||
"project_flocks.created_at DESC",
|
||||
"project_flocks.updated_at DESC",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-112
@@ -22,7 +22,6 @@ type ProjectFlockKandangRepository interface {
|
||||
GetAll(ctx context.Context, offset int, limit int, modifier func(*gorm.DB) *gorm.DB) ([]entity.ProjectFlockKandang, int64, error)
|
||||
GetAllWithFilters(ctx context.Context, offset int, limit int, params interface{}) ([]entity.ProjectFlockKandang, int64, error)
|
||||
GetAllWithFiltersScoped(ctx context.Context, offset int, limit int, params interface{}, locationIDs []uint, restrict bool) ([]entity.ProjectFlockKandang, int64, error)
|
||||
GetAllNameWithPeriodeScoped(ctx context.Context, offset int, limit int, params *validation.Query, locationIDs []uint, restrict bool) ([]ProjectFlockKandangNameWithPeriode, int64, error)
|
||||
GetByProjectFlockID(ctx context.Context, projectFlockID uint) ([]entity.ProjectFlockKandang, error)
|
||||
ListExistingKandangIDs(ctx context.Context, projectFlockID uint, kandangIDs []uint) ([]uint, error)
|
||||
HasKandangsLinkedToOtherProject(ctx context.Context, kandangIDs []uint, exceptProjectID *uint) (bool, error)
|
||||
@@ -41,12 +40,6 @@ type projectFlockKandangRepositoryImpl struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
type ProjectFlockKandangNameWithPeriode struct {
|
||||
Id uint `gorm:"column:id"`
|
||||
KandangName string `gorm:"column:kandang_name"`
|
||||
Period int `gorm:"column:period"`
|
||||
}
|
||||
|
||||
const flockBaseNameExpression = "LOWER(TRIM(regexp_replace(project_flocks.flock_name, '\\s+\\d+(\\s+\\d+)*$', '', 'g')))"
|
||||
|
||||
func NewProjectFlockKandangRepository(db *gorm.DB) ProjectFlockKandangRepository {
|
||||
@@ -178,17 +171,13 @@ func (r *projectFlockKandangRepositoryImpl) GetAllWithFilters(ctx context.Contex
|
||||
if query.AreaId > 0 {
|
||||
q = q.Where("\"project_flocks\".\"area_id\" = ?", query.AreaId)
|
||||
}
|
||||
|
||||
if query.LocationId > 0 {
|
||||
q = q.Where("\"kandangs\".\"location_id\" = ?", query.LocationId)
|
||||
}
|
||||
}
|
||||
|
||||
if err := q.Model(&entity.ProjectFlockKandang{}).Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
sortBy := "\"project_flock_kandangs\".\"id\" ASC"
|
||||
sortBy := "\"project_flock_kandangs\".\"created_at\" DESC"
|
||||
if ok && query != nil && query.SortBy != "" {
|
||||
sortOrder := "DESC"
|
||||
if query.SortOrder == "ASC" {
|
||||
@@ -280,17 +269,13 @@ func (r *projectFlockKandangRepositoryImpl) GetAllWithFiltersScoped(ctx context.
|
||||
if query.AreaId > 0 {
|
||||
q = q.Where("\"project_flocks\".\"area_id\" = ?", query.AreaId)
|
||||
}
|
||||
|
||||
if query.LocationId > 0 {
|
||||
q = q.Where("\"kandangs\".\"location_id\" = ?", query.LocationId)
|
||||
}
|
||||
}
|
||||
|
||||
if err := q.Model(&entity.ProjectFlockKandang{}).Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
sortBy := "\"project_flock_kandangs\".\"id\" ASC"
|
||||
sortBy := "\"project_flock_kandangs\".\"created_at\" DESC"
|
||||
if ok && query != nil && query.SortBy != "" {
|
||||
sortOrder := "DESC"
|
||||
if query.SortOrder == "ASC" {
|
||||
@@ -312,101 +297,6 @@ func (r *projectFlockKandangRepositoryImpl) GetAllWithFiltersScoped(ctx context.
|
||||
return records, total, nil
|
||||
}
|
||||
|
||||
func (r *projectFlockKandangRepositoryImpl) GetAllNameWithPeriodeScoped(ctx context.Context, offset int, limit int, params *validation.Query, locationIDs []uint, restrict bool) ([]ProjectFlockKandangNameWithPeriode, int64, error) {
|
||||
var records []ProjectFlockKandangNameWithPeriode
|
||||
var total int64
|
||||
|
||||
q := r.db.WithContext(ctx).
|
||||
Table("project_flock_kandangs").
|
||||
Joins("JOIN \"kandangs\" ON \"project_flock_kandangs\".\"kandang_id\" = \"kandangs\".\"id\"").
|
||||
Joins("JOIN \"project_flocks\" ON \"project_flock_kandangs\".\"project_flock_id\" = \"project_flocks\".\"id\"")
|
||||
|
||||
if restrict {
|
||||
if len(locationIDs) == 0 {
|
||||
return []ProjectFlockKandangNameWithPeriode{}, 0, nil
|
||||
}
|
||||
q = q.Where("\"project_flocks\".\"location_id\" IN ?", locationIDs)
|
||||
}
|
||||
|
||||
if params != nil && params.StepName != "" {
|
||||
q = q.Where(`
|
||||
EXISTS (
|
||||
SELECT 1 FROM "approvals"
|
||||
WHERE "approvals"."approvable_id" = "project_flock_kandangs"."id"
|
||||
AND "approvals"."approvable_type" = ?
|
||||
AND LOWER("approvals"."step_name") = LOWER(?)
|
||||
AND "approvals"."id" IN (
|
||||
SELECT "approvals"."id" FROM "approvals"
|
||||
WHERE "approvals"."approvable_id" = "project_flock_kandangs"."id"
|
||||
AND "approvals"."approvable_type" = ?
|
||||
ORDER BY "approvals"."id" DESC
|
||||
LIMIT 1
|
||||
)
|
||||
)
|
||||
`, "PROJECT_FLOCK_KANDANGS", params.StepName, "PROJECT_FLOCK_KANDANGS")
|
||||
}
|
||||
|
||||
if params != nil {
|
||||
if params.Search != "" {
|
||||
escapedSearch := strings.NewReplacer("\\", "\\\\", "%", "\\%", "_", "\\_").Replace(params.Search)
|
||||
q = q.Where(
|
||||
r.db.Where("LOWER(\"kandangs\".\"name\") LIKE LOWER(?) ESCAPE '\\'", "%"+escapedSearch+"%").
|
||||
Or("LOWER(\"project_flocks\".\"flock_name\") LIKE LOWER(?) ESCAPE '\\'", "%"+escapedSearch+"%"),
|
||||
)
|
||||
}
|
||||
|
||||
if params.ProjectFlockId > 0 {
|
||||
q = q.Where("\"project_flock_kandangs\".\"project_flock_id\" = ?", params.ProjectFlockId)
|
||||
}
|
||||
|
||||
if params.KandangId > 0 {
|
||||
q = q.Where("\"project_flock_kandangs\".\"kandang_id\" = ?", params.KandangId)
|
||||
}
|
||||
|
||||
if params.Category != "" {
|
||||
q = q.Where("\"project_flocks\".\"category\" = ?", params.Category)
|
||||
}
|
||||
|
||||
if params.AreaId > 0 {
|
||||
q = q.Where("\"project_flocks\".\"area_id\" = ?", params.AreaId)
|
||||
}
|
||||
|
||||
if params.LocationId > 0 {
|
||||
q = q.Where("\"kandangs\".\"location_id\" = ?", params.LocationId)
|
||||
}
|
||||
}
|
||||
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
sortBy := "\"project_flock_kandangs\".\"created_at\" DESC"
|
||||
if params != nil && params.SortBy != "" {
|
||||
sortOrder := "DESC"
|
||||
if params.SortOrder == "ASC" {
|
||||
sortOrder = "ASC"
|
||||
}
|
||||
|
||||
switch params.SortBy {
|
||||
case "created_at":
|
||||
sortBy = "\"project_flock_kandangs\".\"created_at\" " + sortOrder
|
||||
case "period":
|
||||
sortBy = "\"project_flocks\".\"period\" " + sortOrder
|
||||
}
|
||||
}
|
||||
|
||||
if err := q.
|
||||
Select("\"project_flock_kandangs\".\"id\", \"project_flock_kandangs\".\"period\", \"kandangs\".\"name\" AS kandang_name").
|
||||
Order(sortBy).
|
||||
Offset(offset).
|
||||
Limit(limit).
|
||||
Scan(&records).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return records, total, nil
|
||||
}
|
||||
|
||||
func (r *projectFlockKandangRepositoryImpl) WithTx(tx *gorm.DB) ProjectFlockKandangRepository {
|
||||
return &projectFlockKandangRepositoryImpl{db: tx}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ func NewRecordingController(recordingService service.RecordingService) *Recordin
|
||||
|
||||
func (u *RecordingController) GetAll(c *fiber.Ctx) error {
|
||||
projectFlockID := c.QueryInt("project_flock_kandang_id", 0)
|
||||
exportType := strings.TrimSpace(c.Query("export"))
|
||||
|
||||
page := c.QueryInt("page", 1)
|
||||
limit := c.QueryInt("limit", 10)
|
||||
@@ -47,11 +46,6 @@ func (u *RecordingController) GetAll(c *fiber.Ctx) error {
|
||||
return err
|
||||
}
|
||||
|
||||
listDTO := dto.ToRecordingListDTOs(result)
|
||||
if strings.EqualFold(exportType, "excel") {
|
||||
return exportRecordingListExcel(c, listDTO)
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusOK).
|
||||
JSON(response.SuccessWithPaginate[dto.RecordingListDTO]{
|
||||
Code: fiber.StatusOK,
|
||||
@@ -63,7 +57,7 @@ func (u *RecordingController) GetAll(c *fiber.Ctx) error {
|
||||
TotalPages: int64(math.Ceil(float64(totalResults) / float64(query.Limit))),
|
||||
TotalResults: totalResults,
|
||||
},
|
||||
Data: listDTO,
|
||||
Data: dto.ToRecordingListDTOs(result),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,517 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/modules/production/recordings/dto"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
func exportRecordingListExcel(c *fiber.Ctx, items []dto.RecordingListDTO) error {
|
||||
file := excelize.NewFile()
|
||||
defer file.Close()
|
||||
|
||||
const sheetName = "Recordings"
|
||||
defaultSheet := file.GetSheetName(file.GetActiveSheetIndex())
|
||||
if defaultSheet != sheetName {
|
||||
if err := file.SetSheetName(defaultSheet, sheetName); err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "failed to prepare excel sheet")
|
||||
}
|
||||
}
|
||||
|
||||
if err := setRecordingExportColumns(file, sheetName); err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "failed to prepare excel columns")
|
||||
}
|
||||
if err := setRecordingExportHeaders(file, sheetName); err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "failed to prepare excel headers")
|
||||
}
|
||||
if err := setRecordingExportRows(file, sheetName, items); err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "failed to prepare excel rows")
|
||||
}
|
||||
|
||||
buffer, err := file.WriteToBuffer()
|
||||
if err != nil {
|
||||
return fiber.NewError(fiber.StatusInternalServerError, "failed to generate excel file")
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("recordings_%s.xlsx", time.Now().Format("20060102_150405"))
|
||||
c.Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||||
c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
||||
|
||||
return c.Status(fiber.StatusOK).Send(buffer.Bytes())
|
||||
}
|
||||
|
||||
func setRecordingExportColumns(file *excelize.File, sheet string) error {
|
||||
columnWidths := map[string]float64{
|
||||
"A": 6,
|
||||
"B": 18,
|
||||
"C": 24,
|
||||
"D": 18,
|
||||
"E": 10,
|
||||
"F": 12,
|
||||
"G": 20,
|
||||
"H": 18,
|
||||
"I": 16,
|
||||
"J": 12,
|
||||
"K": 12,
|
||||
"L": 16,
|
||||
"M": 16,
|
||||
"N": 18,
|
||||
"O": 18,
|
||||
"P": 16,
|
||||
"Q": 16,
|
||||
"R": 16,
|
||||
"S": 16,
|
||||
"T": 16,
|
||||
"U": 16,
|
||||
"V": 16,
|
||||
"W": 18,
|
||||
"X": 18,
|
||||
"Y": 18,
|
||||
"Z": 22,
|
||||
"AA": 16,
|
||||
"AB": 18,
|
||||
}
|
||||
|
||||
for col, width := range columnWidths {
|
||||
if err := file.SetColWidth(sheet, col, col, width); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := file.SetRowHeight(sheet, 1, 30); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := file.SetRowHeight(sheet, 2, 30); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func setRecordingExportHeaders(file *excelize.File, sheet string) error {
|
||||
verticalHeaderCols := []string{"A", "B", "C", "D", "E", "F", "G", "H", "I", "Y", "Z", "AA", "AB"}
|
||||
for _, col := range verticalHeaderCols {
|
||||
if err := file.MergeCell(sheet, col+"1", col+"2"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
headerValues := map[string]string{
|
||||
"A1": "No",
|
||||
"B1": "Lokasi",
|
||||
"C1": "Flock",
|
||||
"D1": "Kandang",
|
||||
"E1": "Periode",
|
||||
"F1": "Kategori",
|
||||
"G1": "Umur (hari)",
|
||||
"H1": "Waktu Recording",
|
||||
"I1": "Populasi Akhir",
|
||||
"Y1": "Status Approval",
|
||||
"Z1": "Catatan Approval",
|
||||
"AA1": "Dibuat Oleh",
|
||||
"AB1": "Tanggal Submit",
|
||||
}
|
||||
for cell, value := range headerValues {
|
||||
if err := file.SetCellValue(sheet, cell, value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := file.MergeCell(sheet, "J1", "K1"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := file.SetCellValue(sheet, "J1", "FCR"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := file.SetCellValue(sheet, "J2", "Actual"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := file.SetCellValue(sheet, "K2", "Standard"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := file.MergeCell(sheet, "L1", "M1"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := file.SetCellValue(sheet, "L1", "Feed Intake (KG)"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := file.SetCellValue(sheet, "L2", "Actual"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := file.SetCellValue(sheet, "M2", "Standard"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := file.MergeCell(sheet, "N1", "P1"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := file.SetCellValue(sheet, "N1", "Mortality"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := file.SetCellValue(sheet, "N2", "Cum Depletion Rate"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := file.SetCellValue(sheet, "O2", "Max Depletion Std"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := file.SetCellValue(sheet, "P2", "Total Depletion"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := file.MergeCell(sheet, "Q1", "T1"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := file.SetCellValue(sheet, "Q1", "Egg Production"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := file.SetCellValue(sheet, "Q2", "Egg Mass Actual"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := file.SetCellValue(sheet, "R2", "Egg Mass Standar"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := file.SetCellValue(sheet, "S2", "Egg Weight Actual"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := file.SetCellValue(sheet, "T2", "Egg Weight Standar"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := file.MergeCell(sheet, "U1", "X1"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := file.SetCellValue(sheet, "U1", "Hen Performance"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := file.SetCellValue(sheet, "U2", "Hen Day Actual"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := file.SetCellValue(sheet, "V2", "Hen Day Standar"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := file.SetCellValue(sheet, "W2", "Hen House Actual"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := file.SetCellValue(sheet, "X2", "Hen House Standar"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
headerStyle, err := file.NewStyle(&excelize.Style{
|
||||
Font: &excelize.Font{
|
||||
Bold: true,
|
||||
Color: "7A7A7A",
|
||||
},
|
||||
Fill: excelize.Fill{
|
||||
Type: "pattern",
|
||||
Pattern: 1,
|
||||
Color: []string{"F5F5F5"},
|
||||
},
|
||||
Alignment: &excelize.Alignment{
|
||||
Horizontal: "center",
|
||||
Vertical: "center",
|
||||
WrapText: true,
|
||||
},
|
||||
Border: []excelize.Border{
|
||||
{Type: "left", Color: "DDDDDD", Style: 1},
|
||||
{Type: "top", Color: "DDDDDD", Style: 1},
|
||||
{Type: "bottom", Color: "DDDDDD", Style: 1},
|
||||
{Type: "right", Color: "DDDDDD", Style: 1},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return file.SetCellStyle(sheet, "A1", "AB2", headerStyle)
|
||||
}
|
||||
|
||||
func setRecordingExportRows(file *excelize.File, sheet string, items []dto.RecordingListDTO) error {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
columns := []string{
|
||||
"A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N",
|
||||
"O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", "AA", "AB",
|
||||
}
|
||||
|
||||
for i, item := range items {
|
||||
rowNumber := i + 3
|
||||
|
||||
fcrStd := 0.0
|
||||
if item.ProjectFlock.Fcr != nil {
|
||||
fcrStd = item.ProjectFlock.Fcr.FcrStd
|
||||
}
|
||||
|
||||
maxDepletionStd := 0.0
|
||||
eggMassStd := 0.0
|
||||
eggWeightStd := 0.0
|
||||
henDayStd := 0.0
|
||||
henHouseStd := 0.0
|
||||
feedIntakeStd := 0.0
|
||||
if item.ProjectFlock.ProductionStandart != nil {
|
||||
maxDepletionStd = item.ProjectFlock.ProductionStandart.MaxDepletionStd
|
||||
eggMassStd = item.ProjectFlock.ProductionStandart.EggMassStd
|
||||
eggWeightStd = item.ProjectFlock.ProductionStandart.EggWeightStd
|
||||
henDayStd = item.ProjectFlock.ProductionStandart.HenDayStd
|
||||
henHouseStd = item.ProjectFlock.ProductionStandart.HenHouseStd
|
||||
feedIntakeStd = item.ProjectFlock.ProductionStandart.FeedIntakeStd
|
||||
}
|
||||
|
||||
locationName := "-"
|
||||
if item.Location != nil {
|
||||
locationName = safeExportText(item.Location.Name)
|
||||
}
|
||||
|
||||
kandangName := "-"
|
||||
if item.Kandang != nil {
|
||||
kandangName = safeExportText(item.Kandang.Name)
|
||||
}
|
||||
|
||||
createdBy := "-"
|
||||
if item.CreatedUser != nil {
|
||||
createdBy = safeExportText(item.CreatedUser.Name)
|
||||
} else if strings.TrimSpace(item.Approval.ActionBy.Name) != "" {
|
||||
createdBy = safeExportText(item.Approval.ActionBy.Name)
|
||||
}
|
||||
|
||||
rowValues := []interface{}{
|
||||
i + 1,
|
||||
locationName,
|
||||
safeExportText(item.ProjectFlock.FlockName),
|
||||
kandangName,
|
||||
item.ProjectFlock.Period,
|
||||
formatCategoryLabel(item.ProjectFlock.ProjectFlockCategory),
|
||||
formatAgeLabel(item),
|
||||
formatDateIndonesian(item.RecordDatetime),
|
||||
formatNumberID(item.ProjectFlock.TotalChickQty, 0, false),
|
||||
formatNumberID(item.FcrValue, 2, true),
|
||||
formatNumberID(fcrStd, 2, true),
|
||||
formatNumberID(item.FeedIntake, 2, true),
|
||||
formatNumberID(feedIntakeStd, 2, true),
|
||||
formatPercentID(item.CumDepletionRate, 2),
|
||||
formatPercentID(maxDepletionStd, 2),
|
||||
formatNumberID(item.TotalDepletionQty, 2, true),
|
||||
formatNumberID(item.EggMass, 2, true),
|
||||
formatNumberID(eggMassStd, 2, true),
|
||||
formatNumberID(item.EggWeight, 2, true),
|
||||
formatNumberID(eggWeightStd, 2, true),
|
||||
formatPercentID(item.HenDay, 2),
|
||||
formatPercentID(henDayStd, 2),
|
||||
formatPercentID(item.HenHouse, 2),
|
||||
formatPercentID(henHouseStd, 2),
|
||||
formatApprovalStatus(item),
|
||||
safeExportText(pointerString(item.Approval.Notes)),
|
||||
createdBy,
|
||||
formatDateIndonesian(item.CreatedAt),
|
||||
}
|
||||
|
||||
for idx, col := range columns {
|
||||
cell := fmt.Sprintf("%s%d", col, rowNumber)
|
||||
if err := file.SetCellValue(sheet, cell, rowValues[idx]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lastRow := len(items) + 2
|
||||
dataCenterStyle, err := file.NewStyle(&excelize.Style{
|
||||
Alignment: &excelize.Alignment{
|
||||
Horizontal: "center",
|
||||
Vertical: "center",
|
||||
WrapText: true,
|
||||
},
|
||||
Border: []excelize.Border{
|
||||
{Type: "left", Color: "E6E6E6", Style: 1},
|
||||
{Type: "top", Color: "E6E6E6", Style: 1},
|
||||
{Type: "bottom", Color: "E6E6E6", Style: 1},
|
||||
{Type: "right", Color: "E6E6E6", Style: 1},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := file.SetCellStyle(sheet, "A3", fmt.Sprintf("AB%d", lastRow), dataCenterStyle); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dataLeftStyle, err := file.NewStyle(&excelize.Style{
|
||||
Alignment: &excelize.Alignment{
|
||||
Horizontal: "left",
|
||||
Vertical: "center",
|
||||
WrapText: true,
|
||||
},
|
||||
Border: []excelize.Border{
|
||||
{Type: "left", Color: "E6E6E6", Style: 1},
|
||||
{Type: "top", Color: "E6E6E6", Style: 1},
|
||||
{Type: "bottom", Color: "E6E6E6", Style: 1},
|
||||
{Type: "right", Color: "E6E6E6", Style: 1},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
leftColumns := []string{"B", "C", "D", "F", "G", "H", "Y", "Z", "AA", "AB"}
|
||||
for _, col := range leftColumns {
|
||||
if err := file.SetCellStyle(sheet, col+"3", fmt.Sprintf("%s%d", col, lastRow), dataLeftStyle); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func formatAgeLabel(item dto.RecordingListDTO) string {
|
||||
if item.Day <= 0 {
|
||||
return "-"
|
||||
}
|
||||
|
||||
week := 0
|
||||
if item.ProjectFlock.ProductionStandart != nil && item.ProjectFlock.ProductionStandart.Week > 0 {
|
||||
week = item.ProjectFlock.ProductionStandart.Week
|
||||
} else {
|
||||
week = ((item.Day - 1) / 7) + 1
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%d (Minggu ke-%d)", item.Day, week)
|
||||
}
|
||||
|
||||
func formatDateIndonesian(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return "-"
|
||||
}
|
||||
|
||||
loc, err := time.LoadLocation("Asia/Jakarta")
|
||||
if err == nil {
|
||||
t = t.In(loc)
|
||||
}
|
||||
|
||||
monthNames := []string{
|
||||
"",
|
||||
"Januari",
|
||||
"Februari",
|
||||
"Maret",
|
||||
"April",
|
||||
"Mei",
|
||||
"Juni",
|
||||
"Juli",
|
||||
"Agustus",
|
||||
"September",
|
||||
"Oktober",
|
||||
"November",
|
||||
"Desember",
|
||||
}
|
||||
|
||||
month := int(t.Month())
|
||||
monthLabel := strconv.Itoa(month)
|
||||
if month > 0 && month < len(monthNames) {
|
||||
monthLabel = monthNames[month]
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%02d %s %d", t.Day(), monthLabel, t.Year())
|
||||
}
|
||||
|
||||
func formatCategoryLabel(value string) string {
|
||||
normalized := strings.TrimSpace(strings.ReplaceAll(value, "_", " "))
|
||||
if normalized == "" {
|
||||
return "-"
|
||||
}
|
||||
|
||||
parts := strings.Fields(strings.ToLower(normalized))
|
||||
for i, part := range parts {
|
||||
if len(part) == 0 {
|
||||
continue
|
||||
}
|
||||
parts[i] = strings.ToUpper(part[:1]) + part[1:]
|
||||
}
|
||||
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func formatPercentID(value float64, decimals int) string {
|
||||
return fmt.Sprintf("%s%%", formatNumberID(value, decimals, false))
|
||||
}
|
||||
|
||||
func formatNumberID(value float64, decimals int, trim bool) string {
|
||||
if math.IsNaN(value) || math.IsInf(value, 0) {
|
||||
return "0"
|
||||
}
|
||||
if decimals < 0 {
|
||||
decimals = 0
|
||||
}
|
||||
|
||||
raw := strconv.FormatFloat(value, 'f', decimals, 64)
|
||||
if trim && strings.Contains(raw, ".") {
|
||||
raw = strings.TrimRight(raw, "0")
|
||||
raw = strings.TrimRight(raw, ".")
|
||||
}
|
||||
|
||||
parts := strings.SplitN(raw, ".", 2)
|
||||
intPart := parts[0]
|
||||
sign := ""
|
||||
if strings.HasPrefix(intPart, "-") {
|
||||
sign = "-"
|
||||
intPart = strings.TrimPrefix(intPart, "-")
|
||||
}
|
||||
if intPart == "" {
|
||||
intPart = "0"
|
||||
}
|
||||
|
||||
var grouped strings.Builder
|
||||
rem := len(intPart) % 3
|
||||
if rem > 0 {
|
||||
grouped.WriteString(intPart[:rem])
|
||||
if len(intPart) > rem {
|
||||
grouped.WriteString(".")
|
||||
}
|
||||
}
|
||||
for i := rem; i < len(intPart); i += 3 {
|
||||
grouped.WriteString(intPart[i : i+3])
|
||||
if i+3 < len(intPart) {
|
||||
grouped.WriteString(".")
|
||||
}
|
||||
}
|
||||
|
||||
result := sign + grouped.String()
|
||||
if len(parts) == 2 && parts[1] != "" {
|
||||
result += "," + parts[1]
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func safeExportText(value string) string {
|
||||
normalized := strings.TrimSpace(value)
|
||||
if normalized == "" {
|
||||
return "-"
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func pointerString(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func formatApprovalStatus(item dto.RecordingListDTO) string {
|
||||
action := strings.ToUpper(strings.TrimSpace(pointerString(item.Approval.Action)))
|
||||
switch action {
|
||||
case "UPDATED":
|
||||
return "Diperbarui"
|
||||
case "CREATED":
|
||||
return safeExportText(item.Approval.StepName)
|
||||
default:
|
||||
return safeExportText(item.Approval.StepName)
|
||||
}
|
||||
}
|
||||
@@ -758,39 +758,15 @@ func (r *RecordingRepositoryImpl) GetCumulativeEggQtyByProjectFlockKandang(
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
var cumulativeEggQty float64
|
||||
var result float64
|
||||
err := tx.
|
||||
Table("recording_eggs").
|
||||
Select("COALESCE(SUM(recording_eggs.qty), 0)").
|
||||
Joins("JOIN recordings ON recordings.id = recording_eggs.recording_id").
|
||||
Where("recordings.project_flock_kandangs_id = ?", projectFlockKandangId).
|
||||
Where("recordings.record_datetime <= ?", recordTime).
|
||||
Scan(&cumulativeEggQty).Error
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
productWarehouseSubQuery := tx.
|
||||
Table("recording_eggs").
|
||||
Select("DISTINCT recording_eggs.product_warehouse_id").
|
||||
Joins("JOIN recordings ON recordings.id = recording_eggs.recording_id").
|
||||
Where("recordings.project_flock_kandangs_id = ?", projectFlockKandangId).
|
||||
Where("recordings.record_datetime <= ?", recordTime)
|
||||
|
||||
var adjustmentEggQty float64
|
||||
err = tx.
|
||||
Table("adjustment_stocks").
|
||||
Select("COALESCE(SUM(adjustment_stocks.total_qty), 0)").
|
||||
Where("adjustment_stocks.product_warehouse_id IN (?)", productWarehouseSubQuery).
|
||||
Where("adjustment_stocks.function_code = ?", "RECORDING_EGG_IN").
|
||||
Where("adjustment_stocks.transaction_type = ?", "RECORDING").
|
||||
Where("adjustment_stocks.created_at <= ?", recordTime).
|
||||
Scan(&adjustmentEggQty).Error
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return cumulativeEggQty + adjustmentEggQty, nil
|
||||
Scan(&result).Error
|
||||
return result, err
|
||||
}
|
||||
func (r *RecordingRepositoryImpl) GetProductionWeightAndQtyByProjectFlockID(ctx context.Context, projectFlockID uint) (totalWeight float64, totalQty float64, err error) {
|
||||
// Body-weight tracking is removed; keep stub for report compatibility.
|
||||
|
||||
@@ -1989,9 +1989,9 @@ func (s *recordingService) computeAndUpdateMetrics(ctx context.Context, tx *gorm
|
||||
}
|
||||
|
||||
var eggMass float64
|
||||
if initialChickin > 0 && totalEggWeightGrams > 0 {
|
||||
// totalEggWeightGrams is in grams; egg mass uses initial chick population.
|
||||
eggMass = totalEggWeightGrams / initialChickin
|
||||
if remainingChick > 0 && totalEggWeightGrams > 0 {
|
||||
// totalEggWeightGrams is in grams; egg mass is grams per hen.
|
||||
eggMass = totalEggWeightGrams / remainingChick
|
||||
updates["egg_mass"] = eggMass
|
||||
recording.EggMass = &eggMass
|
||||
} else {
|
||||
|
||||
@@ -42,9 +42,7 @@ func (r *UniformityRepositoryImpl) GetAllWithFilters(ctx context.Context, offset
|
||||
func (r *UniformityRepositoryImpl) WithDefaultRelations() func(*gorm.DB) *gorm.DB {
|
||||
return func(db *gorm.DB) *gorm.DB {
|
||||
return db.
|
||||
Preload("ProjectFlockKandang.ProjectFlock").
|
||||
Preload("ProjectFlockKandang.ProjectFlock.Location").
|
||||
Preload("ProjectFlockKandang.Chickins").
|
||||
Preload("ProjectFlockKandang.Kandang.Location")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +106,6 @@ func (s uniformityService) GetAll(c *fiber.Ctx, params *validation.Query) ([]ent
|
||||
s.Log.Errorf("Failed to get uniformitys: %+v", err)
|
||||
return nil, 0, err
|
||||
}
|
||||
s.normalizeUniformityWeeks(uniformitys)
|
||||
if err := s.attachLatestApprovals(c.Context(), uniformitys); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
@@ -126,7 +125,6 @@ func (s uniformityService) GetOne(c *fiber.Ctx, id uint) (*entity.ProjectFlockKa
|
||||
s.Log.Errorf("Failed get uniformity by id: %+v", err)
|
||||
return nil, err
|
||||
}
|
||||
s.normalizeUniformityWeek(uniformity)
|
||||
if err := s.attachLatestApproval(c.Context(), uniformity); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -137,23 +135,6 @@ func (s uniformityService) GetSummary(c *fiber.Ctx, id uint) (*entity.ProjectFlo
|
||||
return s.GetOne(c, id)
|
||||
}
|
||||
|
||||
func (s *uniformityService) normalizeUniformityWeeks(items []entity.ProjectFlockKandangUniformity) {
|
||||
for i := range items {
|
||||
s.normalizeUniformityWeek(&items[i])
|
||||
}
|
||||
}
|
||||
|
||||
func (s *uniformityService) normalizeUniformityWeek(item *entity.ProjectFlockKandangUniformity) {
|
||||
if item == nil || item.UniformDate == nil {
|
||||
return
|
||||
}
|
||||
computedWeek, err := s.computeUniformityWeekForPFK(&item.ProjectFlockKandang, *item.UniformDate)
|
||||
if err != nil || computedWeek <= 0 {
|
||||
return
|
||||
}
|
||||
item.Week = computedWeek
|
||||
}
|
||||
|
||||
func (s uniformityService) GetStandard(c *fiber.Ctx, uniformity *entity.ProjectFlockKandangUniformity) (*utypes.UniformityStandard, error) {
|
||||
if uniformity == nil {
|
||||
return nil, nil
|
||||
@@ -391,18 +372,24 @@ func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
computedWeek, err := s.computeUniformityWeekForPFK(pfk, uniformDate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
category := strings.TrimSpace(pfk.ProjectFlock.Category)
|
||||
if s.ProductionStandardRepo != nil && pfk.ProjectFlock.ProductionStandardId != 0 {
|
||||
if standard, err := s.ProductionStandardRepo.GetByID(c.Context(), pfk.ProjectFlock.ProductionStandardId, nil); err == nil {
|
||||
if strings.TrimSpace(standard.ProjectCategory) != "" {
|
||||
category = standard.ProjectCategory
|
||||
}
|
||||
}
|
||||
}
|
||||
isGrowingCategory := strings.EqualFold(strings.TrimSpace(pfk.ProjectFlock.Category), string(utils.ProjectFlockCategoryGrowing))
|
||||
if req.Week > 0 && req.Week != computedWeek {
|
||||
s.Log.WithFields(logrus.Fields{
|
||||
"project_flock_kandang_id": req.ProjectFlockKandangId,
|
||||
"uniform_date": uniformDate.Format("2006-01-02"),
|
||||
"requested_week": req.Week,
|
||||
"computed_week": computedWeek,
|
||||
}).Warn("Uniformity week mismatch detected; using computed week")
|
||||
weekBase := 1
|
||||
isLayingCategory := strings.EqualFold(category, string(utils.ProjectFlockCategoryLaying))
|
||||
if isLayingCategory {
|
||||
weekBase = config.LayingWeekStart()
|
||||
}
|
||||
if req.Week < weekBase {
|
||||
if isLayingCategory {
|
||||
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("week must start from %d for laying projects", weekBase))
|
||||
}
|
||||
return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 1 for growing projects")
|
||||
}
|
||||
|
||||
var latestWeek int
|
||||
@@ -413,14 +400,17 @@ func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file
|
||||
Scan(&latestWeek).Error; err != nil {
|
||||
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate uniformity week sequence")
|
||||
}
|
||||
if latestWeek > 0 && computedWeek > latestWeek+1 {
|
||||
if latestWeek == 0 && req.Week != weekBase {
|
||||
if isLayingCategory {
|
||||
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("week must start from %d for laying projects", weekBase))
|
||||
}
|
||||
return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 1 for growing projects")
|
||||
}
|
||||
if latestWeek > 0 && req.Week > latestWeek+1 {
|
||||
return nil, fiber.NewError(fiber.StatusBadRequest, "week must be sequential without skipping")
|
||||
}
|
||||
// if latestWeek > 0 && req.Week > latestWeek+1 {
|
||||
// return nil, fiber.NewError(fiber.StatusBadRequest, "week must be sequential without skipping")
|
||||
// }
|
||||
|
||||
if err := s.ensureUniqueUniformity(c.Context(), 0, req.ProjectFlockKandangId, computedWeek); err != nil {
|
||||
if err := s.ensureUniqueUniformity(c.Context(), 0, req.ProjectFlockKandangId, req.Week); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -448,7 +438,7 @@ func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file
|
||||
|
||||
createBody := &entity.ProjectFlockKandangUniformity{
|
||||
Uniformity: calculation.Uniformity,
|
||||
Week: computedWeek,
|
||||
Week: req.Week,
|
||||
Cv: calculation.Cv,
|
||||
ChickQtyOfWeight: calculation.ChickQtyOfWeight,
|
||||
MeanUp: calculation.MeanUp,
|
||||
@@ -477,7 +467,7 @@ func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if isGrowingCategory {
|
||||
if strings.EqualFold(category, string(utils.ProjectFlockCategoryGrowing)) {
|
||||
if err := s.updateGrowingFcrForWeek(tx, createBody.ProjectFlockKandangId, createBody.Week, calculation.MeanUp); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -546,6 +536,9 @@ func (s uniformityService) UpdateOne(c *fiber.Ctx, req *validation.Update, id ui
|
||||
}
|
||||
updateBody["project_flock_kandang_id"] = *req.ProjectFlockKandangId
|
||||
}
|
||||
if req.Week != nil {
|
||||
updateBody["week"] = *req.Week
|
||||
}
|
||||
|
||||
if req.Date != nil || req.ProjectFlockKandangId != nil || req.Week != nil {
|
||||
current, err := s.Repository.GetByID(c.Context(), id, nil)
|
||||
@@ -559,11 +552,15 @@ func (s uniformityService) UpdateOne(c *fiber.Ctx, req *validation.Update, id ui
|
||||
if targetDate == nil {
|
||||
targetDate = current.UniformDate
|
||||
}
|
||||
targetWeek := current.Week
|
||||
if req.Week != nil {
|
||||
targetWeek = *req.Week
|
||||
}
|
||||
targetPFKID := current.ProjectFlockKandangId
|
||||
if req.ProjectFlockKandangId != nil {
|
||||
targetPFKID = *req.ProjectFlockKandangId
|
||||
}
|
||||
if targetPFKID != 0 && targetDate != nil {
|
||||
if targetPFKID != 0 && targetWeek > 0 {
|
||||
pfk, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), targetPFKID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
@@ -571,21 +568,28 @@ func (s uniformityService) UpdateOne(c *fiber.Ctx, req *validation.Update, id ui
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
computedWeek, err := s.computeUniformityWeekForPFK(pfk, *targetDate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
category := strings.TrimSpace(pfk.ProjectFlock.Category)
|
||||
if s.ProductionStandardRepo != nil && pfk.ProjectFlock.ProductionStandardId != 0 {
|
||||
if standard, err := s.ProductionStandardRepo.GetByID(c.Context(), pfk.ProjectFlock.ProductionStandardId, nil); err == nil {
|
||||
if strings.TrimSpace(standard.ProjectCategory) != "" {
|
||||
category = standard.ProjectCategory
|
||||
}
|
||||
}
|
||||
}
|
||||
if req.Week != nil && *req.Week != computedWeek {
|
||||
s.Log.WithFields(logrus.Fields{
|
||||
"uniformity_id": id,
|
||||
"project_flock_kandang_id": targetPFKID,
|
||||
"uniform_date": targetDate.Format("2006-01-02"),
|
||||
"requested_week": *req.Week,
|
||||
"computed_week": computedWeek,
|
||||
}).Warn("Uniformity week mismatch detected on update; using computed week")
|
||||
weekBase := 1
|
||||
isLayingCategory := strings.EqualFold(category, string(utils.ProjectFlockCategoryLaying))
|
||||
if isLayingCategory {
|
||||
weekBase = config.LayingWeekStart()
|
||||
}
|
||||
updateBody["week"] = computedWeek
|
||||
if err := s.ensureUniqueUniformity(c.Context(), id, targetPFKID, computedWeek); err != nil {
|
||||
if targetWeek < weekBase {
|
||||
if isLayingCategory {
|
||||
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("week must start from %d for laying projects", weekBase))
|
||||
}
|
||||
return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 1 for growing projects")
|
||||
}
|
||||
}
|
||||
if targetDate != nil {
|
||||
if err := s.ensureUniqueUniformity(c.Context(), id, targetPFKID, targetWeek); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
@@ -730,51 +734,6 @@ func (s *uniformityService) ensureUniqueUniformity(ctx context.Context, id uint,
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *uniformityService) computeUniformityWeekForPFK(pfk *entity.ProjectFlockKandang, uniformDate time.Time) (int, error) {
|
||||
if pfk == nil || pfk.Id == 0 {
|
||||
return 0, fiber.NewError(fiber.StatusBadRequest, "Project flock kandang tidak valid")
|
||||
}
|
||||
|
||||
chickInDate, ok := earliestUniformityChickInDate(pfk.Chickins)
|
||||
if !ok {
|
||||
return 0, fiber.NewError(fiber.StatusBadRequest, "Tanggal chick in tidak ditemukan")
|
||||
}
|
||||
|
||||
chickInDay := normalizeUniformityDateOnlyUTC(chickInDate)
|
||||
uniformDay := normalizeUniformityDateOnlyUTC(uniformDate)
|
||||
if uniformDay.Before(chickInDay) {
|
||||
return 0, fiber.NewError(fiber.StatusBadRequest, "Uniformity date tidak boleh sebelum tanggal chick in")
|
||||
}
|
||||
|
||||
diff := int(uniformDay.Sub(chickInDay).Hours() / 24)
|
||||
weekBase := 1
|
||||
if strings.EqualFold(strings.TrimSpace(pfk.ProjectFlock.Category), string(utils.ProjectFlockCategoryLaying)) {
|
||||
weekBase = config.LayingWeekStart()
|
||||
}
|
||||
|
||||
return (diff / 7) + weekBase, nil
|
||||
}
|
||||
|
||||
func earliestUniformityChickInDate(chickins []entity.ProjectChickin) (time.Time, bool) {
|
||||
var earliest time.Time
|
||||
for _, chickin := range chickins {
|
||||
if chickin.ChickInDate.IsZero() {
|
||||
continue
|
||||
}
|
||||
if earliest.IsZero() || chickin.ChickInDate.Before(earliest) {
|
||||
earliest = chickin.ChickInDate
|
||||
}
|
||||
}
|
||||
if earliest.IsZero() {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return earliest, true
|
||||
}
|
||||
|
||||
func normalizeUniformityDateOnlyUTC(value time.Time) time.Time {
|
||||
return time.Date(value.UTC().Year(), value.UTC().Month(), value.UTC().Day(), 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
func (s uniformityService) DeleteOne(c *fiber.Ctx, id uint) error {
|
||||
if err := m.EnsureUniformityAccess(c, s.Repository.DB(), id); err != nil {
|
||||
return err
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
func TestComputeUniformityWeekForPFK(t *testing.T) {
|
||||
originalWeekStart := config.TransferToLayingGrowingMaxWeek
|
||||
config.TransferToLayingGrowingMaxWeek = 19
|
||||
t.Cleanup(func() {
|
||||
config.TransferToLayingGrowingMaxWeek = originalWeekStart
|
||||
})
|
||||
|
||||
svc := &uniformityService{}
|
||||
baseDate := time.Date(2026, time.January, 1, 9, 30, 0, 0, time.UTC)
|
||||
|
||||
t.Run("growing starts from week one", func(t *testing.T) {
|
||||
pfk := &entity.ProjectFlockKandang{
|
||||
Id: 1,
|
||||
ProjectFlock: entity.ProjectFlock{
|
||||
Category: string(utils.ProjectFlockCategoryGrowing),
|
||||
},
|
||||
Chickins: []entity.ProjectChickin{
|
||||
{ChickInDate: baseDate},
|
||||
},
|
||||
}
|
||||
|
||||
week, err := svc.computeUniformityWeekForPFK(pfk, baseDate)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if week != 1 {
|
||||
t.Fatalf("expected week 1, got %d", week)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("laying uses configured week base and earliest chick in", func(t *testing.T) {
|
||||
pfk := &entity.ProjectFlockKandang{
|
||||
Id: 2,
|
||||
ProjectFlock: entity.ProjectFlock{
|
||||
Category: string(utils.ProjectFlockCategoryLaying),
|
||||
},
|
||||
Chickins: []entity.ProjectChickin{
|
||||
{ChickInDate: baseDate.AddDate(0, 0, 4)},
|
||||
{ChickInDate: baseDate},
|
||||
},
|
||||
}
|
||||
|
||||
week, err := svc.computeUniformityWeekForPFK(pfk, baseDate.AddDate(0, 0, 7))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if week != 20 {
|
||||
t.Fatalf("expected week 20, got %d", week)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects date before chick in", func(t *testing.T) {
|
||||
pfk := &entity.ProjectFlockKandang{
|
||||
Id: 3,
|
||||
ProjectFlock: entity.ProjectFlock{
|
||||
Category: string(utils.ProjectFlockCategoryLaying),
|
||||
},
|
||||
Chickins: []entity.ProjectChickin{
|
||||
{ChickInDate: baseDate},
|
||||
},
|
||||
}
|
||||
|
||||
if _, err := svc.computeUniformityWeekForPFK(pfk, baseDate.AddDate(0, 0, -1)); err == nil {
|
||||
t.Fatal("expected error for date before chick in")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
type Create struct {
|
||||
Date string `form:"date" validate:"required"`
|
||||
ProjectFlockKandangId uint `form:"project_flock_kandang_id" validate:"required,number,min=1"`
|
||||
Week int `form:"week" validate:"omitempty,min=1"`
|
||||
Week int `form:"week" validate:"required,min=1"`
|
||||
}
|
||||
|
||||
type Update struct {
|
||||
@@ -120,14 +120,14 @@ func ParseCreate(c *fiber.Ctx) (*Create, *multipart.FileHeader, error) {
|
||||
return nil, nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is required")
|
||||
}
|
||||
|
||||
week := 0
|
||||
weekStr := strings.TrimSpace(c.FormValue("week"))
|
||||
if weekStr != "" {
|
||||
parsedWeek, err := strconv.Atoi(weekStr)
|
||||
if err != nil || parsedWeek <= 0 {
|
||||
return nil, nil, fiber.NewError(fiber.StatusBadRequest, "week is invalid")
|
||||
}
|
||||
week = parsedWeek
|
||||
if weekStr == "" {
|
||||
return nil, nil, fiber.NewError(fiber.StatusBadRequest, "week is required")
|
||||
}
|
||||
|
||||
week, err := strconv.Atoi(weekStr)
|
||||
if err != nil || week <= 0 {
|
||||
return nil, nil, fiber.NewError(fiber.StatusBadRequest, "week is required")
|
||||
}
|
||||
|
||||
file, err := c.FormFile("document")
|
||||
|
||||
@@ -169,7 +169,6 @@ type PurchaseReceivingUpdate struct {
|
||||
TravelNumber *string
|
||||
TravelDocumentPath *string
|
||||
VehicleNumber *string
|
||||
ClearVehicleNumber bool
|
||||
ReceivedQty *float64
|
||||
WarehouseID *uint
|
||||
ProductWarehouseID *uint
|
||||
@@ -247,8 +246,6 @@ func (r *PurchaseRepositoryImpl) UpdateReceivingDetails(
|
||||
}
|
||||
if upd.VehicleNumber != nil {
|
||||
data["vehicle_number"] = upd.VehicleNumber
|
||||
} else if upd.ClearVehicleNumber {
|
||||
data["vehicle_number"] = gorm.Expr("NULL")
|
||||
}
|
||||
if upd.WarehouseID != nil && *upd.WarehouseID != 0 {
|
||||
data["warehouse_id"] = upd.WarehouseID
|
||||
|
||||
@@ -183,102 +183,15 @@ func (b *expenseBridge) markExpensesUpdated(ctx context.Context, expenseIDs map[
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *expenseBridge) clearExpenseLinksForItems(ctx context.Context, itemIDs []uint) error {
|
||||
if len(itemIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
unique := make(map[uint]struct{}, len(itemIDs))
|
||||
normalized := make([]uint, 0, len(itemIDs))
|
||||
for _, id := range itemIDs {
|
||||
if id == 0 {
|
||||
continue
|
||||
}
|
||||
if _, exists := unique[id]; exists {
|
||||
continue
|
||||
}
|
||||
unique[id] = struct{}{}
|
||||
normalized = append(normalized, id)
|
||||
}
|
||||
if len(normalized) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
rows := make([]struct {
|
||||
ItemID uint
|
||||
ExpenseNonstockID *uint64
|
||||
ExpenseID *uint64
|
||||
}, 0, len(normalized))
|
||||
if err := b.db.WithContext(ctx).
|
||||
Table("purchase_items pi").
|
||||
Select("pi.id as item_id, pi.expense_nonstock_id, en.expense_id").
|
||||
Joins("LEFT JOIN expense_nonstocks en ON en.id = pi.expense_nonstock_id").
|
||||
Where("pi.id IN ?", normalized).
|
||||
Scan(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
expenseNonstockIDs := make([]uint64, 0, len(rows))
|
||||
expenseIDs := make(map[uint64]struct{})
|
||||
for _, row := range rows {
|
||||
if row.ExpenseNonstockID != nil && *row.ExpenseNonstockID != 0 {
|
||||
expenseNonstockIDs = append(expenseNonstockIDs, *row.ExpenseNonstockID)
|
||||
}
|
||||
if row.ExpenseID != nil && *row.ExpenseID != 0 {
|
||||
expenseIDs[*row.ExpenseID] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
return b.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&entity.PurchaseItem{}).
|
||||
Where("id IN ?", normalized).
|
||||
Update("expense_nonstock_id", gorm.Expr("NULL")).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(expenseNonstockIDs) > 0 {
|
||||
if err := tx.Where("id IN ?", expenseNonstockIDs).Delete(&entity.ExpenseNonstock{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
approvalRepoTx := commonRepo.NewApprovalRepository(tx)
|
||||
for expenseID := range expenseIDs {
|
||||
var count int64
|
||||
if err := tx.Model(&entity.ExpenseNonstock{}).
|
||||
Where("expense_id = ?", expenseID).
|
||||
Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := approvalRepoTx.DeleteByTarget(ctx, utils.ApprovalWorkflowExpense.String(), uint(expenseID)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Delete(&entity.Expense{}, expenseID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (b *expenseBridge) OnItemsReceived(c *fiber.Ctx, purchaseID uint, updates []ExpenseReceivingPayload) error {
|
||||
if purchaseID == 0 || len(updates) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := c.Context()
|
||||
clearExpenseLinks := make([]uint, 0, len(updates))
|
||||
filtered := make([]ExpenseReceivingPayload, 0, len(updates))
|
||||
for _, upd := range updates {
|
||||
if upd.PurchaseItemID == 0 {
|
||||
continue
|
||||
}
|
||||
if upd.SupplierID == 0 {
|
||||
clearExpenseLinks = append(clearExpenseLinks, upd.PurchaseItemID)
|
||||
continue
|
||||
}
|
||||
if upd.TransportPerItem == nil || *upd.TransportPerItem <= 0 {
|
||||
@@ -289,11 +202,6 @@ func (b *expenseBridge) OnItemsReceived(c *fiber.Ctx, purchaseID uint, updates [
|
||||
}
|
||||
filtered = append(filtered, upd)
|
||||
}
|
||||
if len(clearExpenseLinks) > 0 {
|
||||
if err := b.clearExpenseLinksForItems(ctx, clearExpenseLinks); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -248,7 +248,7 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
|
||||
SELECT 1
|
||||
FROM purchase_items pi
|
||||
JOIN products p ON p.id = pi.product_id
|
||||
WHERE pi.purchase_id = purchases.id AND p.product_category_id IN ?
|
||||
WHERE pi.purchase_id = purchases.id AND p.product_category_id IN ?
|
||||
)`,
|
||||
productCategoryIDs,
|
||||
)
|
||||
@@ -316,119 +316,12 @@ func (s *purchaseService) GetAll(c *fiber.Ctx, params *validation.Query) ([]enti
|
||||
WHERE pi.purchase_id = purchases.id
|
||||
AND LOWER(COALESCE(p.name, '')) LIKE ?
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM purchase_items pi
|
||||
JOIN warehouses w ON w.id = pi.warehouse_id
|
||||
JOIN locations l ON l.id = w.location_id
|
||||
WHERE pi.purchase_id = purchases.id
|
||||
AND LOWER(COALESCE(l.name, '')) LIKE ?
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM purchase_items pi
|
||||
JOIN expense_nonstocks en ON en.id = pi.expense_nonstock_id
|
||||
JOIN expenses e ON e.id = en.expense_id
|
||||
WHERE pi.purchase_id = purchases.id
|
||||
AND LOWER(COALESCE(e.reference_number, '')) LIKE ?
|
||||
)
|
||||
)`,
|
||||
like,
|
||||
like,
|
||||
like,
|
||||
like,
|
||||
like,
|
||||
like,
|
||||
like,
|
||||
)
|
||||
}
|
||||
|
||||
if len(approvalStatuses) > 0 {
|
||||
approvalConditions := make([]string, 0, len(approvalStatuses))
|
||||
approvalArgs := make([]any, 0, 2+(len(approvalStatuses)*3))
|
||||
approvalArgs = append(approvalArgs, utils.ApprovalWorkflowPurchase.String(), utils.ApprovalWorkflowPurchase.String())
|
||||
for _, status := range approvalStatuses {
|
||||
if status == "" {
|
||||
continue
|
||||
}
|
||||
like := "%" + status + "%"
|
||||
approvalConditions = append(approvalConditions, `(LOWER(COALESCE(a.step_name, '')) LIKE ? OR LOWER(COALESCE(CAST(a.action AS TEXT), '')) LIKE ? OR CAST(a.step_number AS TEXT) = ?)`)
|
||||
approvalArgs = append(approvalArgs, like, like, status)
|
||||
}
|
||||
|
||||
if len(approvalConditions) > 0 {
|
||||
approvalClause := strings.Join(approvalConditions, " OR ")
|
||||
approvalQuery := fmt.Sprintf(
|
||||
`EXISTS (
|
||||
SELECT 1
|
||||
FROM approvals a
|
||||
WHERE a.approvable_type = ?
|
||||
AND a.approvable_id = purchases.id
|
||||
AND a.id = (
|
||||
SELECT a2.id
|
||||
FROM approvals a2
|
||||
WHERE a2.approvable_type = ?
|
||||
AND a2.approvable_id = purchases.id
|
||||
ORDER BY a2.action_at DESC, a2.id DESC
|
||||
LIMIT 1
|
||||
)
|
||||
AND (%s)
|
||||
)`,
|
||||
approvalClause,
|
||||
)
|
||||
db = db.Where(approvalQuery, approvalArgs...)
|
||||
}
|
||||
}
|
||||
|
||||
if search != "" {
|
||||
like := "%" + search + "%"
|
||||
db = db.Where(
|
||||
`(
|
||||
LOWER(COALESCE(purchases.pr_number, '')) LIKE ?
|
||||
OR LOWER(COALESCE(purchases.po_number, '')) LIKE ?
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM suppliers s
|
||||
WHERE s.id = purchases.supplier_id
|
||||
AND LOWER(COALESCE(s.name, '')) LIKE ?
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM users u
|
||||
WHERE u.id = purchases.created_by
|
||||
AND LOWER(COALESCE(u.name, '')) LIKE ?
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM purchase_items pi
|
||||
JOIN products p ON p.id = pi.product_id
|
||||
WHERE pi.purchase_id = purchases.id
|
||||
AND LOWER(COALESCE(p.name, '')) LIKE ?
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM purchase_items pi
|
||||
JOIN warehouses w ON w.id = pi.warehouse_id
|
||||
JOIN locations l ON l.id = w.location_id
|
||||
WHERE pi.purchase_id = purchases.id
|
||||
AND LOWER(COALESCE(l.name, '')) LIKE ?
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM purchase_items pi
|
||||
JOIN expense_nonstocks en ON en.id = pi.expense_nonstock_id
|
||||
JOIN expenses e ON e.id = en.expense_id
|
||||
WHERE pi.purchase_id = purchases.id
|
||||
AND LOWER(COALESCE(e.reference_number, '')) LIKE ?
|
||||
)
|
||||
)`,
|
||||
like,
|
||||
like,
|
||||
like,
|
||||
like,
|
||||
like,
|
||||
like,
|
||||
like,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1061,7 +954,6 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
|
||||
supplierID uint
|
||||
transportPerItem *float64
|
||||
vehicleNumber *string
|
||||
clearVehicle bool
|
||||
overrideWarehouse bool
|
||||
receivedQty float64
|
||||
}
|
||||
@@ -1154,16 +1046,12 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
|
||||
}
|
||||
|
||||
var vehicleNumber *string
|
||||
clearVehicle := false
|
||||
if payload.VehicleNumber != nil {
|
||||
if payload.VehicleNumber != nil && strings.TrimSpace(*payload.VehicleNumber) != "" {
|
||||
val := strings.TrimSpace(*payload.VehicleNumber)
|
||||
if val != "" {
|
||||
vehicleNumber = &val
|
||||
} else {
|
||||
clearVehicle = true
|
||||
}
|
||||
} else {
|
||||
clearVehicle = true
|
||||
vehicleNumber = &val
|
||||
} else if item.VehicleNumber != nil && strings.TrimSpace(*item.VehicleNumber) != "" {
|
||||
val := strings.TrimSpace(*item.VehicleNumber)
|
||||
vehicleNumber = &val
|
||||
}
|
||||
|
||||
prepared = append(prepared, preparedReceiving{
|
||||
@@ -1174,7 +1062,6 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
|
||||
supplierID: supplierID,
|
||||
transportPerItem: transportPerItem,
|
||||
vehicleNumber: vehicleNumber,
|
||||
clearVehicle: clearVehicle,
|
||||
overrideWarehouse: overrideWarehouse,
|
||||
receivedQty: receivedQty,
|
||||
})
|
||||
@@ -1288,8 +1175,7 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
|
||||
ReceivedDate: &dateCopy,
|
||||
TravelNumber: prep.payload.TravelNumber,
|
||||
TravelDocumentPath: prep.payload.TravelDocumentPath,
|
||||
VehicleNumber: prep.vehicleNumber,
|
||||
ClearVehicleNumber: prep.clearVehicle,
|
||||
VehicleNumber: prep.payload.VehicleNumber,
|
||||
ReceivedQty: &qtyCopy,
|
||||
ProductWarehouseID: newPWID,
|
||||
ClearProductWarehouse: false,
|
||||
|
||||
@@ -67,11 +67,11 @@ type Query struct {
|
||||
AreaID uint `query:"area_id" validate:"omitempty,gt=0"`
|
||||
LocationID uint `query:"location_id" validate:"omitempty,gt=0"`
|
||||
ProductCategoryID string `query:"product_category_id" validate:"omitempty,max=500"`
|
||||
Search string `query:"search" validate:"omitempty,max=100"`
|
||||
ApprovalStatus string `query:"approval_status" validate:"omitempty,max=500"`
|
||||
PoDate string `query:"po_date" validate:"omitempty,datetime=2006-01-02"`
|
||||
PoDateFrom string `query:"po_date_from" validate:"omitempty,datetime=2006-01-02"`
|
||||
PoDateTo string `query:"po_date_to" validate:"omitempty,datetime=2006-01-02"`
|
||||
Search string `query:"search" validate:"omitempty,max=100"`
|
||||
CreatedFrom string `query:"created_from" validate:"omitempty,datetime=2006-01-02"`
|
||||
CreatedTo string `query:"created_to" validate:"omitempty,datetime=2006-01-02"`
|
||||
}
|
||||
|
||||
@@ -432,7 +432,6 @@ func (c *RepportController) GetProductionResult(ctx *fiber.Ctx) error {
|
||||
func parseCommaSeparatedInt64s(raw string) ([]int64, error) {
|
||||
return parseCommaSeparatedInt64sWithField(raw, "supplier_ids")
|
||||
}
|
||||
|
||||
func parseCommaSeparatedInt64sWithField(raw, field string) ([]int64, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
|
||||
@@ -196,6 +196,7 @@ func (r *purchaseSupplierRepositoryImpl) GetItemsBySuppliers(ctx context.Context
|
||||
Joins("JOIN products ON products.id = purchase_items.product_id").
|
||||
Where("products.product_category_id IN ?", filters.ProductCategoryIDs)
|
||||
}
|
||||
|
||||
if len(filters.AreaIDs) > 0 || filters.AllowedAreaIDs != nil {
|
||||
db = db.Joins("JOIN warehouses ON warehouses.id = purchase_items.warehouse_id")
|
||||
if len(filters.AreaIDs) > 0 {
|
||||
|
||||
@@ -939,27 +939,12 @@ func (s *repportService) getUniformityByWeek(ctx context.Context, projectFlockKa
|
||||
return result, nil
|
||||
}
|
||||
|
||||
weekExpr := fmt.Sprintf(`CASE
|
||||
WHEN u.uniform_date IS NULL OR pc.chick_in_date IS NULL THEN 0
|
||||
WHEN u.uniform_date::date < pc.chick_in_date THEN 0
|
||||
WHEN UPPER(pf.category) = 'LAYING' THEN (((u.uniform_date::date - pc.chick_in_date)::int) / 7) + %d
|
||||
ELSE (((u.uniform_date::date - pc.chick_in_date)::int) / 7) + 1
|
||||
END`, config.LayingWeekStart())
|
||||
|
||||
var rows []entity.ProjectFlockKandangUniformity
|
||||
if err := s.db.WithContext(ctx).
|
||||
Table("project_flock_kandang_uniformity AS u").
|
||||
Select(fmt.Sprintf("%s AS week, u.uniformity, u.uniform_date, u.id, u.chart_data", weekExpr)).
|
||||
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = u.project_flock_kandang_id").
|
||||
Joins("JOIN project_flocks AS pf ON pf.id = pfk.project_flock_id").
|
||||
Joins(`JOIN (
|
||||
SELECT project_flock_kandang_id, MIN(chick_in_date)::date AS chick_in_date
|
||||
FROM project_chickins
|
||||
WHERE deleted_at IS NULL
|
||||
GROUP BY project_flock_kandang_id
|
||||
) AS pc ON pc.project_flock_kandang_id = u.project_flock_kandang_id`).
|
||||
Where("u.project_flock_kandang_id = ?", projectFlockKandangID).
|
||||
Where(fmt.Sprintf("%s IN ?", weekExpr), weeks).
|
||||
Model(&entity.ProjectFlockKandangUniformity{}).
|
||||
Select("week, uniformity, uniform_date, id, chart_data").
|
||||
Where("project_flock_kandang_id = ?", projectFlockKandangID).
|
||||
Where("week IN ?", weeks).
|
||||
Order("uniform_date DESC").
|
||||
Order("id DESC").
|
||||
Find(&rows).Error; err != nil {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,97 +0,0 @@
|
||||
package readapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/cache"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/config"
|
||||
"gitlab.com/mbugroup/lti-api.git/internal/route"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func TestGeneratedArtifactsAreCurrent(t *testing.T) {
|
||||
PrimeBuildConfig()
|
||||
cache.SetRedis(redis.NewClient(&redis.Options{Addr: "127.0.0.1:6379"}))
|
||||
app := fiber.New(config.FiberConfig())
|
||||
app.Get("/healthz", func(c *fiber.Ctx) error {
|
||||
return c.JSON(fiber.Map{"status": "ok"})
|
||||
})
|
||||
app.Get("/readyz", func(c *fiber.Ctx) error {
|
||||
return c.JSON(fiber.Map{"status": "ok"})
|
||||
})
|
||||
route.Routes(app, nil)
|
||||
|
||||
artifacts, err := BuildArtifactsFromApp(app)
|
||||
if err != nil {
|
||||
t.Fatalf("build artifacts: %v", err)
|
||||
}
|
||||
|
||||
root := repoRoot(t)
|
||||
assertJSONMatchesFile(t, artifacts.OpenAPIJSON, filepath.Join(root, "docs", "openapi", "read-api.json"))
|
||||
assertYAMLMatchesFile(t, artifacts.OpenAPIYAML, filepath.Join(root, "docs", "openapi", "read-api.yaml"))
|
||||
assertJSONMatchesFile(t, artifacts.PostmanCollectionJSON, filepath.Join(root, "docs", "postman", "read-api.collection.json"))
|
||||
assertJSONMatchesFile(t, artifacts.PostmanEnvironmentJSON, filepath.Join(root, "docs", "postman", "read-api.environment.json"))
|
||||
}
|
||||
|
||||
func assertJSONMatchesFile(t *testing.T, got []byte, path string) {
|
||||
t.Helper()
|
||||
|
||||
want, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
|
||||
var gotValue any
|
||||
if err := json.Unmarshal(got, &gotValue); err != nil {
|
||||
t.Fatalf("unmarshal generated json: %v", err)
|
||||
}
|
||||
var wantValue any
|
||||
if err := json.Unmarshal(want, &wantValue); err != nil {
|
||||
t.Fatalf("unmarshal fixture json: %v", err)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(gotValue, wantValue) {
|
||||
t.Fatalf("json artifact mismatch for %s", path)
|
||||
}
|
||||
}
|
||||
|
||||
func assertYAMLMatchesFile(t *testing.T, got []byte, path string) {
|
||||
t.Helper()
|
||||
|
||||
want, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
|
||||
var gotValue any
|
||||
if err := yaml.Unmarshal(got, &gotValue); err != nil {
|
||||
t.Fatalf("unmarshal generated yaml: %v", err)
|
||||
}
|
||||
var wantValue any
|
||||
if err := yaml.Unmarshal(want, &wantValue); err != nil {
|
||||
t.Fatalf("unmarshal fixture yaml: %v", err)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(gotValue, wantValue) {
|
||||
t.Fatalf("yaml artifact mismatch for %s", path)
|
||||
}
|
||||
}
|
||||
|
||||
func repoRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
_, filename, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("runtime.Caller failed")
|
||||
}
|
||||
|
||||
return filepath.Clean(filepath.Join(filepath.Dir(filename), "..", ".."))
|
||||
}
|
||||
Reference in New Issue
Block a user