mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-05-20 21:41:55 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c08fadb7a | |||
| cae7f3ef63 | |||
| 42793d94bd | |||
| 1369bf0e36 | |||
| 361d14bd3e | |||
| 7923352535 | |||
| 010240066a |
@@ -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"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitlab.com/mbugroup/lti-api.git/internal/apikeys"
|
|
||||||
"gitlab.com/mbugroup/lti-api.git/internal/cache"
|
"gitlab.com/mbugroup/lti-api.git/internal/cache"
|
||||||
"gitlab.com/mbugroup/lti-api.git/internal/config"
|
"gitlab.com/mbugroup/lti-api.git/internal/config"
|
||||||
"gitlab.com/mbugroup/lti-api.git/internal/database"
|
"gitlab.com/mbugroup/lti-api.git/internal/database"
|
||||||
"gitlab.com/mbugroup/lti-api.git/internal/middleware"
|
"gitlab.com/mbugroup/lti-api.git/internal/middleware"
|
||||||
"gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session"
|
"gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session"
|
||||||
sso "gitlab.com/mbugroup/lti-api.git/internal/modules/sso/verifier"
|
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/route"
|
||||||
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
"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) {
|
func setupRoutes(app *fiber.App, db *gorm.DB, rdb *redis.Client) {
|
||||||
middleware.SetAPIKeyAuthenticator(apikeys.NewService(db))
|
|
||||||
|
|
||||||
// route.Routes(app, db)
|
// route.Routes(app, db)
|
||||||
// app.Use(utils.NotFoundHandler)
|
// 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)
|
return c.Status(status).JSON(body)
|
||||||
})
|
})
|
||||||
|
|
||||||
readAPIRoutes := app.Group("/api")
|
|
||||||
readapi.RegisterRoutes(readAPIRoutes)
|
|
||||||
route.Routes(app, db)
|
route.Routes(app, db)
|
||||||
app.Use(utils.NotFoundHandler)
|
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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 (
|
var (
|
||||||
IsProd bool
|
IsProd bool
|
||||||
AppEnv string
|
|
||||||
AppHost string
|
AppHost string
|
||||||
Version string
|
Version string
|
||||||
LogLevel string
|
LogLevel string
|
||||||
@@ -85,8 +84,7 @@ func init() {
|
|||||||
loadConfig()
|
loadConfig()
|
||||||
|
|
||||||
// server configuration
|
// server configuration
|
||||||
AppEnv = defaultString(strings.TrimSpace(viper.GetString("APP_ENV")), "development")
|
IsProd = viper.GetString("APP_ENV") == "prod"
|
||||||
IsProd = AppEnv == "prod"
|
|
||||||
AppHost = viper.GetString("APP_HOST")
|
AppHost = viper.GetString("APP_HOST")
|
||||||
AppPort = viper.GetInt("APP_PORT")
|
AppPort = viper.GetInt("APP_PORT")
|
||||||
Version = viper.GetString("VERSION")
|
Version = viper.GetString("VERSION")
|
||||||
@@ -113,7 +111,7 @@ func init() {
|
|||||||
// Cors
|
// Cors
|
||||||
CORSAllowOrigins = parseList("CORS_ALLOW_ORIGINS")
|
CORSAllowOrigins = parseList("CORS_ALLOW_ORIGINS")
|
||||||
CORSAllowMethods = parseListWithDefault("CORS_ALLOW_METHODS", "GET,POST,PUT,PATCH,DELETE,OPTIONS")
|
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")
|
CORSExposeHeaders = parseList("CORS_EXPOSE_HEADERS")
|
||||||
CORSAllowCredentials = viper.GetBool("CORS_ALLOW_CREDENTIALS")
|
CORSAllowCredentials = viper.GetBool("CORS_ALLOW_CREDENTIALS")
|
||||||
CORSMaxAge = viper.GetInt("CORS_MAX_AGE")
|
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);
|
|
||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE purchase_items
|
||||||
|
ALTER COLUMN vehicle_number TYPE VARCHAR(10) USING LEFT(vehicle_number, 10);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE purchase_items
|
||||||
|
ALTER COLUMN vehicle_number TYPE VARCHAR(15) USING vehicle_number;
|
||||||
@@ -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)"`
|
UnitPrice float64 `gorm:"type:numeric(15,3)"`
|
||||||
TotalWeight float64 `gorm:"type:numeric(15,3)"`
|
TotalWeight float64 `gorm:"type:numeric(15,3)"`
|
||||||
AvgWeight 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)"`
|
TotalPrice float64 `gorm:"type:numeric(15,3)"`
|
||||||
DeliveryDate *time.Time `gorm:"type:timestamptz"`
|
DeliveryDate *time.Time `gorm:"type:timestamptz"`
|
||||||
VehicleNumber string `gorm:"type:varchar(50)"`
|
VehicleNumber string `gorm:"type:varchar(50)"`
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
"gitlab.com/mbugroup/lti-api.git/internal/apikeys"
|
|
||||||
"gitlab.com/mbugroup/lti-api.git/internal/config"
|
"gitlab.com/mbugroup/lti-api.git/internal/config"
|
||||||
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||||
"gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session"
|
"gitlab.com/mbugroup/lti-api.git/internal/modules/sso/session"
|
||||||
@@ -21,21 +17,11 @@ const (
|
|||||||
authUserLocalsKey = "auth.user"
|
authUserLocalsKey = "auth.user"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
|
||||||
verifyAccessTokenFunc = sso.VerifyAccessToken
|
|
||||||
fetchProfileFunc = sso.FetchProfile
|
|
||||||
|
|
||||||
apiKeyAuthMu sync.RWMutex
|
|
||||||
apiKeyAuthenticator apikeys.Authenticator
|
|
||||||
)
|
|
||||||
|
|
||||||
// AuthContext keeps authentication details captured by the middleware.
|
// AuthContext keeps authentication details captured by the middleware.
|
||||||
type AuthContext struct {
|
type AuthContext struct {
|
||||||
Token string
|
Token string
|
||||||
Verification *sso.VerificationResult
|
Verification *sso.VerificationResult
|
||||||
User *entity.User
|
User *entity.User
|
||||||
PrincipalType string
|
|
||||||
PrincipalName string
|
|
||||||
Roles []sso.Role
|
Roles []sso.Role
|
||||||
Permissions map[string]struct{}
|
Permissions map[string]struct{}
|
||||||
UserAreaIDs []uint
|
UserAreaIDs []uint
|
||||||
@@ -44,13 +30,6 @@ type AuthContext struct {
|
|||||||
UserAllLocation bool
|
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
|
// Auth validates the incoming request against the central SSO access token and
|
||||||
// loads the corresponding local user. Optional scopes can be provided to enforce
|
// loads the corresponding local user. Optional scopes can be provided to enforce
|
||||||
// fine-grained authorization using the SSO access token scopes.
|
// 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 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")
|
return fiber.NewError(fiber.StatusUnauthorized, "Please authenticate")
|
||||||
}
|
}
|
||||||
|
|
||||||
verification, err := verifyAccessTokenFunc(token)
|
verification, err := sso.VerifyAccessToken(token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if sso.IsSignatureError(err) {
|
if sso.IsSignatureError(err) {
|
||||||
logSignatureError("auth", tokenSource, token, err)
|
logSignatureError("auth", tokenSource, token, err)
|
||||||
@@ -130,7 +99,7 @@ func Auth(userService service.UserService, requiredScopes ...string) fiber.Handl
|
|||||||
permissions := make(map[string]struct{})
|
permissions := make(map[string]struct{})
|
||||||
var profile *sso.UserProfile
|
var profile *sso.UserProfile
|
||||||
if verification.UserID != 0 {
|
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")
|
utils.Log.WithError(err).Warn("auth: failed to fetch sso profile")
|
||||||
} else {
|
} else {
|
||||||
profile = p
|
profile = p
|
||||||
@@ -149,8 +118,6 @@ func Auth(userService service.UserService, requiredScopes ...string) fiber.Handl
|
|||||||
Token: token,
|
Token: token,
|
||||||
Verification: verification,
|
Verification: verification,
|
||||||
User: user,
|
User: user,
|
||||||
PrincipalType: "user",
|
|
||||||
PrincipalName: user.Name,
|
|
||||||
Roles: roles,
|
Roles: roles,
|
||||||
Permissions: permissions,
|
Permissions: permissions,
|
||||||
UserAreaIDs: nil,
|
UserAreaIDs: nil,
|
||||||
@@ -252,57 +219,6 @@ func bearerToken(c *fiber.Ctx) string {
|
|||||||
return ""
|
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 {
|
func hasAllScopes(have, required []string) bool {
|
||||||
if len(required) == 0 {
|
if len(required) == 0 {
|
||||||
return true
|
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"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitlab.com/mbugroup/lti-api.git/internal/config"
|
|
||||||
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||||
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/validations"
|
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/dashboards/validations"
|
||||||
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
||||||
@@ -106,15 +105,6 @@ func applyDashboardFilters(db *gorm.DB, filters *validation.DashboardFilter) *go
|
|||||||
return db
|
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) {
|
func (r *DashboardRepositoryImpl) GetRecordingWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]RecordingWeeklyMetric, error) {
|
||||||
var rows []RecordingWeeklyMetric
|
var rows []RecordingWeeklyMetric
|
||||||
|
|
||||||
@@ -150,29 +140,21 @@ func (r *DashboardRepositoryImpl) GetRecordingWeeklyMetrics(ctx context.Context,
|
|||||||
|
|
||||||
func (r *DashboardRepositoryImpl) GetUniformityWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]UniformityWeeklyMetric, error) {
|
func (r *DashboardRepositoryImpl) GetUniformityWeeklyMetrics(ctx context.Context, start, end time.Time, filters *validation.DashboardFilter) ([]UniformityWeeklyMetric, error) {
|
||||||
var rows []UniformityWeeklyMetric
|
var rows []UniformityWeeklyMetric
|
||||||
weekExpr := dashboardUniformityWeekExpr()
|
|
||||||
|
|
||||||
db := r.DB().WithContext(ctx).
|
db := r.DB().WithContext(ctx).
|
||||||
Table("project_flock_kandang_uniformity AS u").
|
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.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,
|
||||||
|
MAX(u.uniform_date) AS uniform_date`).
|
||||||
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = u.project_flock_kandang_id").
|
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 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 IS NOT NULL").
|
||||||
Where("u.uniform_date >= ? AND u.uniform_date < ?", start, end).
|
Where("u.uniform_date >= ? AND u.uniform_date < ?", start, end)
|
||||||
Where("u.uniform_date::date >= pc.chick_in_date")
|
|
||||||
|
|
||||||
db = applyDashboardFilters(db, filters)
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -538,31 +520,23 @@ func (r *DashboardRepositoryImpl) GetComparisonWeeklyUniformityMetrics(ctx conte
|
|||||||
}
|
}
|
||||||
|
|
||||||
var rows []ComparisonUniformityMetric
|
var rows []ComparisonUniformityMetric
|
||||||
weekExpr := dashboardUniformityWeekExpr()
|
|
||||||
db := r.DB().WithContext(ctx).
|
db := r.DB().WithContext(ctx).
|
||||||
Table("project_flock_kandang_uniformity AS u").
|
Table("project_flock_kandang_uniformity AS u").
|
||||||
Select(fmt.Sprintf(`%s AS week,
|
Select(fmt.Sprintf(`u.week AS week,
|
||||||
%s AS series_id,
|
%s AS series_id,
|
||||||
COALESCE(AVG(u.uniformity), 0) AS uniformity,
|
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 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 kandangs AS k ON k.id = pfk.kandang_id").
|
||||||
Joins("JOIN project_flocks AS pf ON pf.id = pfk.project_flock_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 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 IS NOT NULL").
|
||||||
Where("u.uniform_date >= ? AND u.uniform_date < ?", start, end).
|
Where("u.uniform_date >= ? AND u.uniform_date < ?", start, end)
|
||||||
Where("u.uniform_date::date >= pc.chick_in_date")
|
|
||||||
|
|
||||||
db = applyDashboardFilters(db, filters)
|
db = applyDashboardFilters(db, filters)
|
||||||
|
|
||||||
groupBy := fmt.Sprintf("week, %s", groupExpr)
|
groupBy := fmt.Sprintf("u.week, %s", groupExpr)
|
||||||
orderBy := fmt.Sprintf("week ASC, %s", orderExpr)
|
orderBy := fmt.Sprintf("u.week ASC, %s", orderExpr)
|
||||||
if err := db.Group(groupBy).Order(orderBy).Scan(&rows).Error; err != nil {
|
if err := db.Group(groupBy).Order(orderBy).Scan(&rows).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -275,10 +275,10 @@ func (s dashboardService) buildPerformanceCharts(ctx context.Context, params *va
|
|||||||
cumFeed := 0.0
|
cumFeed := 0.0
|
||||||
|
|
||||||
for _, week := range weeks {
|
for _, week := range weeks {
|
||||||
rec, hasRec := recordingMap[week]
|
rec := recordingMap[week]
|
||||||
uni, hasUni := uniformityMap[week]
|
uni := uniformityMap[week]
|
||||||
std, hasStd := standardMap[week]
|
std := standardMap[week]
|
||||||
stdFcr, hasStdFcr := standardFcrMap[week]
|
stdFcr := standardFcrMap[week]
|
||||||
weekEgg := weeklyEggMap[week]
|
weekEgg := weeklyEggMap[week]
|
||||||
weekFeed := weeklyFeedMap[week]
|
weekFeed := weeklyFeedMap[week]
|
||||||
|
|
||||||
@@ -294,69 +294,38 @@ func (s dashboardService) buildPerformanceCharts(ctx context.Context, params *va
|
|||||||
actFcrCum = cumFeed / cumEgg
|
actFcrCum = cumFeed / cumEgg
|
||||||
}
|
}
|
||||||
|
|
||||||
bodyWeightRow := map[string]interface{}{
|
bodyWeightDataset = append(bodyWeightDataset, map[string]interface{}{
|
||||||
"week": week,
|
"week": week,
|
||||||
}
|
"body_weight": roundTo(uni.AverageWeight, 2),
|
||||||
if hasUni {
|
"std_body_weight": roundTo(std.StdBodyWeight, 2),
|
||||||
bodyWeightRow["body_weight"] = roundTo(uni.AverageWeight, 2)
|
})
|
||||||
}
|
bodyWeightDatasetIndexByWeek[week] = len(bodyWeightDataset) - 1
|
||||||
if hasStd {
|
|
||||||
bodyWeightRow["std_body_weight"] = roundTo(std.StdBodyWeight, 2)
|
|
||||||
}
|
|
||||||
if len(bodyWeightRow) > 1 {
|
|
||||||
bodyWeightDataset = append(bodyWeightDataset, bodyWeightRow)
|
|
||||||
}
|
|
||||||
|
|
||||||
performanceRow := map[string]interface{}{
|
performanceDataset = append(performanceDataset, map[string]interface{}{
|
||||||
"week": week,
|
"week": week,
|
||||||
}
|
"act_laying": roundTo(rec.HenDay, 2),
|
||||||
if hasRec {
|
"std_laying": roundTo(std.StdLaying, 2),
|
||||||
performanceRow["act_laying"] = roundTo(rec.HenDay, 2)
|
"act_egg_weight": roundTo(rec.EggWeight, 2),
|
||||||
performanceRow["act_egg_weight"] = roundTo(rec.EggWeight, 2)
|
"std_egg_weight": roundTo(std.StdEggWeight, 2),
|
||||||
performanceRow["act_feed_intake"] = roundTo(rec.FeedIntake, 2)
|
"act_feed_intake": roundTo(rec.FeedIntake, 2),
|
||||||
}
|
"std_feed_intake": roundTo(std.StdFeedIntake, 2),
|
||||||
if hasUni {
|
"act_uniformity": roundTo(uni.Uniformity, 2),
|
||||||
performanceRow["act_uniformity"] = roundTo(uni.Uniformity, 2)
|
"std_uniformity": roundTo(std.StdUniformity, 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
fcrRow := map[string]interface{}{
|
fcrDataset = append(fcrDataset, map[string]interface{}{
|
||||||
"week": week,
|
"week": week,
|
||||||
}
|
"act_fcr": roundTo(actFcr, 2),
|
||||||
if weekEgg > 0 && weekFeed > 0 {
|
"std_fcr": roundTo(stdFcr, 2),
|
||||||
fcrRow["act_fcr"] = roundTo(actFcr, 2)
|
"act_fcr_cum": roundTo(actFcrCum, 2),
|
||||||
}
|
"std_fcr_cum": roundTo(stdFcr, 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
deplesiRow := map[string]interface{}{
|
deplesiDataset = append(deplesiDataset, map[string]interface{}{
|
||||||
"week": week,
|
"week": week,
|
||||||
}
|
"act_deplesi": roundTo(rec.CumDepletionRate, 2),
|
||||||
if hasRec {
|
"std_deplesi": roundTo(std.StdDepletion, 2),
|
||||||
deplesiRow["act_deplesi"] = roundTo(rec.CumDepletionRate, 2)
|
})
|
||||||
}
|
|
||||||
if hasStd {
|
|
||||||
deplesiRow["std_deplesi"] = roundTo(std.StdDepletion, 2)
|
|
||||||
}
|
|
||||||
if len(deplesiRow) > 1 {
|
|
||||||
deplesiDataset = append(deplesiDataset, deplesiRow)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bodyWeightDataset = extendBodyWeightDatasetUntilEndDate(
|
bodyWeightDataset = extendBodyWeightDatasetUntilEndDate(
|
||||||
|
|||||||
@@ -49,30 +49,26 @@ type MarketingDetailDTO struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type MarketingDeliveryProductDTO struct {
|
type MarketingDeliveryProductDTO struct {
|
||||||
Id uint `json:"id"`
|
Id uint `json:"id"`
|
||||||
MarketingProductId uint `json:"marketing_product_id"`
|
MarketingProductId uint `json:"marketing_product_id"`
|
||||||
Qty float64 `json:"qty"`
|
Qty float64 `json:"qty"`
|
||||||
UnitPrice float64 `json:"unit_price"`
|
UnitPrice float64 `json:"unit_price"`
|
||||||
TotalWeight float64 `json:"total_weight"`
|
TotalWeight float64 `json:"total_weight"`
|
||||||
AvgWeight float64 `json:"avg_weight"`
|
AvgWeight float64 `json:"avg_weight"`
|
||||||
TotalPrice float64 `json:"total_price"`
|
TotalPrice float64 `json:"total_price"`
|
||||||
DeliveryDate *time.Time `json:"delivery_date"`
|
DeliveryDate *time.Time `json:"delivery_date"`
|
||||||
VehicleNumber string `json:"vehicle_number"`
|
VehicleNumber string `json:"vehicle_number"`
|
||||||
ConvertionUnit *string `json:"-"`
|
ProductWarehouse *productwarehouseDTO.ProductWarehousNestedDTO `json:"product_warehouse,omitempty"`
|
||||||
WeightPerConvertion *float64 `json:"-"`
|
|
||||||
ProductWarehouse *productwarehouseDTO.ProductWarehousNestedDTO `json:"product_warehouse,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type DeliveryItemDTO struct {
|
type DeliveryItemDTO struct {
|
||||||
ProductWarehouse *productwarehouseDTO.ProductWarehousNestedDTO `json:"product_warehouse"`
|
ProductWarehouse *productwarehouseDTO.ProductWarehousNestedDTO `json:"product_warehouse"`
|
||||||
Qty float64 `json:"qty"`
|
Qty float64 `json:"qty"`
|
||||||
UnitPrice float64 `json:"unit_price"`
|
UnitPrice float64 `json:"unit_price"`
|
||||||
TotalWeight float64 `json:"total_weight"`
|
TotalWeight float64 `json:"total_weight"`
|
||||||
AvgWeight float64 `json:"avg_weight"`
|
AvgWeight float64 `json:"avg_weight"`
|
||||||
WeightPerConvertion *float64 `json:"weight_per_convertion"`
|
TotalPrice float64 `json:"total_price"`
|
||||||
TotalPeti *float64 `json:"total_peti"`
|
VehicleNumber string `json:"vehicle_number"`
|
||||||
TotalPrice float64 `json:"total_price"`
|
|
||||||
VehicleNumber string `json:"vehicle_number"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type DeliveryGroupDTO struct {
|
type DeliveryGroupDTO struct {
|
||||||
@@ -151,16 +147,15 @@ func ToDeliveryMarketingProductDTO(e entity.MarketingProduct, marketingType stri
|
|||||||
|
|
||||||
func ToMarketingDeliveryProductDTO(e entity.MarketingDeliveryProduct) MarketingDeliveryProductDTO {
|
func ToMarketingDeliveryProductDTO(e entity.MarketingDeliveryProduct) MarketingDeliveryProductDTO {
|
||||||
return MarketingDeliveryProductDTO{
|
return MarketingDeliveryProductDTO{
|
||||||
Id: e.Id,
|
Id: e.Id,
|
||||||
MarketingProductId: e.MarketingProductId,
|
MarketingProductId: e.MarketingProductId,
|
||||||
Qty: e.UsageQty,
|
Qty: e.UsageQty,
|
||||||
UnitPrice: e.UnitPrice,
|
UnitPrice: e.UnitPrice,
|
||||||
TotalWeight: e.TotalWeight,
|
TotalWeight: e.TotalWeight,
|
||||||
AvgWeight: e.AvgWeight,
|
AvgWeight: e.AvgWeight,
|
||||||
TotalPrice: e.TotalPrice,
|
TotalPrice: e.TotalPrice,
|
||||||
DeliveryDate: e.DeliveryDate,
|
DeliveryDate: e.DeliveryDate,
|
||||||
VehicleNumber: e.VehicleNumber,
|
VehicleNumber: e.VehicleNumber,
|
||||||
WeightPerConvertion: e.WeightPerConvertion,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,7 +285,6 @@ func enrichDeliveryProductDTOsWithWarehouse(deliveryProductDTOs []MarketingDeliv
|
|||||||
mapped := productwarehouseDTO.ToProductWarehouseNestedDTO(product.ProductWarehouse)
|
mapped := productwarehouseDTO.ToProductWarehouseNestedDTO(product.ProductWarehouse)
|
||||||
deliveryProductDTOs[i].ProductWarehouse = &mapped
|
deliveryProductDTOs[i].ProductWarehouse = &mapped
|
||||||
}
|
}
|
||||||
deliveryProductDTOs[i].ConvertionUnit = product.ConvertionUnit
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,21 +322,13 @@ func groupDeliveryProducts(products []MarketingDeliveryProductDTO, soNumber stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
deliveryItem := DeliveryItemDTO{
|
deliveryItem := DeliveryItemDTO{
|
||||||
ProductWarehouse: product.ProductWarehouse,
|
ProductWarehouse: product.ProductWarehouse,
|
||||||
Qty: product.Qty,
|
Qty: product.Qty,
|
||||||
UnitPrice: product.UnitPrice,
|
UnitPrice: product.UnitPrice,
|
||||||
TotalWeight: product.TotalWeight,
|
TotalWeight: product.TotalWeight,
|
||||||
AvgWeight: product.AvgWeight,
|
AvgWeight: product.AvgWeight,
|
||||||
WeightPerConvertion: product.WeightPerConvertion,
|
TotalPrice: product.TotalPrice,
|
||||||
TotalPrice: product.TotalPrice,
|
VehicleNumber: product.VehicleNumber,
|
||||||
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
|
|
||||||
}
|
}
|
||||||
group.Deliveries = append(group.Deliveries, deliveryItem)
|
group.Deliveries = append(group.Deliveries, deliveryItem)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -376,12 +375,11 @@ func (s *deliveryOrdersService) CreateOne(c *fiber.Ctx, req *validation.Delivery
|
|||||||
itemDeliveryDate = &parsedDate
|
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.ProductWarehouseId = foundMarketingProduct.ProductWarehouseId
|
||||||
deliveryProduct.UnitPrice = requestedProduct.UnitPrice
|
deliveryProduct.UnitPrice = requestedProduct.UnitPrice
|
||||||
deliveryProduct.AvgWeight = requestedProduct.AvgWeight
|
deliveryProduct.AvgWeight = requestedProduct.AvgWeight
|
||||||
deliveryProduct.WeightPerConvertion = requestedProduct.WeightPerConvertion
|
|
||||||
deliveryProduct.TotalWeight = totalWeight
|
deliveryProduct.TotalWeight = totalWeight
|
||||||
deliveryProduct.TotalPrice = totalPrice
|
deliveryProduct.TotalPrice = totalPrice
|
||||||
deliveryProduct.DeliveryDate = itemDeliveryDate
|
deliveryProduct.DeliveryDate = itemDeliveryDate
|
||||||
@@ -500,12 +498,11 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO
|
|||||||
itemDeliveryDate = deliveryProduct.DeliveryDate
|
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.ProductWarehouseId = foundMarketingProduct.ProductWarehouseId
|
||||||
deliveryProduct.UnitPrice = requestedProduct.UnitPrice
|
deliveryProduct.UnitPrice = requestedProduct.UnitPrice
|
||||||
deliveryProduct.AvgWeight = requestedProduct.AvgWeight
|
deliveryProduct.AvgWeight = requestedProduct.AvgWeight
|
||||||
deliveryProduct.WeightPerConvertion = requestedProduct.WeightPerConvertion
|
|
||||||
deliveryProduct.TotalWeight = totalWeight
|
deliveryProduct.TotalWeight = totalWeight
|
||||||
deliveryProduct.TotalPrice = totalPrice
|
deliveryProduct.TotalPrice = totalPrice
|
||||||
deliveryProduct.DeliveryDate = itemDeliveryDate
|
deliveryProduct.DeliveryDate = itemDeliveryDate
|
||||||
@@ -544,53 +541,20 @@ func (s deliveryOrdersService) UpdateOne(c *fiber.Ctx, req *validation.DeliveryO
|
|||||||
return s.getMarketingWithDeliveries(c, id)
|
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) {
|
if marketingType == string(utils.MarketingTypeTrading) {
|
||||||
totalWeight = 0
|
totalWeight = 0
|
||||||
totalPrice = math.Round(qty*unitPrice*100) / 100
|
totalPrice = qty * unitPrice
|
||||||
} else if marketingType == string(utils.MarketingTypeAyamPullet) && week != nil && *week > 0 {
|
} else if marketingType == string(utils.MarketingTypeAyamPullet) && week != nil && *week > 0 {
|
||||||
totalWeight = math.Round(qty*avgWeight*100) / 100
|
totalWeight = qty * avgWeight
|
||||||
totalPrice = math.Round(unitPrice*float64(*week)*qty*100) / 100
|
totalPrice = unitPrice * float64(*week) * qty
|
||||||
} else {
|
} else {
|
||||||
totalWeight = math.Round(qty*avgWeight*100) / 100
|
totalWeight = qty * avgWeight
|
||||||
|
totalPrice = totalWeight * unitPrice
|
||||||
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
|
|
||||||
}
|
}
|
||||||
return totalWeight, totalPrice
|
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 {
|
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 {
|
if marketingProduct == nil || marketingProduct.ProductWarehouseId == 0 {
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "Product warehouse not found")
|
return fiber.NewError(fiber.StatusInternalServerError, "Product warehouse not found")
|
||||||
|
|||||||
@@ -815,7 +815,7 @@ func (s *salesOrdersService) createMarketingProductWithDelivery(ctx context.Cont
|
|||||||
return nil
|
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) {
|
if marketingType == string(utils.MarketingTypeTrading) {
|
||||||
totalWeight = 0
|
totalWeight = 0
|
||||||
totalPrice = math.Round(qty*unitPrice*100) / 100
|
totalPrice = math.Round(qty*unitPrice*100) / 100
|
||||||
@@ -831,8 +831,11 @@ func (s *salesOrdersService) calculatePriceByMarketingType(marketingType string,
|
|||||||
totalPrice = math.Round(qty*unitPrice*100) / 100
|
totalPrice = math.Round(qty*unitPrice*100) / 100
|
||||||
return totalWeight, totalPrice
|
return totalWeight, totalPrice
|
||||||
case string(utils.ConvertionUnitPeti):
|
case string(utils.ConvertionUnitPeti):
|
||||||
totalPrice = math.Round(totalWeight*unitPrice*100) / 100
|
if weightPerConvertion != nil && *weightPerConvertion > 0 {
|
||||||
return totalWeight, totalPrice
|
totalPeti := totalWeight / *weightPerConvertion
|
||||||
|
totalPrice = math.Round(totalPeti*unitPrice*100) / 100
|
||||||
|
return totalWeight, totalPrice
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,12 @@
|
|||||||
package validation
|
package validation
|
||||||
|
|
||||||
type DeliveryProduct struct {
|
type DeliveryProduct struct {
|
||||||
MarketingProductId uint `json:"marketing_product_id" validate:"required,gt=0"`
|
MarketingProductId uint `json:"marketing_product_id" validate:"required,gt=0"`
|
||||||
Qty float64 `json:"qty" validate:"omitempty,gte=0"`
|
Qty float64 `json:"qty" validate:"omitempty,gte=0"`
|
||||||
UnitPrice float64 `json:"unit_price" validate:"omitempty,gte=0"`
|
UnitPrice float64 `json:"unit_price" validate:"omitempty,gte=0"`
|
||||||
AvgWeight float64 `json:"avg_weight" validate:"omitempty,gte=0"`
|
AvgWeight float64 `json:"avg_weight" validate:"omitempty,gte=0"`
|
||||||
WeightPerConvertion *float64 `json:"weight_per_convertion" validate:"omitempty,gt=0"`
|
DeliveryDate string `json:"delivery_date" validate:"omitempty,datetime=2006-01-02"`
|
||||||
TotalWeight *float64 `json:"total_weight" validate:"omitempty,gte=0"`
|
VehicleNumber string `json:"vehicle_number" validate:"omitempty,max=50"`
|
||||||
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"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type DeliveryOrderCreate struct {
|
type DeliveryOrderCreate struct {
|
||||||
|
|||||||
@@ -42,9 +42,7 @@ func (r *UniformityRepositoryImpl) GetAllWithFilters(ctx context.Context, offset
|
|||||||
func (r *UniformityRepositoryImpl) WithDefaultRelations() func(*gorm.DB) *gorm.DB {
|
func (r *UniformityRepositoryImpl) WithDefaultRelations() func(*gorm.DB) *gorm.DB {
|
||||||
return func(db *gorm.DB) *gorm.DB {
|
return func(db *gorm.DB) *gorm.DB {
|
||||||
return db.
|
return db.
|
||||||
Preload("ProjectFlockKandang.ProjectFlock").
|
|
||||||
Preload("ProjectFlockKandang.ProjectFlock.Location").
|
Preload("ProjectFlockKandang.ProjectFlock.Location").
|
||||||
Preload("ProjectFlockKandang.Chickins").
|
|
||||||
Preload("ProjectFlockKandang.Kandang.Location")
|
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)
|
s.Log.Errorf("Failed to get uniformitys: %+v", err)
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
s.normalizeUniformityWeeks(uniformitys)
|
|
||||||
if err := s.attachLatestApprovals(c.Context(), uniformitys); err != nil {
|
if err := s.attachLatestApprovals(c.Context(), uniformitys); err != nil {
|
||||||
return nil, 0, err
|
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)
|
s.Log.Errorf("Failed get uniformity by id: %+v", err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
s.normalizeUniformityWeek(uniformity)
|
|
||||||
if err := s.attachLatestApproval(c.Context(), uniformity); err != nil {
|
if err := s.attachLatestApproval(c.Context(), uniformity); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -137,23 +135,6 @@ func (s uniformityService) GetSummary(c *fiber.Ctx, id uint) (*entity.ProjectFlo
|
|||||||
return s.GetOne(c, id)
|
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) {
|
func (s uniformityService) GetStandard(c *fiber.Ctx, uniformity *entity.ProjectFlockKandangUniformity) (*utypes.UniformityStandard, error) {
|
||||||
if uniformity == nil {
|
if uniformity == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -391,18 +372,24 @@ func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file
|
|||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
computedWeek, err := s.computeUniformityWeekForPFK(pfk, uniformDate)
|
category := strings.TrimSpace(pfk.ProjectFlock.Category)
|
||||||
if err != nil {
|
if s.ProductionStandardRepo != nil && pfk.ProjectFlock.ProductionStandardId != 0 {
|
||||||
return nil, err
|
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))
|
weekBase := 1
|
||||||
if req.Week > 0 && req.Week != computedWeek {
|
isLayingCategory := strings.EqualFold(category, string(utils.ProjectFlockCategoryLaying))
|
||||||
s.Log.WithFields(logrus.Fields{
|
if isLayingCategory {
|
||||||
"project_flock_kandang_id": req.ProjectFlockKandangId,
|
weekBase = config.LayingWeekStart()
|
||||||
"uniform_date": uniformDate.Format("2006-01-02"),
|
}
|
||||||
"requested_week": req.Week,
|
if req.Week < weekBase {
|
||||||
"computed_week": computedWeek,
|
if !isLayingCategory {
|
||||||
}).Warn("Uniformity week mismatch detected; using computed week")
|
return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 1 for growing projects")
|
||||||
|
}
|
||||||
|
// return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("week must start from %d for laying projects", weekBase))
|
||||||
}
|
}
|
||||||
|
|
||||||
var latestWeek int
|
var latestWeek int
|
||||||
@@ -413,14 +400,17 @@ func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file
|
|||||||
Scan(&latestWeek).Error; err != nil {
|
Scan(&latestWeek).Error; err != nil {
|
||||||
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate uniformity week sequence")
|
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate uniformity week sequence")
|
||||||
}
|
}
|
||||||
if latestWeek > 0 && computedWeek > latestWeek+1 {
|
if latestWeek == 0 && req.Week != weekBase {
|
||||||
return nil, fiber.NewError(fiber.StatusBadRequest, "week must be sequential without skipping")
|
if !isLayingCategory {
|
||||||
|
return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 1 for growing projects")
|
||||||
|
}
|
||||||
|
// return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("week must start from %d for laying projects", weekBase))
|
||||||
}
|
}
|
||||||
// if latestWeek > 0 && req.Week > latestWeek+1 {
|
// if latestWeek > 0 && req.Week > latestWeek+1 {
|
||||||
// return nil, fiber.NewError(fiber.StatusBadRequest, "week must be sequential without skipping")
|
// 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
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,7 +438,7 @@ func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file
|
|||||||
|
|
||||||
createBody := &entity.ProjectFlockKandangUniformity{
|
createBody := &entity.ProjectFlockKandangUniformity{
|
||||||
Uniformity: calculation.Uniformity,
|
Uniformity: calculation.Uniformity,
|
||||||
Week: computedWeek,
|
Week: req.Week,
|
||||||
Cv: calculation.Cv,
|
Cv: calculation.Cv,
|
||||||
ChickQtyOfWeight: calculation.ChickQtyOfWeight,
|
ChickQtyOfWeight: calculation.ChickQtyOfWeight,
|
||||||
MeanUp: calculation.MeanUp,
|
MeanUp: calculation.MeanUp,
|
||||||
@@ -477,7 +467,7 @@ func (s *uniformityService) CreateOne(c *fiber.Ctx, req *validation.Create, file
|
|||||||
); err != nil {
|
); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if isGrowingCategory {
|
if strings.EqualFold(category, string(utils.ProjectFlockCategoryGrowing)) {
|
||||||
if err := s.updateGrowingFcrForWeek(tx, createBody.ProjectFlockKandangId, createBody.Week, calculation.MeanUp); err != nil {
|
if err := s.updateGrowingFcrForWeek(tx, createBody.ProjectFlockKandangId, createBody.Week, calculation.MeanUp); err != nil {
|
||||||
return err
|
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
|
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 {
|
if req.Date != nil || req.ProjectFlockKandangId != nil || req.Week != nil {
|
||||||
current, err := s.Repository.GetByID(c.Context(), id, 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 {
|
if targetDate == nil {
|
||||||
targetDate = current.UniformDate
|
targetDate = current.UniformDate
|
||||||
}
|
}
|
||||||
|
targetWeek := current.Week
|
||||||
|
if req.Week != nil {
|
||||||
|
targetWeek = *req.Week
|
||||||
|
}
|
||||||
targetPFKID := current.ProjectFlockKandangId
|
targetPFKID := current.ProjectFlockKandangId
|
||||||
if req.ProjectFlockKandangId != nil {
|
if req.ProjectFlockKandangId != nil {
|
||||||
targetPFKID = *req.ProjectFlockKandangId
|
targetPFKID = *req.ProjectFlockKandangId
|
||||||
}
|
}
|
||||||
if targetPFKID != 0 && targetDate != nil {
|
if targetPFKID != 0 && targetWeek > 0 {
|
||||||
pfk, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), targetPFKID)
|
pfk, err := s.ProjectFlockKandangRepo.GetByID(c.Context(), targetPFKID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
computedWeek, err := s.computeUniformityWeekForPFK(pfk, *targetDate)
|
category := strings.TrimSpace(pfk.ProjectFlock.Category)
|
||||||
if err != nil {
|
if s.ProductionStandardRepo != nil && pfk.ProjectFlock.ProductionStandardId != 0 {
|
||||||
return nil, err
|
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 {
|
weekBase := 1
|
||||||
s.Log.WithFields(logrus.Fields{
|
isLayingCategory := strings.EqualFold(category, string(utils.ProjectFlockCategoryLaying))
|
||||||
"uniformity_id": id,
|
if isLayingCategory {
|
||||||
"project_flock_kandang_id": targetPFKID,
|
weekBase = config.LayingWeekStart()
|
||||||
"uniform_date": targetDate.Format("2006-01-02"),
|
|
||||||
"requested_week": *req.Week,
|
|
||||||
"computed_week": computedWeek,
|
|
||||||
}).Warn("Uniformity week mismatch detected on update; using computed week")
|
|
||||||
}
|
}
|
||||||
updateBody["week"] = computedWeek
|
if targetWeek < weekBase {
|
||||||
if err := s.ensureUniqueUniformity(c.Context(), id, targetPFKID, computedWeek); err != nil {
|
if !isLayingCategory {
|
||||||
|
return nil, fiber.NewError(fiber.StatusBadRequest, "week must start from 1 for growing projects")
|
||||||
|
}
|
||||||
|
return nil, fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("week must start from %d for laying projects", weekBase))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if targetDate != nil {
|
||||||
|
if err := s.ensureUniqueUniformity(c.Context(), id, targetPFKID, targetWeek); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -730,51 +734,6 @@ func (s *uniformityService) ensureUniqueUniformity(ctx context.Context, id uint,
|
|||||||
return nil
|
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 {
|
func (s uniformityService) DeleteOne(c *fiber.Ctx, id uint) error {
|
||||||
if err := m.EnsureUniformityAccess(c, s.Repository.DB(), id); err != nil {
|
if err := m.EnsureUniformityAccess(c, s.Repository.DB(), id); err != nil {
|
||||||
return err
|
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 {
|
type Create struct {
|
||||||
Date string `form:"date" validate:"required"`
|
Date string `form:"date" validate:"required"`
|
||||||
ProjectFlockKandangId uint `form:"project_flock_kandang_id" validate:"required,number,min=1"`
|
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 {
|
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")
|
return nil, nil, fiber.NewError(fiber.StatusBadRequest, "project_flock_kandang_id is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
week := 0
|
|
||||||
weekStr := strings.TrimSpace(c.FormValue("week"))
|
weekStr := strings.TrimSpace(c.FormValue("week"))
|
||||||
if weekStr != "" {
|
if weekStr == "" {
|
||||||
parsedWeek, err := strconv.Atoi(weekStr)
|
return nil, nil, fiber.NewError(fiber.StatusBadRequest, "week is required")
|
||||||
if err != nil || parsedWeek <= 0 {
|
}
|
||||||
return nil, nil, fiber.NewError(fiber.StatusBadRequest, "week is invalid")
|
|
||||||
}
|
week, err := strconv.Atoi(weekStr)
|
||||||
week = parsedWeek
|
if err != nil || week <= 0 {
|
||||||
|
return nil, nil, fiber.NewError(fiber.StatusBadRequest, "week is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
file, err := c.FormFile("document")
|
file, err := c.FormFile("document")
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
|
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
|
||||||
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
|
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
|
||||||
@@ -46,7 +47,8 @@ type PurchaseService interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
priceTolerance = 0.0001
|
priceTolerance = 0.0001
|
||||||
|
purchaseVehicleNumberMaxLength = 15
|
||||||
)
|
)
|
||||||
|
|
||||||
type purchaseService struct {
|
type purchaseService struct {
|
||||||
@@ -1158,6 +1160,15 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
|
|||||||
if payload.VehicleNumber != nil {
|
if payload.VehicleNumber != nil {
|
||||||
val := strings.TrimSpace(*payload.VehicleNumber)
|
val := strings.TrimSpace(*payload.VehicleNumber)
|
||||||
if val != "" {
|
if val != "" {
|
||||||
|
if utf8.RuneCountInString(val) > purchaseVehicleNumberMaxLength {
|
||||||
|
return nil, utils.BadRequest(
|
||||||
|
fmt.Sprintf(
|
||||||
|
"vehicle_number for item %d must be at most %d characters",
|
||||||
|
payload.PurchaseItemID,
|
||||||
|
purchaseVehicleNumberMaxLength,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
vehicleNumber = &val
|
vehicleNumber = &val
|
||||||
} else {
|
} else {
|
||||||
clearVehicle = true
|
clearVehicle = true
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ type ReceivePurchaseItemRequest struct {
|
|||||||
TransportPerItem *float64 `form:"transport_per_item" json:"transport_per_item,omitempty" validate:"omitempty,gte=0"`
|
TransportPerItem *float64 `form:"transport_per_item" json:"transport_per_item,omitempty" validate:"omitempty,gte=0"`
|
||||||
TravelNumber *string `form:"travel_number" json:"travel_number" validate:"omitempty,max=100"`
|
TravelNumber *string `form:"travel_number" json:"travel_number" validate:"omitempty,max=100"`
|
||||||
TravelDocumentPath *string `form:"travel_document_path" json:"travel_document_path" validate:"omitempty,max=1024"`
|
TravelDocumentPath *string `form:"travel_document_path" json:"travel_document_path" validate:"omitempty,max=1024"`
|
||||||
VehicleNumber *string `form:"vehicle_number" json:"vehicle_number" validate:"omitempty,max=100"`
|
VehicleNumber *string `form:"vehicle_number" json:"vehicle_number" validate:"omitempty,max=15"`
|
||||||
ReceivedQty *float64 `form:"received_qty" json:"received_qty" validate:"omitempty,gte=0"`
|
ReceivedQty *float64 `form:"received_qty" json:"received_qty" validate:"omitempty,gte=0"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -939,27 +939,12 @@ func (s *repportService) getUniformityByWeek(ctx context.Context, projectFlockKa
|
|||||||
return result, nil
|
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
|
var rows []entity.ProjectFlockKandangUniformity
|
||||||
if err := s.db.WithContext(ctx).
|
if err := s.db.WithContext(ctx).
|
||||||
Table("project_flock_kandang_uniformity AS u").
|
Model(&entity.ProjectFlockKandangUniformity{}).
|
||||||
Select(fmt.Sprintf("%s AS week, u.uniformity, u.uniform_date, u.id, u.chart_data", weekExpr)).
|
Select("week, uniformity, uniform_date, id, chart_data").
|
||||||
Joins("JOIN project_flock_kandangs AS pfk ON pfk.id = u.project_flock_kandang_id").
|
Where("project_flock_kandang_id = ?", projectFlockKandangID).
|
||||||
Joins("JOIN project_flocks AS pf ON pf.id = pfk.project_flock_id").
|
Where("week IN ?", weeks).
|
||||||
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).
|
|
||||||
Order("uniform_date DESC").
|
Order("uniform_date DESC").
|
||||||
Order("id DESC").
|
Order("id DESC").
|
||||||
Find(&rows).Error; err != nil {
|
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