mirror of
https://gitlab.com/mbugroup/lti-api.git
synced 2026-05-22 22:35:43 +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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,587 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/xuri/excelize/v2"
|
|
||||||
"gitlab.com/mbugroup/lti-api.git/internal/config"
|
|
||||||
"gitlab.com/mbugroup/lti-api.git/internal/database"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
type importOptions struct {
|
|
||||||
FilePath string
|
|
||||||
Sheet string
|
|
||||||
Apply bool
|
|
||||||
}
|
|
||||||
|
|
||||||
type headerIndexes struct {
|
|
||||||
AdjustmentID int
|
|
||||||
Weight int
|
|
||||||
}
|
|
||||||
|
|
||||||
type adjustmentPriceImportRow struct {
|
|
||||||
RowNumber int
|
|
||||||
AdjustmentID uint
|
|
||||||
Weight float64
|
|
||||||
}
|
|
||||||
|
|
||||||
type validationIssue struct {
|
|
||||||
Row int
|
|
||||||
Field string
|
|
||||||
Message string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (i validationIssue) Error() string {
|
|
||||||
if i.Row > 0 {
|
|
||||||
return fmt.Sprintf("row=%d field=%s message=%s", i.Row, i.Field, i.Message)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("field=%s message=%s", i.Field, i.Message)
|
|
||||||
}
|
|
||||||
|
|
||||||
type adjustmentResolver interface {
|
|
||||||
ResolveExistingAdjustmentIDs(ctx context.Context, adjustmentIDs []uint) (map[uint]struct{}, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type dbAdjustmentResolver struct {
|
|
||||||
db *gorm.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
type adjustmentPriceStore interface {
|
|
||||||
UpdatePrice(ctx context.Context, adjustmentID uint, price float64) (bool, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type txRunner interface {
|
|
||||||
InTx(ctx context.Context, fn func(store adjustmentPriceStore) error) error
|
|
||||||
}
|
|
||||||
|
|
||||||
type dbTxRunner struct {
|
|
||||||
db *gorm.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
type dbAdjustmentPriceStore struct {
|
|
||||||
db *gorm.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
type applyRowResult struct {
|
|
||||||
RowNumber int
|
|
||||||
AdjustmentID uint
|
|
||||||
Price float64
|
|
||||||
Changed bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
var opts importOptions
|
|
||||||
flag.StringVar(&opts.FilePath, "file", "", "Path to .xlsx file (required)")
|
|
||||||
flag.StringVar(&opts.Sheet, "sheet", "", "Sheet name (optional, default: first sheet)")
|
|
||||||
flag.BoolVar(&opts.Apply, "apply", false, "Apply changes. If false, run as dry-run")
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
opts.FilePath = strings.TrimSpace(opts.FilePath)
|
|
||||||
opts.Sheet = strings.TrimSpace(opts.Sheet)
|
|
||||||
|
|
||||||
if opts.FilePath == "" {
|
|
||||||
log.Fatal("--file is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
sheetName, rows, parseIssues, err := parseAdjustmentPriceFile(opts.FilePath, opts.Sheet)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("failed reading excel: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
db := database.Connect(config.DBHost, config.DBName)
|
|
||||||
resolver := dbAdjustmentResolver{db: db}
|
|
||||||
|
|
||||||
existingAdjustmentIDs, err := resolver.ResolveExistingAdjustmentIDs(ctx, collectAdjustmentIDs(rows))
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("failed checking adjustment_id against adjustment_stocks: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
processableRows, skippedRows := splitRowsByExistingIDs(rows, existingAdjustmentIDs)
|
|
||||||
issues := append([]validationIssue{}, parseIssues...)
|
|
||||||
sortValidationIssues(issues)
|
|
||||||
|
|
||||||
fmt.Printf("Mode: %s\n", modeLabel(opts.Apply))
|
|
||||||
fmt.Printf("File: %s\n", opts.FilePath)
|
|
||||||
fmt.Printf("Sheet: %s\n", sheetName)
|
|
||||||
fmt.Printf("Rows parsed: %d\n", len(rows))
|
|
||||||
fmt.Printf("Rows invalid: %d\n", len(issues))
|
|
||||||
fmt.Printf("Rows processable: %d\n", len(processableRows))
|
|
||||||
fmt.Printf("Rows skipped_missing: %d\n", len(skippedRows))
|
|
||||||
fmt.Println()
|
|
||||||
|
|
||||||
if len(processableRows) > 0 {
|
|
||||||
printPlanRows(processableRows)
|
|
||||||
}
|
|
||||||
if len(skippedRows) > 0 {
|
|
||||||
printSkippedRows(skippedRows)
|
|
||||||
}
|
|
||||||
if len(processableRows) > 0 || len(skippedRows) > 0 {
|
|
||||||
fmt.Println()
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(issues) > 0 {
|
|
||||||
fmt.Println("Validation errors:")
|
|
||||||
for _, issue := range issues {
|
|
||||||
fmt.Printf("ERROR %s\n", issue.Error())
|
|
||||||
}
|
|
||||||
fmt.Println()
|
|
||||||
fmt.Printf(
|
|
||||||
"Summary: planned=%d processable=%d skipped_missing=%d applied=0 failed=%d\n",
|
|
||||||
len(rows),
|
|
||||||
len(processableRows),
|
|
||||||
len(skippedRows),
|
|
||||||
len(issues),
|
|
||||||
)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !opts.Apply {
|
|
||||||
fmt.Printf(
|
|
||||||
"Summary: planned=%d processable=%d skipped_missing=%d applied=0 failed=0\n",
|
|
||||||
len(rows),
|
|
||||||
len(processableRows),
|
|
||||||
len(skippedRows),
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
results, err := applyIfRequested(ctx, true, dbTxRunner{db: db}, processableRows)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("apply failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, result := range results {
|
|
||||||
fmt.Printf(
|
|
||||||
"DONE row=%d adjustment_id=%d price=%.3f status=%s\n",
|
|
||||||
result.RowNumber,
|
|
||||||
result.AdjustmentID,
|
|
||||||
result.Price,
|
|
||||||
applyStatus(result.Changed),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
appliedCount := countChangedRows(results)
|
|
||||||
if len(results) > 0 {
|
|
||||||
fmt.Println()
|
|
||||||
}
|
|
||||||
fmt.Printf(
|
|
||||||
"Summary: planned=%d processable=%d skipped_missing=%d applied=%d failed=0\n",
|
|
||||||
len(rows),
|
|
||||||
len(processableRows),
|
|
||||||
len(skippedRows),
|
|
||||||
appliedCount,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseAdjustmentPriceFile(
|
|
||||||
filePath string,
|
|
||||||
requestedSheet string,
|
|
||||||
) (string, []adjustmentPriceImportRow, []validationIssue, error) {
|
|
||||||
workbook, err := excelize.OpenFile(filePath)
|
|
||||||
if err != nil {
|
|
||||||
return "", nil, nil, err
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
_ = workbook.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
sheetName, err := resolveSheetName(workbook, requestedSheet)
|
|
||||||
if err != nil {
|
|
||||||
return "", nil, nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
allRows, err := workbook.GetRows(sheetName, excelize.Options{RawCellValue: true})
|
|
||||||
if err != nil {
|
|
||||||
return "", nil, nil, err
|
|
||||||
}
|
|
||||||
if len(allRows) == 0 {
|
|
||||||
return sheetName, nil, []validationIssue{{Field: "header", Message: "sheet is empty"}}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
indexes, headerIssues := parseHeaderIndexes(allRows[0])
|
|
||||||
if len(headerIssues) > 0 {
|
|
||||||
return sheetName, nil, headerIssues, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
rowsByAdjustmentID := make(map[uint]adjustmentPriceImportRow)
|
|
||||||
issues := make([]validationIssue, 0)
|
|
||||||
|
|
||||||
for idx := 1; idx < len(allRows); idx++ {
|
|
||||||
rowNumber := idx + 1
|
|
||||||
rawRow := allRows[idx]
|
|
||||||
|
|
||||||
if isRowEmpty(rawRow) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
parsed, rowIssues := parseDataRow(rawRow, rowNumber, indexes)
|
|
||||||
if len(rowIssues) > 0 {
|
|
||||||
issues = append(issues, rowIssues...)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
rowsByAdjustmentID[parsed.AdjustmentID] = *parsed
|
|
||||||
}
|
|
||||||
|
|
||||||
rows := make([]adjustmentPriceImportRow, 0, len(rowsByAdjustmentID))
|
|
||||||
for _, row := range rowsByAdjustmentID {
|
|
||||||
rows = append(rows, row)
|
|
||||||
}
|
|
||||||
sort.Slice(rows, func(i, j int) bool {
|
|
||||||
return rows[i].RowNumber < rows[j].RowNumber
|
|
||||||
})
|
|
||||||
|
|
||||||
if len(rows) == 0 && len(issues) == 0 {
|
|
||||||
issues = append(issues, validationIssue{Field: "rows", Message: "no data rows found"})
|
|
||||||
}
|
|
||||||
|
|
||||||
return sheetName, rows, issues, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveSheetName(workbook *excelize.File, requestedSheet string) (string, error) {
|
|
||||||
if workbook == nil {
|
|
||||||
return "", fmt.Errorf("workbook is nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
sheets := workbook.GetSheetList()
|
|
||||||
if len(sheets) == 0 {
|
|
||||||
return "", fmt.Errorf("workbook has no sheets")
|
|
||||||
}
|
|
||||||
|
|
||||||
if requestedSheet == "" {
|
|
||||||
return sheets[0], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, sheet := range sheets {
|
|
||||||
if strings.EqualFold(strings.TrimSpace(sheet), strings.TrimSpace(requestedSheet)) {
|
|
||||||
return sheet, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", fmt.Errorf("sheet %q not found", requestedSheet)
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseHeaderIndexes(headerRow []string) (headerIndexes, []validationIssue) {
|
|
||||||
indexes := headerIndexes{AdjustmentID: -1, Weight: -1}
|
|
||||||
issues := make([]validationIssue, 0)
|
|
||||||
|
|
||||||
for idx, raw := range headerRow {
|
|
||||||
header := normalizeHeader(raw)
|
|
||||||
if header == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
switch header {
|
|
||||||
case "adjustment_id":
|
|
||||||
if indexes.AdjustmentID >= 0 {
|
|
||||||
issues = append(issues, validationIssue{Field: "header", Message: "duplicate header adjustment_id"})
|
|
||||||
}
|
|
||||||
indexes.AdjustmentID = idx
|
|
||||||
case "weight":
|
|
||||||
if indexes.Weight >= 0 {
|
|
||||||
issues = append(issues, validationIssue{Field: "header", Message: "duplicate header weight"})
|
|
||||||
}
|
|
||||||
indexes.Weight = idx
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if indexes.AdjustmentID < 0 {
|
|
||||||
issues = append(issues, validationIssue{Field: "adjustment_id", Message: "required header is missing"})
|
|
||||||
}
|
|
||||||
if indexes.Weight < 0 {
|
|
||||||
issues = append(issues, validationIssue{Field: "weight", Message: "required header is missing"})
|
|
||||||
}
|
|
||||||
|
|
||||||
return indexes, issues
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseDataRow(
|
|
||||||
rawRow []string,
|
|
||||||
rowNumber int,
|
|
||||||
indexes headerIndexes,
|
|
||||||
) (*adjustmentPriceImportRow, []validationIssue) {
|
|
||||||
issues := make([]validationIssue, 0)
|
|
||||||
|
|
||||||
adjustmentIDRaw := strings.TrimSpace(cellValue(rawRow, indexes.AdjustmentID))
|
|
||||||
adjustmentID, err := parsePositiveUint(adjustmentIDRaw)
|
|
||||||
if err != nil {
|
|
||||||
issues = append(issues, validationIssue{Row: rowNumber, Field: "adjustment_id", Message: err.Error()})
|
|
||||||
}
|
|
||||||
|
|
||||||
weightRaw := strings.TrimSpace(cellValue(rawRow, indexes.Weight))
|
|
||||||
weight, err := parseNonNegativeFloat(weightRaw)
|
|
||||||
if err != nil {
|
|
||||||
issues = append(issues, validationIssue{Row: rowNumber, Field: "weight", Message: err.Error()})
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(issues) > 0 {
|
|
||||||
return nil, issues
|
|
||||||
}
|
|
||||||
|
|
||||||
return &adjustmentPriceImportRow{
|
|
||||||
RowNumber: rowNumber,
|
|
||||||
AdjustmentID: adjustmentID,
|
|
||||||
Weight: weight,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parsePositiveUint(raw string) (uint, error) {
|
|
||||||
if raw == "" {
|
|
||||||
return 0, fmt.Errorf("is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
uintValue, err := strconv.ParseUint(raw, 10, 64)
|
|
||||||
if err == nil {
|
|
||||||
if uintValue == 0 {
|
|
||||||
return 0, fmt.Errorf("must be greater than 0")
|
|
||||||
}
|
|
||||||
return uint(uintValue), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
floatValue, floatErr := strconv.ParseFloat(raw, 64)
|
|
||||||
if floatErr != nil {
|
|
||||||
return 0, fmt.Errorf("must be a positive integer")
|
|
||||||
}
|
|
||||||
if floatValue <= 0 {
|
|
||||||
return 0, fmt.Errorf("must be greater than 0")
|
|
||||||
}
|
|
||||||
if floatValue != float64(uint(floatValue)) {
|
|
||||||
return 0, fmt.Errorf("must be a positive integer")
|
|
||||||
}
|
|
||||||
|
|
||||||
return uint(floatValue), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseNonNegativeFloat(raw string) (float64, error) {
|
|
||||||
if raw == "" {
|
|
||||||
return 0, fmt.Errorf("is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
value, err := strconv.ParseFloat(raw, 64)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("must be numeric")
|
|
||||||
}
|
|
||||||
if value < 0 {
|
|
||||||
return 0, fmt.Errorf("must be greater than or equal to 0")
|
|
||||||
}
|
|
||||||
|
|
||||||
return value, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func isRowEmpty(row []string) bool {
|
|
||||||
for _, cell := range row {
|
|
||||||
if strings.TrimSpace(cell) != "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeHeader(raw string) string {
|
|
||||||
return strings.ToLower(strings.TrimSpace(raw))
|
|
||||||
}
|
|
||||||
|
|
||||||
func cellValue(row []string, index int) string {
|
|
||||||
if index < 0 || index >= len(row) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return row[index]
|
|
||||||
}
|
|
||||||
|
|
||||||
func collectAdjustmentIDs(rows []adjustmentPriceImportRow) []uint {
|
|
||||||
ids := make([]uint, 0, len(rows))
|
|
||||||
seen := make(map[uint]struct{}, len(rows))
|
|
||||||
for _, row := range rows {
|
|
||||||
if row.AdjustmentID == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, exists := seen[row.AdjustmentID]; exists {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[row.AdjustmentID] = struct{}{}
|
|
||||||
ids = append(ids, row.AdjustmentID)
|
|
||||||
}
|
|
||||||
sort.Slice(ids, func(i, j int) bool {
|
|
||||||
return ids[i] < ids[j]
|
|
||||||
})
|
|
||||||
return ids
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r dbAdjustmentResolver) ResolveExistingAdjustmentIDs(
|
|
||||||
ctx context.Context,
|
|
||||||
adjustmentIDs []uint,
|
|
||||||
) (map[uint]struct{}, error) {
|
|
||||||
result := make(map[uint]struct{})
|
|
||||||
if len(adjustmentIDs) == 0 {
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type adjustmentIDRow struct {
|
|
||||||
ID uint `gorm:"column:id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
rows := make([]adjustmentIDRow, 0, len(adjustmentIDs))
|
|
||||||
if err := r.db.WithContext(ctx).
|
|
||||||
Table("adjustment_stocks").
|
|
||||||
Select("id").
|
|
||||||
Where("id IN ?", adjustmentIDs).
|
|
||||||
Scan(&rows).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, row := range rows {
|
|
||||||
result[row.ID] = struct{}{}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func splitRowsByExistingIDs(
|
|
||||||
rows []adjustmentPriceImportRow,
|
|
||||||
existing map[uint]struct{},
|
|
||||||
) ([]adjustmentPriceImportRow, []adjustmentPriceImportRow) {
|
|
||||||
processable := make([]adjustmentPriceImportRow, 0, len(rows))
|
|
||||||
skipped := make([]adjustmentPriceImportRow, 0)
|
|
||||||
|
|
||||||
for _, row := range rows {
|
|
||||||
if _, exists := existing[row.AdjustmentID]; exists {
|
|
||||||
processable = append(processable, row)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
skipped = append(skipped, row)
|
|
||||||
}
|
|
||||||
|
|
||||||
return processable, skipped
|
|
||||||
}
|
|
||||||
|
|
||||||
func printPlanRows(rows []adjustmentPriceImportRow) {
|
|
||||||
for _, row := range rows {
|
|
||||||
fmt.Printf(
|
|
||||||
"PLAN row=%d adjustment_id=%d price=%.3f\n",
|
|
||||||
row.RowNumber,
|
|
||||||
row.AdjustmentID,
|
|
||||||
row.Weight,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func printSkippedRows(rows []adjustmentPriceImportRow) {
|
|
||||||
for _, row := range rows {
|
|
||||||
fmt.Printf(
|
|
||||||
"SKIP row=%d adjustment_id=%d reason=adjustment_id not found\n",
|
|
||||||
row.RowNumber,
|
|
||||||
row.AdjustmentID,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func sortValidationIssues(issues []validationIssue) {
|
|
||||||
sort.Slice(issues, func(i, j int) bool {
|
|
||||||
if issues[i].Row == issues[j].Row {
|
|
||||||
if issues[i].Field == issues[j].Field {
|
|
||||||
return issues[i].Message < issues[j].Message
|
|
||||||
}
|
|
||||||
return issues[i].Field < issues[j].Field
|
|
||||||
}
|
|
||||||
return issues[i].Row < issues[j].Row
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func applyIfRequested(
|
|
||||||
ctx context.Context,
|
|
||||||
apply bool,
|
|
||||||
runner txRunner,
|
|
||||||
rows []adjustmentPriceImportRow,
|
|
||||||
) ([]applyRowResult, error) {
|
|
||||||
if !apply || len(rows) == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return applyImportRows(ctx, runner, rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
func applyImportRows(
|
|
||||||
ctx context.Context,
|
|
||||||
runner txRunner,
|
|
||||||
rows []adjustmentPriceImportRow,
|
|
||||||
) ([]applyRowResult, error) {
|
|
||||||
results := make([]applyRowResult, 0, len(rows))
|
|
||||||
|
|
||||||
err := runner.InTx(ctx, func(store adjustmentPriceStore) error {
|
|
||||||
for _, row := range rows {
|
|
||||||
changed, err := store.UpdatePrice(ctx, row.AdjustmentID, row.Weight)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("row %d adjustment_id=%d update failed: %w", row.RowNumber, row.AdjustmentID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
results = append(results, applyRowResult{
|
|
||||||
RowNumber: row.RowNumber,
|
|
||||||
AdjustmentID: row.AdjustmentID,
|
|
||||||
Price: row.Weight,
|
|
||||||
Changed: changed,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return results, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r dbTxRunner) InTx(ctx context.Context, fn func(store adjustmentPriceStore) error) error {
|
|
||||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
return fn(dbAdjustmentPriceStore{db: tx})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s dbAdjustmentPriceStore) UpdatePrice(
|
|
||||||
ctx context.Context,
|
|
||||||
adjustmentID uint,
|
|
||||||
price float64,
|
|
||||||
) (bool, error) {
|
|
||||||
result := s.db.WithContext(ctx).Exec(`
|
|
||||||
UPDATE adjustment_stocks
|
|
||||||
SET price = ?,
|
|
||||||
updated_at = NOW()
|
|
||||||
WHERE id = ?
|
|
||||||
AND price IS DISTINCT FROM ?
|
|
||||||
`, price, adjustmentID, price)
|
|
||||||
if result.Error != nil {
|
|
||||||
return false, result.Error
|
|
||||||
}
|
|
||||||
return result.RowsAffected > 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func modeLabel(apply bool) string {
|
|
||||||
if apply {
|
|
||||||
return "APPLY"
|
|
||||||
}
|
|
||||||
return "DRY-RUN"
|
|
||||||
}
|
|
||||||
|
|
||||||
func applyStatus(changed bool) string {
|
|
||||||
if changed {
|
|
||||||
return "UPDATED"
|
|
||||||
}
|
|
||||||
return "UNCHANGED"
|
|
||||||
}
|
|
||||||
|
|
||||||
func countChangedRows(results []applyRowResult) int {
|
|
||||||
count := 0
|
|
||||||
for _, result := range results {
|
|
||||||
if result.Changed {
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return count
|
|
||||||
}
|
|
||||||
@@ -1,362 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/xuri/excelize/v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestParseAdjustmentPriceFile_ValidSingleRow(t *testing.T) {
|
|
||||||
filePath := createWorkbook(
|
|
||||||
t,
|
|
||||||
"adjustment_prices",
|
|
||||||
[]string{"adjustment_id", "weight"},
|
|
||||||
[][]string{{"101", "12.345"}},
|
|
||||||
)
|
|
||||||
|
|
||||||
sheet, rows, issues, err := parseAdjustmentPriceFile(filePath, "")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
if sheet != "adjustment_prices" {
|
|
||||||
t.Fatalf("expected selected sheet adjustment_prices, got %q", sheet)
|
|
||||||
}
|
|
||||||
if len(issues) != 0 {
|
|
||||||
t.Fatalf("expected no issues, got %+v", issues)
|
|
||||||
}
|
|
||||||
if len(rows) != 1 {
|
|
||||||
t.Fatalf("expected 1 row, got %d", len(rows))
|
|
||||||
}
|
|
||||||
if rows[0].AdjustmentID != 101 {
|
|
||||||
t.Fatalf("expected adjustment_id 101, got %d", rows[0].AdjustmentID)
|
|
||||||
}
|
|
||||||
if rows[0].Weight != 12.345 {
|
|
||||||
t.Fatalf("expected weight 12.345, got %v", rows[0].Weight)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseAdjustmentPriceFile_ValidMultiRow(t *testing.T) {
|
|
||||||
filePath := createWorkbook(
|
|
||||||
t,
|
|
||||||
"adjustment_prices",
|
|
||||||
[]string{" Adjustment_ID ", "WEIGHT"},
|
|
||||||
[][]string{{"101", "10"}, {"102", "11.5"}},
|
|
||||||
)
|
|
||||||
|
|
||||||
_, rows, issues, err := parseAdjustmentPriceFile(filePath, "adjustment_prices")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
if len(issues) != 0 {
|
|
||||||
t.Fatalf("expected no issues, got %+v", issues)
|
|
||||||
}
|
|
||||||
if len(rows) != 2 {
|
|
||||||
t.Fatalf("expected 2 rows, got %d", len(rows))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseAdjustmentPriceFile_MissingRequiredHeader(t *testing.T) {
|
|
||||||
filePath := createWorkbook(
|
|
||||||
t,
|
|
||||||
"adjustment_prices",
|
|
||||||
[]string{"adjustment_id", "price"},
|
|
||||||
[][]string{{"101", "12"}},
|
|
||||||
)
|
|
||||||
|
|
||||||
_, rows, issues, err := parseAdjustmentPriceFile(filePath, "")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
if len(rows) != 0 {
|
|
||||||
t.Fatalf("expected 0 parsed rows when header invalid, got %d", len(rows))
|
|
||||||
}
|
|
||||||
if !hasIssue(issues, 0, "weight", "required header is missing") {
|
|
||||||
t.Fatalf("expected missing weight header issue, got %+v", issues)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseAdjustmentPriceFile_InvalidAdjustmentID(t *testing.T) {
|
|
||||||
filePath := createWorkbook(
|
|
||||||
t,
|
|
||||||
"adjustment_prices",
|
|
||||||
[]string{"adjustment_id", "weight"},
|
|
||||||
[][]string{{"abc", "10"}, {"0", "12"}},
|
|
||||||
)
|
|
||||||
|
|
||||||
_, rows, issues, err := parseAdjustmentPriceFile(filePath, "")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
if len(rows) != 0 {
|
|
||||||
t.Fatalf("expected no valid rows, got %d", len(rows))
|
|
||||||
}
|
|
||||||
if !hasIssue(issues, 2, "adjustment_id", "must be a positive integer") {
|
|
||||||
t.Fatalf("expected non numeric adjustment_id issue, got %+v", issues)
|
|
||||||
}
|
|
||||||
if !hasIssue(issues, 3, "adjustment_id", "must be greater than 0") {
|
|
||||||
t.Fatalf("expected adjustment_id >0 issue, got %+v", issues)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseAdjustmentPriceFile_InvalidWeight(t *testing.T) {
|
|
||||||
filePath := createWorkbook(
|
|
||||||
t,
|
|
||||||
"adjustment_prices",
|
|
||||||
[]string{"adjustment_id", "weight"},
|
|
||||||
[][]string{{"101", "abc"}, {"102", "-1"}},
|
|
||||||
)
|
|
||||||
|
|
||||||
_, rows, issues, err := parseAdjustmentPriceFile(filePath, "")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
if len(rows) != 0 {
|
|
||||||
t.Fatalf("expected no valid rows, got %d", len(rows))
|
|
||||||
}
|
|
||||||
if !hasIssue(issues, 2, "weight", "must be numeric") {
|
|
||||||
t.Fatalf("expected weight numeric issue, got %+v", issues)
|
|
||||||
}
|
|
||||||
if !hasIssue(issues, 3, "weight", "must be greater than or equal to 0") {
|
|
||||||
t.Fatalf("expected weight >=0 issue, got %+v", issues)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseAdjustmentPriceFile_DuplicateAdjustmentID_LastRowWins(t *testing.T) {
|
|
||||||
filePath := createWorkbook(
|
|
||||||
t,
|
|
||||||
"adjustment_prices",
|
|
||||||
[]string{"adjustment_id", "weight"},
|
|
||||||
[][]string{{"101", "10"}, {"102", "20"}, {"101", "30"}},
|
|
||||||
)
|
|
||||||
|
|
||||||
_, rows, issues, err := parseAdjustmentPriceFile(filePath, "")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
if len(issues) != 0 {
|
|
||||||
t.Fatalf("expected no issues, got %+v", issues)
|
|
||||||
}
|
|
||||||
if len(rows) != 2 {
|
|
||||||
t.Fatalf("expected 2 deduped rows, got %d", len(rows))
|
|
||||||
}
|
|
||||||
|
|
||||||
row101, ok := findRowByAdjustmentID(rows, 101)
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("expected adjustment_id 101 to exist, got %+v", rows)
|
|
||||||
}
|
|
||||||
if row101.Weight != 30 {
|
|
||||||
t.Fatalf("expected duplicate adjustment_id to keep last weight 30, got %v", row101.Weight)
|
|
||||||
}
|
|
||||||
if row101.RowNumber != 4 {
|
|
||||||
t.Fatalf("expected duplicate adjustment_id to keep last row number 4, got %d", row101.RowNumber)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSplitRowsByExistingIDs_SkipMissing(t *testing.T) {
|
|
||||||
rows := []adjustmentPriceImportRow{
|
|
||||||
{RowNumber: 2, AdjustmentID: 101, Weight: 10},
|
|
||||||
{RowNumber: 3, AdjustmentID: 102, Weight: 11},
|
|
||||||
{RowNumber: 4, AdjustmentID: 103, Weight: 12},
|
|
||||||
}
|
|
||||||
existing := map[uint]struct{}{101: {}, 103: {}}
|
|
||||||
|
|
||||||
processable, skipped := splitRowsByExistingIDs(rows, existing)
|
|
||||||
if len(processable) != 2 {
|
|
||||||
t.Fatalf("expected 2 processable rows, got %d", len(processable))
|
|
||||||
}
|
|
||||||
if len(skipped) != 1 {
|
|
||||||
t.Fatalf("expected 1 skipped row, got %d", len(skipped))
|
|
||||||
}
|
|
||||||
if skipped[0].AdjustmentID != 102 {
|
|
||||||
t.Fatalf("expected adjustment_id 102 skipped, got %+v", skipped)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApplyIfRequested_DryRunDoesNotWrite(t *testing.T) {
|
|
||||||
runner := &fakeTransactionRunner{}
|
|
||||||
rows := []adjustmentPriceImportRow{{RowNumber: 2, AdjustmentID: 101, Weight: 10}}
|
|
||||||
|
|
||||||
results, err := applyIfRequested(context.Background(), false, runner, rows)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
if results != nil {
|
|
||||||
t.Fatalf("expected nil results on dry-run, got %+v", results)
|
|
||||||
}
|
|
||||||
if runner.txCalls != 0 {
|
|
||||||
t.Fatalf("expected no transaction call during dry-run, got %d", runner.txCalls)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApplyImportRows_Success(t *testing.T) {
|
|
||||||
runner := &fakeTransactionRunner{
|
|
||||||
changedByID: map[uint]bool{101: true, 102: false},
|
|
||||||
}
|
|
||||||
rows := []adjustmentPriceImportRow{
|
|
||||||
{RowNumber: 2, AdjustmentID: 101, Weight: 10},
|
|
||||||
{RowNumber: 3, AdjustmentID: 102, Weight: 11},
|
|
||||||
}
|
|
||||||
|
|
||||||
results, err := applyImportRows(context.Background(), runner, rows)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
if runner.txCalls != 1 {
|
|
||||||
t.Fatalf("expected 1 transaction call, got %d", runner.txCalls)
|
|
||||||
}
|
|
||||||
if len(runner.committedCalls) != 2 {
|
|
||||||
t.Fatalf("expected 2 committed updates, got %d", len(runner.committedCalls))
|
|
||||||
}
|
|
||||||
if len(results) != 2 {
|
|
||||||
t.Fatalf("expected 2 row results, got %d", len(results))
|
|
||||||
}
|
|
||||||
if !results[0].Changed || results[1].Changed {
|
|
||||||
t.Fatalf("unexpected changed flags: %+v", results)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApplyImportRows_RollbackOnError(t *testing.T) {
|
|
||||||
runner := &fakeTransactionRunner{
|
|
||||||
errByID: map[uint]error{102: errors.New("boom")},
|
|
||||||
}
|
|
||||||
rows := []adjustmentPriceImportRow{
|
|
||||||
{RowNumber: 2, AdjustmentID: 101, Weight: 10},
|
|
||||||
{RowNumber: 3, AdjustmentID: 102, Weight: 11},
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := applyImportRows(context.Background(), runner, rows)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error due to update failure")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "row 3 adjustment_id=102 update failed") {
|
|
||||||
t.Fatalf("unexpected error message: %v", err)
|
|
||||||
}
|
|
||||||
if runner.txCalls != 1 {
|
|
||||||
t.Fatalf("expected 1 transaction call, got %d", runner.txCalls)
|
|
||||||
}
|
|
||||||
if len(runner.committedCalls) != 0 {
|
|
||||||
t.Fatalf("expected no committed updates on rollback, got %d", len(runner.committedCalls))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func createWorkbook(t *testing.T, sheetName string, headers []string, rows [][]string) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
f := excelize.NewFile()
|
|
||||||
defaultSheet := f.GetSheetName(f.GetActiveSheetIndex())
|
|
||||||
if sheetName == "" {
|
|
||||||
sheetName = defaultSheet
|
|
||||||
} else if sheetName != defaultSheet {
|
|
||||||
f.SetSheetName(defaultSheet, sheetName)
|
|
||||||
}
|
|
||||||
|
|
||||||
for idx, header := range headers {
|
|
||||||
cell, err := excelize.CoordinatesToCellName(idx+1, 1)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed resolving header cell: %v", err)
|
|
||||||
}
|
|
||||||
if err := f.SetCellValue(sheetName, cell, header); err != nil {
|
|
||||||
t.Fatalf("failed setting header cell: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for rowIdx, row := range rows {
|
|
||||||
for colIdx, value := range row {
|
|
||||||
cell, err := excelize.CoordinatesToCellName(colIdx+1, rowIdx+2)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed resolving data cell: %v", err)
|
|
||||||
}
|
|
||||||
if err := f.SetCellValue(sheetName, cell, value); err != nil {
|
|
||||||
t.Fatalf("failed setting data cell: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
path := filepath.Join(t.TempDir(), "adjustment_prices.xlsx")
|
|
||||||
if err := f.SaveAs(path); err != nil {
|
|
||||||
t.Fatalf("failed saving workbook: %v", err)
|
|
||||||
}
|
|
||||||
if err := f.Close(); err != nil {
|
|
||||||
t.Fatalf("failed closing workbook: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
||||||
func hasIssue(issues []validationIssue, row int, field, messageContains string) bool {
|
|
||||||
for _, issue := range issues {
|
|
||||||
if issue.Row != row {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if issue.Field != field {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if strings.Contains(issue.Message, messageContains) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func findRowByAdjustmentID(rows []adjustmentPriceImportRow, adjustmentID uint) (adjustmentPriceImportRow, bool) {
|
|
||||||
for _, row := range rows {
|
|
||||||
if row.AdjustmentID == adjustmentID {
|
|
||||||
return row, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return adjustmentPriceImportRow{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
type updateCall struct {
|
|
||||||
adjustmentID uint
|
|
||||||
price float64
|
|
||||||
}
|
|
||||||
|
|
||||||
type fakeAdjustmentPriceStore struct {
|
|
||||||
changedByID map[uint]bool
|
|
||||||
errByID map[uint]error
|
|
||||||
calls []updateCall
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *fakeAdjustmentPriceStore) UpdatePrice(_ context.Context, adjustmentID uint, price float64) (bool, error) {
|
|
||||||
s.calls = append(s.calls, updateCall{adjustmentID: adjustmentID, price: price})
|
|
||||||
if err, exists := s.errByID[adjustmentID]; exists {
|
|
||||||
return false, fmt.Errorf("forced update failure for adjustment_id=%d: %w", adjustmentID, err)
|
|
||||||
}
|
|
||||||
if changed, exists := s.changedByID[adjustmentID]; exists {
|
|
||||||
return changed, nil
|
|
||||||
}
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type fakeTransactionRunner struct {
|
|
||||||
txCalls int
|
|
||||||
changedByID map[uint]bool
|
|
||||||
errByID map[uint]error
|
|
||||||
committedCalls []updateCall
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *fakeTransactionRunner) InTx(ctx context.Context, fn func(store adjustmentPriceStore) error) error {
|
|
||||||
r.txCalls++
|
|
||||||
|
|
||||||
txStore := &fakeAdjustmentPriceStore{
|
|
||||||
changedByID: r.changedByID,
|
|
||||||
errByID: r.errByID,
|
|
||||||
calls: make([]updateCall, 0),
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := fn(txStore); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
r.committedCalls = append(r.committedCalls, txStore.calls...)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ txRunner = (*fakeTransactionRunner)(nil)
|
|
||||||
var _ adjustmentPriceStore = (*fakeAdjustmentPriceStore)(nil)
|
|
||||||
@@ -1,632 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/xuri/excelize/v2"
|
|
||||||
"gitlab.com/mbugroup/lti-api.git/internal/config"
|
|
||||||
"gitlab.com/mbugroup/lti-api.git/internal/database"
|
|
||||||
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
|
||||||
repportRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/repositories"
|
|
||||||
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
const dateLayout = "2006-01-02"
|
|
||||||
|
|
||||||
type importOptions struct {
|
|
||||||
FilePath string
|
|
||||||
Sheet string
|
|
||||||
Apply bool
|
|
||||||
}
|
|
||||||
|
|
||||||
type headerIndexes struct {
|
|
||||||
ProjectFlockID int
|
|
||||||
TotalCost int
|
|
||||||
CutoverDate int
|
|
||||||
Note int
|
|
||||||
}
|
|
||||||
|
|
||||||
type manualInputImportRow struct {
|
|
||||||
RowNumber int
|
|
||||||
ProjectFlockID uint
|
|
||||||
TotalCost float64
|
|
||||||
CutoverDate time.Time
|
|
||||||
Note *string
|
|
||||||
}
|
|
||||||
|
|
||||||
type validationIssue struct {
|
|
||||||
Row int
|
|
||||||
Field string
|
|
||||||
Message string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (i validationIssue) Error() string {
|
|
||||||
if i.Row > 0 {
|
|
||||||
return fmt.Sprintf("row=%d field=%s message=%s", i.Row, i.Field, i.Message)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("field=%s message=%s", i.Field, i.Message)
|
|
||||||
}
|
|
||||||
|
|
||||||
type farmResolver interface {
|
|
||||||
ResolveActiveLayingFarms(ctx context.Context, projectFlockIDs []uint) (map[uint]string, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type dbFarmResolver struct {
|
|
||||||
db *gorm.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
type manualInputStore interface {
|
|
||||||
UpsertManualInput(ctx context.Context, row *entity.FarmDepreciationManualInput) error
|
|
||||||
DeleteSnapshotsFromDate(ctx context.Context, fromDate time.Time, farmIDs []uint) error
|
|
||||||
}
|
|
||||||
|
|
||||||
type txRunner interface {
|
|
||||||
InTx(ctx context.Context, fn func(store manualInputStore) error) error
|
|
||||||
}
|
|
||||||
|
|
||||||
type dbTxRunner struct {
|
|
||||||
db *gorm.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
type expenseDepreciationStore struct {
|
|
||||||
repo repportRepo.ExpenseDepreciationRepository
|
|
||||||
}
|
|
||||||
|
|
||||||
type farmIdentityRow struct {
|
|
||||||
ID uint `gorm:"column:id"`
|
|
||||||
FarmName string `gorm:"column:farm_name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
var opts importOptions
|
|
||||||
flag.StringVar(&opts.FilePath, "file", "", "Path to .xlsx file (required)")
|
|
||||||
flag.StringVar(&opts.Sheet, "sheet", "", "Sheet name (optional, default: first sheet)")
|
|
||||||
flag.BoolVar(&opts.Apply, "apply", false, "Apply changes. If false, run as dry-run")
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
opts.FilePath = strings.TrimSpace(opts.FilePath)
|
|
||||||
opts.Sheet = strings.TrimSpace(opts.Sheet)
|
|
||||||
|
|
||||||
if opts.FilePath == "" {
|
|
||||||
log.Fatal("--file is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
location, err := time.LoadLocation("Asia/Jakarta")
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("failed to load timezone Asia/Jakarta: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
sheetName, rows, parseIssues, err := parseManualInputFile(opts.FilePath, opts.Sheet, location)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("failed reading excel: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
db := database.Connect(config.DBHost, config.DBName)
|
|
||||||
resolver := dbFarmResolver{db: db}
|
|
||||||
|
|
||||||
farmNameByID, err := resolver.ResolveActiveLayingFarms(ctx, collectProjectFlockIDs(rows))
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("failed validating project_flock_id against project_flocks: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
issues := append([]validationIssue{}, parseIssues...)
|
|
||||||
issues = append(issues, buildMissingFarmIssues(rows, farmNameByID)...)
|
|
||||||
sortValidationIssues(issues)
|
|
||||||
|
|
||||||
fmt.Printf("Mode: %s\n", modeLabel(opts.Apply))
|
|
||||||
fmt.Printf("File: %s\n", opts.FilePath)
|
|
||||||
fmt.Printf("Sheet: %s\n", sheetName)
|
|
||||||
fmt.Printf("Rows parsed: %d\n", len(rows))
|
|
||||||
fmt.Printf("Rows invalid: %d\n", len(issues))
|
|
||||||
fmt.Println()
|
|
||||||
|
|
||||||
if len(rows) > 0 {
|
|
||||||
printPlanRows(rows, farmNameByID)
|
|
||||||
fmt.Println()
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(issues) > 0 {
|
|
||||||
fmt.Println("Validation errors:")
|
|
||||||
for _, issue := range issues {
|
|
||||||
fmt.Printf("ERROR %s\n", issue.Error())
|
|
||||||
}
|
|
||||||
fmt.Println()
|
|
||||||
fmt.Printf("Summary: planned=%d applied=0 failed=%d\n", len(rows), len(issues))
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !opts.Apply {
|
|
||||||
fmt.Printf("Summary: planned=%d applied=0 failed=0\n", len(rows))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(rows) == 0 {
|
|
||||||
fmt.Println("Summary: planned=0 applied=0 failed=0")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := applyIfRequested(ctx, true, dbTxRunner{db: db}, rows); err != nil {
|
|
||||||
log.Fatalf("apply failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, row := range rows {
|
|
||||||
fmt.Printf(
|
|
||||||
"DONE row=%d project_flock_id=%d cutover_date=%s\n",
|
|
||||||
row.RowNumber,
|
|
||||||
row.ProjectFlockID,
|
|
||||||
row.CutoverDate.In(location).Format(dateLayout),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println()
|
|
||||||
fmt.Printf("Summary: planned=%d applied=%d failed=0\n", len(rows), len(rows))
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseManualInputFile(
|
|
||||||
filePath string,
|
|
||||||
requestedSheet string,
|
|
||||||
location *time.Location,
|
|
||||||
) (string, []manualInputImportRow, []validationIssue, error) {
|
|
||||||
workbook, err := excelize.OpenFile(filePath)
|
|
||||||
if err != nil {
|
|
||||||
return "", nil, nil, err
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
_ = workbook.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
sheetName, err := resolveSheetName(workbook, requestedSheet)
|
|
||||||
if err != nil {
|
|
||||||
return "", nil, nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
allRows, err := workbook.GetRows(sheetName, excelize.Options{RawCellValue: true})
|
|
||||||
if err != nil {
|
|
||||||
return "", nil, nil, err
|
|
||||||
}
|
|
||||||
if len(allRows) == 0 {
|
|
||||||
return sheetName, nil, []validationIssue{
|
|
||||||
{Field: "header", Message: "sheet is empty"},
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
indexes, headerIssues := parseHeaderIndexes(allRows[0])
|
|
||||||
if len(headerIssues) > 0 {
|
|
||||||
return sheetName, nil, headerIssues, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
rows := make([]manualInputImportRow, 0, len(allRows)-1)
|
|
||||||
issues := make([]validationIssue, 0)
|
|
||||||
seenProjectFlockIDs := make(map[uint]int)
|
|
||||||
|
|
||||||
for idx := 1; idx < len(allRows); idx++ {
|
|
||||||
rowNumber := idx + 1
|
|
||||||
rawRow := allRows[idx]
|
|
||||||
|
|
||||||
if isRowEmpty(rawRow) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
parsed, rowIssues := parseDataRow(rawRow, rowNumber, indexes, location, seenProjectFlockIDs)
|
|
||||||
if len(rowIssues) > 0 {
|
|
||||||
issues = append(issues, rowIssues...)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
rows = append(rows, *parsed)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(rows) == 0 && len(issues) == 0 {
|
|
||||||
issues = append(issues, validationIssue{
|
|
||||||
Field: "rows",
|
|
||||||
Message: "no data rows found",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return sheetName, rows, issues, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveSheetName(workbook *excelize.File, requestedSheet string) (string, error) {
|
|
||||||
if workbook == nil {
|
|
||||||
return "", fmt.Errorf("workbook is nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
sheets := workbook.GetSheetList()
|
|
||||||
if len(sheets) == 0 {
|
|
||||||
return "", fmt.Errorf("workbook has no sheets")
|
|
||||||
}
|
|
||||||
|
|
||||||
if requestedSheet == "" {
|
|
||||||
return sheets[0], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, sheet := range sheets {
|
|
||||||
if strings.EqualFold(strings.TrimSpace(sheet), strings.TrimSpace(requestedSheet)) {
|
|
||||||
return sheet, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", fmt.Errorf("sheet %q not found", requestedSheet)
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseHeaderIndexes(headerRow []string) (headerIndexes, []validationIssue) {
|
|
||||||
indexes := headerIndexes{
|
|
||||||
ProjectFlockID: -1,
|
|
||||||
TotalCost: -1,
|
|
||||||
CutoverDate: -1,
|
|
||||||
Note: -1,
|
|
||||||
}
|
|
||||||
issues := make([]validationIssue, 0)
|
|
||||||
|
|
||||||
for idx, raw := range headerRow {
|
|
||||||
header := normalizeHeader(raw)
|
|
||||||
if header == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
switch header {
|
|
||||||
case "project_flock_id":
|
|
||||||
if indexes.ProjectFlockID >= 0 {
|
|
||||||
issues = append(issues, validationIssue{
|
|
||||||
Field: "header",
|
|
||||||
Message: "duplicate header project_flock_id",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
indexes.ProjectFlockID = idx
|
|
||||||
case "total_cost":
|
|
||||||
if indexes.TotalCost >= 0 {
|
|
||||||
issues = append(issues, validationIssue{
|
|
||||||
Field: "header",
|
|
||||||
Message: "duplicate header total_cost",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
indexes.TotalCost = idx
|
|
||||||
case "cutover_date":
|
|
||||||
if indexes.CutoverDate >= 0 {
|
|
||||||
issues = append(issues, validationIssue{
|
|
||||||
Field: "header",
|
|
||||||
Message: "duplicate header cutover_date",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
indexes.CutoverDate = idx
|
|
||||||
case "note":
|
|
||||||
if indexes.Note >= 0 {
|
|
||||||
issues = append(issues, validationIssue{
|
|
||||||
Field: "header",
|
|
||||||
Message: "duplicate header note",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
indexes.Note = idx
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if indexes.ProjectFlockID < 0 {
|
|
||||||
issues = append(issues, validationIssue{
|
|
||||||
Field: "project_flock_id",
|
|
||||||
Message: "required header is missing",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if indexes.TotalCost < 0 {
|
|
||||||
issues = append(issues, validationIssue{
|
|
||||||
Field: "total_cost",
|
|
||||||
Message: "required header is missing",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if indexes.CutoverDate < 0 {
|
|
||||||
issues = append(issues, validationIssue{
|
|
||||||
Field: "cutover_date",
|
|
||||||
Message: "required header is missing",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return indexes, issues
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseDataRow(
|
|
||||||
rawRow []string,
|
|
||||||
rowNumber int,
|
|
||||||
indexes headerIndexes,
|
|
||||||
location *time.Location,
|
|
||||||
seenProjectFlockIDs map[uint]int,
|
|
||||||
) (*manualInputImportRow, []validationIssue) {
|
|
||||||
issues := make([]validationIssue, 0)
|
|
||||||
|
|
||||||
projectFlockIDRaw := strings.TrimSpace(cellValue(rawRow, indexes.ProjectFlockID))
|
|
||||||
projectFlockID, err := parsePositiveUint(projectFlockIDRaw)
|
|
||||||
if err != nil {
|
|
||||||
issues = append(issues, validationIssue{
|
|
||||||
Row: rowNumber,
|
|
||||||
Field: "project_flock_id",
|
|
||||||
Message: err.Error(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
totalCostRaw := strings.TrimSpace(cellValue(rawRow, indexes.TotalCost))
|
|
||||||
totalCost, err := parseNonNegativeFloat(totalCostRaw)
|
|
||||||
if err != nil {
|
|
||||||
issues = append(issues, validationIssue{
|
|
||||||
Row: rowNumber,
|
|
||||||
Field: "total_cost",
|
|
||||||
Message: err.Error(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
cutoverDateRaw := strings.TrimSpace(cellValue(rawRow, indexes.CutoverDate))
|
|
||||||
cutoverDate, err := parseDateOnlyInLocation(cutoverDateRaw, location)
|
|
||||||
if err != nil {
|
|
||||||
issues = append(issues, validationIssue{
|
|
||||||
Row: rowNumber,
|
|
||||||
Field: "cutover_date",
|
|
||||||
Message: err.Error(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
var note *string
|
|
||||||
noteRaw := strings.TrimSpace(cellValue(rawRow, indexes.Note))
|
|
||||||
if noteRaw != "" {
|
|
||||||
if len([]rune(noteRaw)) > 1000 {
|
|
||||||
issues = append(issues, validationIssue{
|
|
||||||
Row: rowNumber,
|
|
||||||
Field: "note",
|
|
||||||
Message: "must have at most 1000 characters",
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
note = ¬eRaw
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if projectFlockID > 0 {
|
|
||||||
if previousRow, exists := seenProjectFlockIDs[projectFlockID]; exists {
|
|
||||||
issues = append(issues, validationIssue{
|
|
||||||
Row: rowNumber,
|
|
||||||
Field: "project_flock_id",
|
|
||||||
Message: fmt.Sprintf("duplicate value %d (already used in row %d)", projectFlockID, previousRow),
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
seenProjectFlockIDs[projectFlockID] = rowNumber
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(issues) > 0 {
|
|
||||||
return nil, issues
|
|
||||||
}
|
|
||||||
|
|
||||||
return &manualInputImportRow{
|
|
||||||
RowNumber: rowNumber,
|
|
||||||
ProjectFlockID: projectFlockID,
|
|
||||||
TotalCost: totalCost,
|
|
||||||
CutoverDate: cutoverDate,
|
|
||||||
Note: note,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parsePositiveUint(raw string) (uint, error) {
|
|
||||||
if raw == "" {
|
|
||||||
return 0, fmt.Errorf("is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
uintValue, err := strconv.ParseUint(raw, 10, 64)
|
|
||||||
if err == nil {
|
|
||||||
if uintValue == 0 {
|
|
||||||
return 0, fmt.Errorf("must be greater than 0")
|
|
||||||
}
|
|
||||||
return uint(uintValue), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
floatValue, floatErr := strconv.ParseFloat(raw, 64)
|
|
||||||
if floatErr != nil {
|
|
||||||
return 0, fmt.Errorf("must be a positive integer")
|
|
||||||
}
|
|
||||||
if floatValue <= 0 {
|
|
||||||
return 0, fmt.Errorf("must be greater than 0")
|
|
||||||
}
|
|
||||||
if floatValue != float64(uint(floatValue)) {
|
|
||||||
return 0, fmt.Errorf("must be a positive integer")
|
|
||||||
}
|
|
||||||
|
|
||||||
return uint(floatValue), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseNonNegativeFloat(raw string) (float64, error) {
|
|
||||||
if raw == "" {
|
|
||||||
return 0, fmt.Errorf("is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
value, err := strconv.ParseFloat(raw, 64)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("must be numeric")
|
|
||||||
}
|
|
||||||
if value < 0 {
|
|
||||||
return 0, fmt.Errorf("must be greater than or equal to 0")
|
|
||||||
}
|
|
||||||
|
|
||||||
return value, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseDateOnlyInLocation(raw string, location *time.Location) (time.Time, error) {
|
|
||||||
if raw == "" {
|
|
||||||
return time.Time{}, fmt.Errorf("is required")
|
|
||||||
}
|
|
||||||
value, err := time.ParseInLocation(dateLayout, raw, location)
|
|
||||||
if err != nil {
|
|
||||||
return time.Time{}, fmt.Errorf("must follow format YYYY-MM-DD")
|
|
||||||
}
|
|
||||||
return value, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func isRowEmpty(row []string) bool {
|
|
||||||
for _, cell := range row {
|
|
||||||
if strings.TrimSpace(cell) != "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeHeader(raw string) string {
|
|
||||||
return strings.ToLower(strings.TrimSpace(raw))
|
|
||||||
}
|
|
||||||
|
|
||||||
func cellValue(row []string, index int) string {
|
|
||||||
if index < 0 || index >= len(row) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return row[index]
|
|
||||||
}
|
|
||||||
|
|
||||||
func collectProjectFlockIDs(rows []manualInputImportRow) []uint {
|
|
||||||
ids := make([]uint, 0, len(rows))
|
|
||||||
seen := make(map[uint]struct{}, len(rows))
|
|
||||||
for _, row := range rows {
|
|
||||||
if row.ProjectFlockID == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, exists := seen[row.ProjectFlockID]; exists {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[row.ProjectFlockID] = struct{}{}
|
|
||||||
ids = append(ids, row.ProjectFlockID)
|
|
||||||
}
|
|
||||||
sort.Slice(ids, func(i, j int) bool {
|
|
||||||
return ids[i] < ids[j]
|
|
||||||
})
|
|
||||||
return ids
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r dbFarmResolver) ResolveActiveLayingFarms(
|
|
||||||
ctx context.Context,
|
|
||||||
projectFlockIDs []uint,
|
|
||||||
) (map[uint]string, error) {
|
|
||||||
result := make(map[uint]string)
|
|
||||||
if len(projectFlockIDs) == 0 {
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
rows := make([]farmIdentityRow, 0, len(projectFlockIDs))
|
|
||||||
if err := r.db.WithContext(ctx).
|
|
||||||
Table("project_flocks").
|
|
||||||
Select("id, flock_name AS farm_name").
|
|
||||||
Where("id IN ?", projectFlockIDs).
|
|
||||||
Where("deleted_at IS NULL").
|
|
||||||
Where("category = ?", utils.ProjectFlockCategoryLaying).
|
|
||||||
Scan(&rows).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, row := range rows {
|
|
||||||
result[row.ID] = row.FarmName
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildMissingFarmIssues(rows []manualInputImportRow, farmNameByID map[uint]string) []validationIssue {
|
|
||||||
issues := make([]validationIssue, 0)
|
|
||||||
for _, row := range rows {
|
|
||||||
if _, exists := farmNameByID[row.ProjectFlockID]; exists {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
issues = append(issues, validationIssue{
|
|
||||||
Row: row.RowNumber,
|
|
||||||
Field: "project_flock_id",
|
|
||||||
Message: fmt.Sprintf("value %d must reference an active LAYING project_flock", row.ProjectFlockID),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return issues
|
|
||||||
}
|
|
||||||
|
|
||||||
func printPlanRows(rows []manualInputImportRow, farmNameByID map[uint]string) {
|
|
||||||
for _, row := range rows {
|
|
||||||
farmName := farmNameByID[row.ProjectFlockID]
|
|
||||||
fmt.Printf(
|
|
||||||
"PLAN row=%d project_flock_id=%d farm_name=%q total_cost=%.3f cutover_date=%s note=%q\n",
|
|
||||||
row.RowNumber,
|
|
||||||
row.ProjectFlockID,
|
|
||||||
farmName,
|
|
||||||
row.TotalCost,
|
|
||||||
row.CutoverDate.Format(dateLayout),
|
|
||||||
derefString(row.Note),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func sortValidationIssues(issues []validationIssue) {
|
|
||||||
sort.Slice(issues, func(i, j int) bool {
|
|
||||||
if issues[i].Row == issues[j].Row {
|
|
||||||
if issues[i].Field == issues[j].Field {
|
|
||||||
return issues[i].Message < issues[j].Message
|
|
||||||
}
|
|
||||||
return issues[i].Field < issues[j].Field
|
|
||||||
}
|
|
||||||
return issues[i].Row < issues[j].Row
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func applyIfRequested(ctx context.Context, apply bool, runner txRunner, rows []manualInputImportRow) error {
|
|
||||||
if !apply || len(rows) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return applyImportRows(ctx, runner, rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
func applyImportRows(ctx context.Context, runner txRunner, rows []manualInputImportRow) error {
|
|
||||||
return runner.InTx(ctx, func(store manualInputStore) error {
|
|
||||||
for _, row := range rows {
|
|
||||||
payload := entity.FarmDepreciationManualInput{
|
|
||||||
ProjectFlockId: row.ProjectFlockID,
|
|
||||||
TotalCost: row.TotalCost,
|
|
||||||
CutoverDate: row.CutoverDate,
|
|
||||||
Note: row.Note,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := store.UpsertManualInput(ctx, &payload); err != nil {
|
|
||||||
return fmt.Errorf("row %d project_flock_id=%d upsert failed: %w", row.RowNumber, row.ProjectFlockID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := store.DeleteSnapshotsFromDate(ctx, row.CutoverDate, []uint{row.ProjectFlockID}); err != nil {
|
|
||||||
return fmt.Errorf("row %d project_flock_id=%d snapshot invalidation failed: %w", row.RowNumber, row.ProjectFlockID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r dbTxRunner) InTx(ctx context.Context, fn func(store manualInputStore) error) error {
|
|
||||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
repo := repportRepo.NewExpenseDepreciationRepository(tx)
|
|
||||||
store := expenseDepreciationStore{repo: repo}
|
|
||||||
return fn(store)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s expenseDepreciationStore) UpsertManualInput(ctx context.Context, row *entity.FarmDepreciationManualInput) error {
|
|
||||||
return s.repo.UpsertManualInput(ctx, row)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s expenseDepreciationStore) DeleteSnapshotsFromDate(ctx context.Context, fromDate time.Time, farmIDs []uint) error {
|
|
||||||
return s.repo.DeleteSnapshotsFromDate(ctx, fromDate, farmIDs)
|
|
||||||
}
|
|
||||||
|
|
||||||
func modeLabel(apply bool) string {
|
|
||||||
if apply {
|
|
||||||
return "APPLY"
|
|
||||||
}
|
|
||||||
return "DRY-RUN"
|
|
||||||
}
|
|
||||||
|
|
||||||
func derefString(value *string) string {
|
|
||||||
if value == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return *value
|
|
||||||
}
|
|
||||||
@@ -1,563 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/xuri/excelize/v2"
|
|
||||||
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestParseManualInputFile_ValidSingleRow(t *testing.T) {
|
|
||||||
filePath := createManualInputWorkbook(
|
|
||||||
t,
|
|
||||||
"manual_inputs",
|
|
||||||
[]string{"project_flock_id", "total_cost", "cutover_date", "note"},
|
|
||||||
[][]string{
|
|
||||||
{"101", "12345.678", "2026-06-01", "manual seed"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
location := mustJakartaLocation(t)
|
|
||||||
sheet, rows, issues, err := parseManualInputFile(filePath, "", location)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
if sheet != "manual_inputs" {
|
|
||||||
t.Fatalf("expected selected sheet manual_inputs, got %q", sheet)
|
|
||||||
}
|
|
||||||
if len(issues) != 0 {
|
|
||||||
t.Fatalf("expected no issues, got %+v", issues)
|
|
||||||
}
|
|
||||||
if len(rows) != 1 {
|
|
||||||
t.Fatalf("expected 1 row, got %d", len(rows))
|
|
||||||
}
|
|
||||||
if rows[0].ProjectFlockID != 101 {
|
|
||||||
t.Fatalf("expected project_flock_id 101, got %d", rows[0].ProjectFlockID)
|
|
||||||
}
|
|
||||||
if rows[0].TotalCost != 12345.678 {
|
|
||||||
t.Fatalf("expected total_cost 12345.678, got %v", rows[0].TotalCost)
|
|
||||||
}
|
|
||||||
if rows[0].CutoverDate.Format(dateLayout) != "2026-06-01" {
|
|
||||||
t.Fatalf("expected cutover_date 2026-06-01, got %s", rows[0].CutoverDate.Format(dateLayout))
|
|
||||||
}
|
|
||||||
if rows[0].Note == nil || *rows[0].Note != "manual seed" {
|
|
||||||
t.Fatalf("expected note manual seed, got %+v", rows[0].Note)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseManualInputFile_ValidMultiRow(t *testing.T) {
|
|
||||||
filePath := createManualInputWorkbook(
|
|
||||||
t,
|
|
||||||
"manual_inputs",
|
|
||||||
[]string{" Project_Flock_ID ", "TOTAL_COST", "cutover_date", "NOTE"},
|
|
||||||
[][]string{
|
|
||||||
{"101", "1200", "2026-06-01", ""},
|
|
||||||
{"102", "1300.5", "2026-06-02", "second"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
location := mustJakartaLocation(t)
|
|
||||||
_, rows, issues, err := parseManualInputFile(filePath, "manual_inputs", location)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
if len(issues) != 0 {
|
|
||||||
t.Fatalf("expected no issues, got %+v", issues)
|
|
||||||
}
|
|
||||||
if len(rows) != 2 {
|
|
||||||
t.Fatalf("expected 2 rows, got %d", len(rows))
|
|
||||||
}
|
|
||||||
if rows[0].Note != nil {
|
|
||||||
t.Fatalf("expected first row note nil, got %+v", rows[0].Note)
|
|
||||||
}
|
|
||||||
if rows[1].Note == nil || *rows[1].Note != "second" {
|
|
||||||
t.Fatalf("expected second row note second, got %+v", rows[1].Note)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseManualInputFile_MissingRequiredHeader(t *testing.T) {
|
|
||||||
filePath := createManualInputWorkbook(
|
|
||||||
t,
|
|
||||||
"manual_inputs",
|
|
||||||
[]string{"project_flock_id", "totalcost", "cutover_date", "note"},
|
|
||||||
[][]string{
|
|
||||||
{"101", "1200", "2026-06-01", ""},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
location := mustJakartaLocation(t)
|
|
||||||
_, rows, issues, err := parseManualInputFile(filePath, "", location)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
if len(rows) != 0 {
|
|
||||||
t.Fatalf("expected 0 parsed rows when header invalid, got %d", len(rows))
|
|
||||||
}
|
|
||||||
if !hasIssue(issues, 0, "total_cost", "required header is missing") {
|
|
||||||
t.Fatalf("expected missing total_cost header issue, got %+v", issues)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseManualInputFile_InvalidProjectFlockID(t *testing.T) {
|
|
||||||
filePath := createManualInputWorkbook(
|
|
||||||
t,
|
|
||||||
"manual_inputs",
|
|
||||||
[]string{"project_flock_id", "total_cost", "cutover_date", "note"},
|
|
||||||
[][]string{
|
|
||||||
{"abc", "1200", "2026-06-01", ""},
|
|
||||||
{"0", "1300", "2026-06-02", ""},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
location := mustJakartaLocation(t)
|
|
||||||
_, rows, issues, err := parseManualInputFile(filePath, "", location)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
if len(rows) != 0 {
|
|
||||||
t.Fatalf("expected no valid rows, got %d", len(rows))
|
|
||||||
}
|
|
||||||
if !hasIssue(issues, 2, "project_flock_id", "must be a positive integer") {
|
|
||||||
t.Fatalf("expected non numeric project_flock_id issue, got %+v", issues)
|
|
||||||
}
|
|
||||||
if !hasIssue(issues, 3, "project_flock_id", "must be greater than 0") {
|
|
||||||
t.Fatalf("expected project_flock_id >0 issue, got %+v", issues)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseManualInputFile_InvalidTotalCost(t *testing.T) {
|
|
||||||
filePath := createManualInputWorkbook(
|
|
||||||
t,
|
|
||||||
"manual_inputs",
|
|
||||||
[]string{"project_flock_id", "total_cost", "cutover_date", "note"},
|
|
||||||
[][]string{
|
|
||||||
{"101", "abc", "2026-06-01", ""},
|
|
||||||
{"102", "-1", "2026-06-02", ""},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
location := mustJakartaLocation(t)
|
|
||||||
_, rows, issues, err := parseManualInputFile(filePath, "", location)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
if len(rows) != 0 {
|
|
||||||
t.Fatalf("expected no valid rows, got %d", len(rows))
|
|
||||||
}
|
|
||||||
if !hasIssue(issues, 2, "total_cost", "must be numeric") {
|
|
||||||
t.Fatalf("expected total_cost numeric issue, got %+v", issues)
|
|
||||||
}
|
|
||||||
if !hasIssue(issues, 3, "total_cost", "must be greater than or equal to 0") {
|
|
||||||
t.Fatalf("expected total_cost >=0 issue, got %+v", issues)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseManualInputFile_InvalidCutoverDate(t *testing.T) {
|
|
||||||
filePath := createManualInputWorkbook(
|
|
||||||
t,
|
|
||||||
"manual_inputs",
|
|
||||||
[]string{"project_flock_id", "total_cost", "cutover_date", "note"},
|
|
||||||
[][]string{
|
|
||||||
{"101", "1200", "06-01-2026", ""},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
location := mustJakartaLocation(t)
|
|
||||||
_, rows, issues, err := parseManualInputFile(filePath, "", location)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
if len(rows) != 0 {
|
|
||||||
t.Fatalf("expected no valid rows, got %d", len(rows))
|
|
||||||
}
|
|
||||||
if !hasIssue(issues, 2, "cutover_date", "must follow format YYYY-MM-DD") {
|
|
||||||
t.Fatalf("expected cutover_date format issue, got %+v", issues)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseManualInputFile_DuplicateProjectFlockID(t *testing.T) {
|
|
||||||
filePath := createManualInputWorkbook(
|
|
||||||
t,
|
|
||||||
"manual_inputs",
|
|
||||||
[]string{"project_flock_id", "total_cost", "cutover_date", "note"},
|
|
||||||
[][]string{
|
|
||||||
{"101", "1200", "2026-06-01", ""},
|
|
||||||
{"101", "1300", "2026-06-02", ""},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
location := mustJakartaLocation(t)
|
|
||||||
_, rows, issues, err := parseManualInputFile(filePath, "", location)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
if len(rows) != 1 {
|
|
||||||
t.Fatalf("expected first row valid and second row invalid, got %d rows", len(rows))
|
|
||||||
}
|
|
||||||
if !hasIssue(issues, 3, "project_flock_id", "duplicate value 101") {
|
|
||||||
t.Fatalf("expected duplicate project_flock_id issue, got %+v", issues)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseManualInputFile_NoteValidation(t *testing.T) {
|
|
||||||
longNote := strings.Repeat("a", 1001)
|
|
||||||
filePath := createManualInputWorkbook(
|
|
||||||
t,
|
|
||||||
"manual_inputs",
|
|
||||||
[]string{"project_flock_id", "total_cost", "cutover_date", "note"},
|
|
||||||
[][]string{
|
|
||||||
{"101", "1200", "2026-06-01", ""},
|
|
||||||
{"102", "1300", "2026-06-02", longNote},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
location := mustJakartaLocation(t)
|
|
||||||
_, rows, issues, err := parseManualInputFile(filePath, "", location)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
if len(rows) != 1 {
|
|
||||||
t.Fatalf("expected only first row valid, got %d", len(rows))
|
|
||||||
}
|
|
||||||
if rows[0].Note != nil {
|
|
||||||
t.Fatalf("expected first row note nil, got %+v", rows[0].Note)
|
|
||||||
}
|
|
||||||
if !hasIssue(issues, 3, "note", "at most 1000 characters") {
|
|
||||||
t.Fatalf("expected note length issue, got %+v", issues)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApplyImportRows_Success(t *testing.T) {
|
|
||||||
location := mustJakartaLocation(t)
|
|
||||||
runner := &fakeTransactionRunner{}
|
|
||||||
rows := []manualInputImportRow{
|
|
||||||
{
|
|
||||||
RowNumber: 2,
|
|
||||||
ProjectFlockID: 101,
|
|
||||||
TotalCost: 1000,
|
|
||||||
CutoverDate: mustDateInLocation(t, "2026-06-01", location),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
RowNumber: 3,
|
|
||||||
ProjectFlockID: 102,
|
|
||||||
TotalCost: 2000,
|
|
||||||
CutoverDate: mustDateInLocation(t, "2026-06-02", location),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
err := applyImportRows(context.Background(), runner, rows)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
if runner.txCalls != 1 {
|
|
||||||
t.Fatalf("expected 1 transaction call, got %d", runner.txCalls)
|
|
||||||
}
|
|
||||||
if len(runner.committedUpserts) != 2 {
|
|
||||||
t.Fatalf("expected 2 committed upserts, got %d", len(runner.committedUpserts))
|
|
||||||
}
|
|
||||||
if len(runner.committedInvalidations) != 2 {
|
|
||||||
t.Fatalf("expected 2 committed invalidations, got %d", len(runner.committedInvalidations))
|
|
||||||
}
|
|
||||||
if runner.committedInvalidations[0].farmIDs[0] != 101 || runner.committedInvalidations[1].farmIDs[0] != 102 {
|
|
||||||
t.Fatalf("unexpected invalidation farm IDs: %+v", runner.committedInvalidations)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApplyImportRows_RollbackOnError(t *testing.T) {
|
|
||||||
location := mustJakartaLocation(t)
|
|
||||||
runner := &fakeTransactionRunner{
|
|
||||||
failUpsertOnProjectFlockID: 102,
|
|
||||||
}
|
|
||||||
rows := []manualInputImportRow{
|
|
||||||
{
|
|
||||||
RowNumber: 2,
|
|
||||||
ProjectFlockID: 101,
|
|
||||||
TotalCost: 1000,
|
|
||||||
CutoverDate: mustDateInLocation(t, "2026-06-01", location),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
RowNumber: 3,
|
|
||||||
ProjectFlockID: 102,
|
|
||||||
TotalCost: 2000,
|
|
||||||
CutoverDate: mustDateInLocation(t, "2026-06-02", location),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
err := applyImportRows(context.Background(), runner, rows)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error due to upsert failure")
|
|
||||||
}
|
|
||||||
if runner.txCalls != 1 {
|
|
||||||
t.Fatalf("expected 1 transaction call, got %d", runner.txCalls)
|
|
||||||
}
|
|
||||||
if len(runner.committedUpserts) != 0 {
|
|
||||||
t.Fatalf("expected no committed upserts on rollback, got %d", len(runner.committedUpserts))
|
|
||||||
}
|
|
||||||
if len(runner.committedInvalidations) != 0 {
|
|
||||||
t.Fatalf("expected no committed invalidations on rollback, got %d", len(runner.committedInvalidations))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApplyIfRequested_DryRunDoesNotWrite(t *testing.T) {
|
|
||||||
location := mustJakartaLocation(t)
|
|
||||||
runner := &fakeTransactionRunner{}
|
|
||||||
rows := []manualInputImportRow{
|
|
||||||
{
|
|
||||||
RowNumber: 2,
|
|
||||||
ProjectFlockID: 101,
|
|
||||||
TotalCost: 1000,
|
|
||||||
CutoverDate: mustDateInLocation(t, "2026-06-01", location),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
err := applyIfRequested(context.Background(), false, runner, rows)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
if runner.txCalls != 0 {
|
|
||||||
t.Fatalf("expected no transaction call during dry-run, got %d", runner.txCalls)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func createManualInputWorkbook(t *testing.T, sheetName string, headers []string, rows [][]string) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
f := excelize.NewFile()
|
|
||||||
defaultSheet := f.GetSheetName(f.GetActiveSheetIndex())
|
|
||||||
if sheetName == "" {
|
|
||||||
sheetName = defaultSheet
|
|
||||||
} else if sheetName != defaultSheet {
|
|
||||||
f.SetSheetName(defaultSheet, sheetName)
|
|
||||||
}
|
|
||||||
|
|
||||||
for idx, header := range headers {
|
|
||||||
cell, err := excelize.CoordinatesToCellName(idx+1, 1)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed resolving header cell: %v", err)
|
|
||||||
}
|
|
||||||
if err := f.SetCellValue(sheetName, cell, header); err != nil {
|
|
||||||
t.Fatalf("failed setting header cell: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for rowIdx, row := range rows {
|
|
||||||
for colIdx, value := range row {
|
|
||||||
cell, err := excelize.CoordinatesToCellName(colIdx+1, rowIdx+2)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed resolving data cell: %v", err)
|
|
||||||
}
|
|
||||||
if err := f.SetCellValue(sheetName, cell, value); err != nil {
|
|
||||||
t.Fatalf("failed setting data cell: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
path := filepath.Join(t.TempDir(), "manual_inputs.xlsx")
|
|
||||||
if err := f.SaveAs(path); err != nil {
|
|
||||||
t.Fatalf("failed saving workbook: %v", err)
|
|
||||||
}
|
|
||||||
if err := f.Close(); err != nil {
|
|
||||||
t.Fatalf("failed closing workbook: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
||||||
func mustJakartaLocation(t *testing.T) *time.Location {
|
|
||||||
t.Helper()
|
|
||||||
location, err := time.LoadLocation("Asia/Jakarta")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed loading Asia/Jakarta location: %v", err)
|
|
||||||
}
|
|
||||||
return location
|
|
||||||
}
|
|
||||||
|
|
||||||
func mustDateInLocation(t *testing.T, raw string, location *time.Location) time.Time {
|
|
||||||
t.Helper()
|
|
||||||
value, err := time.ParseInLocation(dateLayout, raw, location)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed parsing date %q: %v", raw, err)
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
func hasIssue(issues []validationIssue, row int, field, messageContains string) bool {
|
|
||||||
for _, issue := range issues {
|
|
||||||
if issue.Row != row {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if issue.Field != field {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if strings.Contains(issue.Message, messageContains) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
type fakeInvalidation struct {
|
|
||||||
fromDate time.Time
|
|
||||||
farmIDs []uint
|
|
||||||
}
|
|
||||||
|
|
||||||
type fakeManualInputStore struct {
|
|
||||||
failUpsertOnProjectFlockID uint
|
|
||||||
failDeleteOnProjectFlockID uint
|
|
||||||
upserts []entity.FarmDepreciationManualInput
|
|
||||||
invalidations []fakeInvalidation
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *fakeManualInputStore) UpsertManualInput(_ context.Context, row *entity.FarmDepreciationManualInput) error {
|
|
||||||
if row == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if s.failUpsertOnProjectFlockID > 0 && row.ProjectFlockId == s.failUpsertOnProjectFlockID {
|
|
||||||
return fmt.Errorf("forced upsert failure for project_flock_id=%d", row.ProjectFlockId)
|
|
||||||
}
|
|
||||||
cloned := *row
|
|
||||||
s.upserts = append(s.upserts, cloned)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *fakeManualInputStore) DeleteSnapshotsFromDate(_ context.Context, fromDate time.Time, farmIDs []uint) error {
|
|
||||||
if s.failDeleteOnProjectFlockID > 0 {
|
|
||||||
for _, farmID := range farmIDs {
|
|
||||||
if farmID == s.failDeleteOnProjectFlockID {
|
|
||||||
return fmt.Errorf("forced delete failure for project_flock_id=%d", farmID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
copiedFarmIDs := append([]uint{}, farmIDs...)
|
|
||||||
s.invalidations = append(s.invalidations, fakeInvalidation{
|
|
||||||
fromDate: fromDate,
|
|
||||||
farmIDs: copiedFarmIDs,
|
|
||||||
})
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type fakeTransactionRunner struct {
|
|
||||||
txCalls int
|
|
||||||
failUpsertOnProjectFlockID uint
|
|
||||||
failDeleteOnProjectFlockID uint
|
|
||||||
committedUpserts []entity.FarmDepreciationManualInput
|
|
||||||
committedInvalidations []fakeInvalidation
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *fakeTransactionRunner) InTx(ctx context.Context, fn func(store manualInputStore) error) error {
|
|
||||||
r.txCalls++
|
|
||||||
|
|
||||||
txStore := &fakeManualInputStore{
|
|
||||||
failUpsertOnProjectFlockID: r.failUpsertOnProjectFlockID,
|
|
||||||
failDeleteOnProjectFlockID: r.failDeleteOnProjectFlockID,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := fn(txStore); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
r.committedUpserts = append(r.committedUpserts, txStore.upserts...)
|
|
||||||
r.committedInvalidations = append(r.committedInvalidations, txStore.invalidations...)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ txRunner = (*fakeTransactionRunner)(nil)
|
|
||||||
var _ manualInputStore = (*fakeManualInputStore)(nil)
|
|
||||||
|
|
||||||
func TestBuildMissingFarmIssues(t *testing.T) {
|
|
||||||
location := mustJakartaLocation(t)
|
|
||||||
rows := []manualInputImportRow{
|
|
||||||
{
|
|
||||||
RowNumber: 2,
|
|
||||||
ProjectFlockID: 101,
|
|
||||||
TotalCost: 1000,
|
|
||||||
CutoverDate: mustDateInLocation(t, "2026-06-01", location),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
RowNumber: 3,
|
|
||||||
ProjectFlockID: 102,
|
|
||||||
TotalCost: 1000,
|
|
||||||
CutoverDate: mustDateInLocation(t, "2026-06-01", location),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
issues := buildMissingFarmIssues(rows, map[uint]string{
|
|
||||||
101: "Farm A",
|
|
||||||
})
|
|
||||||
if len(issues) != 1 {
|
|
||||||
t.Fatalf("expected 1 issue, got %+v", issues)
|
|
||||||
}
|
|
||||||
if issues[0].Row != 3 || issues[0].Field != "project_flock_id" {
|
|
||||||
t.Fatalf("unexpected issue: %+v", issues[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApplyImportRows_PropagatesDeleteError(t *testing.T) {
|
|
||||||
location := mustJakartaLocation(t)
|
|
||||||
runner := &fakeTransactionRunner{
|
|
||||||
failDeleteOnProjectFlockID: 101,
|
|
||||||
}
|
|
||||||
rows := []manualInputImportRow{
|
|
||||||
{
|
|
||||||
RowNumber: 2,
|
|
||||||
ProjectFlockID: 101,
|
|
||||||
TotalCost: 1000,
|
|
||||||
CutoverDate: mustDateInLocation(t, "2026-06-01", location),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
err := applyImportRows(context.Background(), runner, rows)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected delete failure")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "snapshot invalidation failed") {
|
|
||||||
t.Fatalf("expected snapshot invalidation error message, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolveSheetName_ErrorWhenSheetNotFound(t *testing.T) {
|
|
||||||
workbook := excelize.NewFile()
|
|
||||||
defer func() {
|
|
||||||
_ = workbook.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
_, err := resolveSheetName(workbook, "unknown")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error when sheet is missing")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApplyIfRequested_ApplyUsesRunnerError(t *testing.T) {
|
|
||||||
location := mustJakartaLocation(t)
|
|
||||||
rows := []manualInputImportRow{
|
|
||||||
{
|
|
||||||
RowNumber: 2,
|
|
||||||
ProjectFlockID: 101,
|
|
||||||
TotalCost: 1000,
|
|
||||||
CutoverDate: mustDateInLocation(t, "2026-06-01", location),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
runner := &errorTxRunner{err: errors.New("tx failed")}
|
|
||||||
|
|
||||||
err := applyIfRequested(context.Background(), true, runner, rows)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected transaction error")
|
|
||||||
}
|
|
||||||
if err.Error() != "tx failed" {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type errorTxRunner struct {
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *errorTxRunner) InTx(_ context.Context, _ func(store manualInputStore) error) error {
|
|
||||||
return r.err
|
|
||||||
}
|
|
||||||
@@ -1,602 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/xuri/excelize/v2"
|
|
||||||
"gitlab.com/mbugroup/lti-api.git/internal/config"
|
|
||||||
"gitlab.com/mbugroup/lti-api.git/internal/database"
|
|
||||||
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
type importOptions struct {
|
|
||||||
FilePath string
|
|
||||||
Sheet string
|
|
||||||
Apply bool
|
|
||||||
}
|
|
||||||
|
|
||||||
type headerIndexes struct {
|
|
||||||
KandangID int
|
|
||||||
KandangName int
|
|
||||||
HouseType int
|
|
||||||
}
|
|
||||||
|
|
||||||
type kandangHouseTypeImportRow struct {
|
|
||||||
RowNumber int
|
|
||||||
KandangID uint
|
|
||||||
KandangName string
|
|
||||||
HouseType string
|
|
||||||
}
|
|
||||||
|
|
||||||
type validationIssue struct {
|
|
||||||
Row int
|
|
||||||
Field string
|
|
||||||
Message string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (i validationIssue) Error() string {
|
|
||||||
if i.Row > 0 {
|
|
||||||
return fmt.Sprintf("row=%d field=%s message=%s", i.Row, i.Field, i.Message)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("field=%s message=%s", i.Field, i.Message)
|
|
||||||
}
|
|
||||||
|
|
||||||
type kandangResolver interface {
|
|
||||||
ResolveActiveKandangs(ctx context.Context, kandangIDs []uint) (map[uint]string, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type dbKandangResolver struct {
|
|
||||||
db *gorm.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
type txRunner interface {
|
|
||||||
InTx(ctx context.Context, fn func(store kandangHouseTypeStore) error) error
|
|
||||||
}
|
|
||||||
|
|
||||||
type dbTxRunner struct {
|
|
||||||
db *gorm.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
type kandangHouseTypeStore interface {
|
|
||||||
UpdateKandangHouseType(ctx context.Context, kandangID uint, houseType string) (bool, error)
|
|
||||||
NormalizeNullHouseType(ctx context.Context) (int64, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type dbKandangHouseTypeStore struct {
|
|
||||||
db *gorm.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
type kandangIdentityRow struct {
|
|
||||||
ID uint `gorm:"column:id"`
|
|
||||||
Name string `gorm:"column:name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type applyRowResult struct {
|
|
||||||
RowNumber int
|
|
||||||
KandangID uint
|
|
||||||
HouseType string
|
|
||||||
Changed bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
var opts importOptions
|
|
||||||
flag.StringVar(&opts.FilePath, "file", "", "Path to .xlsx file (required)")
|
|
||||||
flag.StringVar(&opts.Sheet, "sheet", "", "Sheet name (optional, default: first sheet)")
|
|
||||||
flag.BoolVar(&opts.Apply, "apply", false, "Apply changes. If false, run as dry-run")
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
opts.FilePath = strings.TrimSpace(opts.FilePath)
|
|
||||||
opts.Sheet = strings.TrimSpace(opts.Sheet)
|
|
||||||
|
|
||||||
if opts.FilePath == "" {
|
|
||||||
log.Fatal("--file is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
sheetName, rows, parseIssues, err := parseKandangHouseTypeFile(opts.FilePath, opts.Sheet)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("failed reading excel: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
db := database.Connect(config.DBHost, config.DBName)
|
|
||||||
resolver := dbKandangResolver{db: db}
|
|
||||||
|
|
||||||
kandangNameByID, err := resolver.ResolveActiveKandangs(ctx, collectKandangIDs(rows))
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("failed validating kandang_id against kandangs: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
issues := append([]validationIssue{}, parseIssues...)
|
|
||||||
issues = append(issues, buildMissingKandangIssues(rows, kandangNameByID)...)
|
|
||||||
issues = append(issues, buildNameMismatchIssues(rows, kandangNameByID)...)
|
|
||||||
sortValidationIssues(issues)
|
|
||||||
|
|
||||||
fmt.Printf("Mode: %s\n", modeLabel(opts.Apply))
|
|
||||||
fmt.Printf("File: %s\n", opts.FilePath)
|
|
||||||
fmt.Printf("Sheet: %s\n", sheetName)
|
|
||||||
fmt.Printf("Rows parsed: %d\n", len(rows))
|
|
||||||
fmt.Printf("Rows invalid: %d\n", len(issues))
|
|
||||||
fmt.Println()
|
|
||||||
|
|
||||||
if len(rows) > 0 {
|
|
||||||
printPlanRows(rows, kandangNameByID)
|
|
||||||
fmt.Println()
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(issues) > 0 {
|
|
||||||
fmt.Println("Validation errors:")
|
|
||||||
for _, issue := range issues {
|
|
||||||
fmt.Printf("ERROR %s\n", issue.Error())
|
|
||||||
}
|
|
||||||
fmt.Println()
|
|
||||||
fmt.Printf("Summary: planned=%d applied=0 normalized_null_to_open_house=0 failed=%d\n", len(rows), len(issues))
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !opts.Apply {
|
|
||||||
fmt.Printf("Summary: planned=%d applied=0 normalized_null_to_open_house=0 failed=0\n", len(rows))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
rowResults, normalizedCount, err := applyImportRows(ctx, dbTxRunner{db: db}, rows)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("apply failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, result := range rowResults {
|
|
||||||
fmt.Printf(
|
|
||||||
"DONE row=%d kandang_id=%d house_type=%s status=%s\n",
|
|
||||||
result.RowNumber,
|
|
||||||
result.KandangID,
|
|
||||||
result.HouseType,
|
|
||||||
applyStatus(result.Changed),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
appliedCount := countChangedRows(rowResults)
|
|
||||||
fmt.Println()
|
|
||||||
fmt.Printf(
|
|
||||||
"Summary: planned=%d applied=%d normalized_null_to_open_house=%d failed=0\n",
|
|
||||||
len(rows),
|
|
||||||
appliedCount,
|
|
||||||
normalizedCount,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseKandangHouseTypeFile(
|
|
||||||
filePath string,
|
|
||||||
requestedSheet string,
|
|
||||||
) (string, []kandangHouseTypeImportRow, []validationIssue, error) {
|
|
||||||
workbook, err := excelize.OpenFile(filePath)
|
|
||||||
if err != nil {
|
|
||||||
return "", nil, nil, err
|
|
||||||
}
|
|
||||||
defer func() {
|
|
||||||
_ = workbook.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
sheetName, err := resolveSheetName(workbook, requestedSheet)
|
|
||||||
if err != nil {
|
|
||||||
return "", nil, nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
allRows, err := workbook.GetRows(sheetName, excelize.Options{RawCellValue: true})
|
|
||||||
if err != nil {
|
|
||||||
return "", nil, nil, err
|
|
||||||
}
|
|
||||||
if len(allRows) == 0 {
|
|
||||||
return sheetName, nil, []validationIssue{{Field: "header", Message: "sheet is empty"}}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
indexes, headerIssues := parseHeaderIndexes(allRows[0])
|
|
||||||
if len(headerIssues) > 0 {
|
|
||||||
return sheetName, nil, headerIssues, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
rows := make([]kandangHouseTypeImportRow, 0, len(allRows)-1)
|
|
||||||
issues := make([]validationIssue, 0)
|
|
||||||
seenKandangIDs := make(map[uint]int)
|
|
||||||
|
|
||||||
for idx := 1; idx < len(allRows); idx++ {
|
|
||||||
rowNumber := idx + 1
|
|
||||||
rawRow := allRows[idx]
|
|
||||||
|
|
||||||
if isRowEmpty(rawRow) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
parsed, rowIssues := parseDataRow(rawRow, rowNumber, indexes, seenKandangIDs)
|
|
||||||
if len(rowIssues) > 0 {
|
|
||||||
issues = append(issues, rowIssues...)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
rows = append(rows, *parsed)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(rows) == 0 && len(issues) == 0 {
|
|
||||||
issues = append(issues, validationIssue{Field: "rows", Message: "no data rows found"})
|
|
||||||
}
|
|
||||||
|
|
||||||
return sheetName, rows, issues, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveSheetName(workbook *excelize.File, requestedSheet string) (string, error) {
|
|
||||||
if workbook == nil {
|
|
||||||
return "", fmt.Errorf("workbook is nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
sheets := workbook.GetSheetList()
|
|
||||||
if len(sheets) == 0 {
|
|
||||||
return "", fmt.Errorf("workbook has no sheets")
|
|
||||||
}
|
|
||||||
|
|
||||||
if requestedSheet == "" {
|
|
||||||
return sheets[0], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, sheet := range sheets {
|
|
||||||
if strings.EqualFold(strings.TrimSpace(sheet), strings.TrimSpace(requestedSheet)) {
|
|
||||||
return sheet, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", fmt.Errorf("sheet %q not found", requestedSheet)
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseHeaderIndexes(headerRow []string) (headerIndexes, []validationIssue) {
|
|
||||||
indexes := headerIndexes{KandangID: -1, KandangName: -1, HouseType: -1}
|
|
||||||
issues := make([]validationIssue, 0)
|
|
||||||
|
|
||||||
for idx, raw := range headerRow {
|
|
||||||
header := normalizeHeader(raw)
|
|
||||||
if header == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
switch header {
|
|
||||||
case "kandang_id":
|
|
||||||
if indexes.KandangID >= 0 {
|
|
||||||
issues = append(issues, validationIssue{Field: "header", Message: "duplicate header kandang_id"})
|
|
||||||
}
|
|
||||||
indexes.KandangID = idx
|
|
||||||
case "kandang_name":
|
|
||||||
if indexes.KandangName >= 0 {
|
|
||||||
issues = append(issues, validationIssue{Field: "header", Message: "duplicate header kandang_name"})
|
|
||||||
}
|
|
||||||
indexes.KandangName = idx
|
|
||||||
case "house_type", "type_house":
|
|
||||||
if indexes.HouseType >= 0 {
|
|
||||||
issues = append(issues, validationIssue{Field: "header", Message: "duplicate header house_type"})
|
|
||||||
}
|
|
||||||
indexes.HouseType = idx
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if indexes.KandangID < 0 {
|
|
||||||
issues = append(issues, validationIssue{Field: "kandang_id", Message: "required header is missing"})
|
|
||||||
}
|
|
||||||
if indexes.KandangName < 0 {
|
|
||||||
issues = append(issues, validationIssue{Field: "kandang_name", Message: "required header is missing"})
|
|
||||||
}
|
|
||||||
if indexes.HouseType < 0 {
|
|
||||||
issues = append(issues, validationIssue{Field: "house_type", Message: "required header is missing"})
|
|
||||||
}
|
|
||||||
|
|
||||||
return indexes, issues
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseDataRow(
|
|
||||||
rawRow []string,
|
|
||||||
rowNumber int,
|
|
||||||
indexes headerIndexes,
|
|
||||||
seenKandangIDs map[uint]int,
|
|
||||||
) (*kandangHouseTypeImportRow, []validationIssue) {
|
|
||||||
issues := make([]validationIssue, 0)
|
|
||||||
|
|
||||||
kandangIDRaw := strings.TrimSpace(cellValue(rawRow, indexes.KandangID))
|
|
||||||
kandangID, err := parsePositiveUint(kandangIDRaw)
|
|
||||||
if err != nil {
|
|
||||||
issues = append(issues, validationIssue{Row: rowNumber, Field: "kandang_id", Message: err.Error()})
|
|
||||||
}
|
|
||||||
|
|
||||||
kandangNameRaw := strings.TrimSpace(cellValue(rawRow, indexes.KandangName))
|
|
||||||
if kandangNameRaw == "" {
|
|
||||||
issues = append(issues, validationIssue{Row: rowNumber, Field: "kandang_name", Message: "is required"})
|
|
||||||
}
|
|
||||||
|
|
||||||
houseTypeRaw := strings.TrimSpace(cellValue(rawRow, indexes.HouseType))
|
|
||||||
houseType, err := normalizeHouseType(houseTypeRaw)
|
|
||||||
if err != nil {
|
|
||||||
issues = append(issues, validationIssue{Row: rowNumber, Field: "house_type", Message: err.Error()})
|
|
||||||
}
|
|
||||||
|
|
||||||
if kandangID > 0 {
|
|
||||||
if previousRow, exists := seenKandangIDs[kandangID]; exists {
|
|
||||||
issues = append(issues, validationIssue{
|
|
||||||
Row: rowNumber,
|
|
||||||
Field: "kandang_id",
|
|
||||||
Message: fmt.Sprintf("duplicate value %d (already used in row %d)", kandangID, previousRow),
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
seenKandangIDs[kandangID] = rowNumber
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(issues) > 0 {
|
|
||||||
return nil, issues
|
|
||||||
}
|
|
||||||
|
|
||||||
return &kandangHouseTypeImportRow{
|
|
||||||
RowNumber: rowNumber,
|
|
||||||
KandangID: kandangID,
|
|
||||||
KandangName: kandangNameRaw,
|
|
||||||
HouseType: houseType,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeHouseType(raw string) (string, error) {
|
|
||||||
normalized := strings.ToLower(strings.TrimSpace(raw))
|
|
||||||
if normalized == "" {
|
|
||||||
return string(utils.HouseTypeOpenHouse), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
switch normalized {
|
|
||||||
case string(utils.HouseTypeOpenHouse), string(utils.HouseTypeCloseHouse):
|
|
||||||
return normalized, nil
|
|
||||||
default:
|
|
||||||
return "", fmt.Errorf("must be one of: open_house, close_house (or empty for default open_house)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func parsePositiveUint(raw string) (uint, error) {
|
|
||||||
if raw == "" {
|
|
||||||
return 0, fmt.Errorf("is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
uintValue, err := strconv.ParseUint(raw, 10, 64)
|
|
||||||
if err == nil {
|
|
||||||
if uintValue == 0 {
|
|
||||||
return 0, fmt.Errorf("must be greater than 0")
|
|
||||||
}
|
|
||||||
return uint(uintValue), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
floatValue, floatErr := strconv.ParseFloat(raw, 64)
|
|
||||||
if floatErr != nil {
|
|
||||||
return 0, fmt.Errorf("must be a positive integer")
|
|
||||||
}
|
|
||||||
if floatValue <= 0 {
|
|
||||||
return 0, fmt.Errorf("must be greater than 0")
|
|
||||||
}
|
|
||||||
if floatValue != float64(uint(floatValue)) {
|
|
||||||
return 0, fmt.Errorf("must be a positive integer")
|
|
||||||
}
|
|
||||||
|
|
||||||
return uint(floatValue), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func isRowEmpty(row []string) bool {
|
|
||||||
for _, cell := range row {
|
|
||||||
if strings.TrimSpace(cell) != "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeHeader(raw string) string {
|
|
||||||
return strings.ToLower(strings.TrimSpace(raw))
|
|
||||||
}
|
|
||||||
|
|
||||||
func cellValue(row []string, index int) string {
|
|
||||||
if index < 0 || index >= len(row) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return row[index]
|
|
||||||
}
|
|
||||||
|
|
||||||
func collectKandangIDs(rows []kandangHouseTypeImportRow) []uint {
|
|
||||||
ids := make([]uint, 0, len(rows))
|
|
||||||
seen := make(map[uint]struct{}, len(rows))
|
|
||||||
for _, row := range rows {
|
|
||||||
if row.KandangID == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if _, exists := seen[row.KandangID]; exists {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[row.KandangID] = struct{}{}
|
|
||||||
ids = append(ids, row.KandangID)
|
|
||||||
}
|
|
||||||
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
|
||||||
return ids
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r dbKandangResolver) ResolveActiveKandangs(ctx context.Context, kandangIDs []uint) (map[uint]string, error) {
|
|
||||||
result := make(map[uint]string)
|
|
||||||
if len(kandangIDs) == 0 {
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
rows := make([]kandangIdentityRow, 0, len(kandangIDs))
|
|
||||||
if err := r.db.WithContext(ctx).
|
|
||||||
Table("kandangs").
|
|
||||||
Select("id, name").
|
|
||||||
Where("id IN ?", kandangIDs).
|
|
||||||
Where("deleted_at IS NULL").
|
|
||||||
Scan(&rows).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, row := range rows {
|
|
||||||
result[row.ID] = row.Name
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildMissingKandangIssues(rows []kandangHouseTypeImportRow, kandangNameByID map[uint]string) []validationIssue {
|
|
||||||
issues := make([]validationIssue, 0)
|
|
||||||
for _, row := range rows {
|
|
||||||
if _, exists := kandangNameByID[row.KandangID]; exists {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
issues = append(issues, validationIssue{
|
|
||||||
Row: row.RowNumber,
|
|
||||||
Field: "kandang_id",
|
|
||||||
Message: fmt.Sprintf("value %d must reference an active kandang", row.KandangID),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return issues
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildNameMismatchIssues(rows []kandangHouseTypeImportRow, kandangNameByID map[uint]string) []validationIssue {
|
|
||||||
issues := make([]validationIssue, 0)
|
|
||||||
for _, row := range rows {
|
|
||||||
dbName, exists := kandangNameByID[row.KandangID]
|
|
||||||
if !exists {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if strings.EqualFold(strings.TrimSpace(row.KandangName), strings.TrimSpace(dbName)) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
issues = append(issues, validationIssue{
|
|
||||||
Row: row.RowNumber,
|
|
||||||
Field: "kandang_name",
|
|
||||||
Message: fmt.Sprintf("value %q does not match kandang_id %d name %q", row.KandangName, row.KandangID, dbName),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return issues
|
|
||||||
}
|
|
||||||
|
|
||||||
func printPlanRows(rows []kandangHouseTypeImportRow, kandangNameByID map[uint]string) {
|
|
||||||
for _, row := range rows {
|
|
||||||
fmt.Printf(
|
|
||||||
"PLAN row=%d kandang_id=%d kandang_name_file=%q kandang_name_db=%q house_type=%q\n",
|
|
||||||
row.RowNumber,
|
|
||||||
row.KandangID,
|
|
||||||
row.KandangName,
|
|
||||||
kandangNameByID[row.KandangID],
|
|
||||||
row.HouseType,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func sortValidationIssues(issues []validationIssue) {
|
|
||||||
sort.Slice(issues, func(i, j int) bool {
|
|
||||||
if issues[i].Row == issues[j].Row {
|
|
||||||
if issues[i].Field == issues[j].Field {
|
|
||||||
return issues[i].Message < issues[j].Message
|
|
||||||
}
|
|
||||||
return issues[i].Field < issues[j].Field
|
|
||||||
}
|
|
||||||
return issues[i].Row < issues[j].Row
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func applyImportRows(
|
|
||||||
ctx context.Context,
|
|
||||||
runner txRunner,
|
|
||||||
rows []kandangHouseTypeImportRow,
|
|
||||||
) ([]applyRowResult, int64, error) {
|
|
||||||
results := make([]applyRowResult, 0, len(rows))
|
|
||||||
normalizedNullCount := int64(0)
|
|
||||||
|
|
||||||
err := runner.InTx(ctx, func(store kandangHouseTypeStore) error {
|
|
||||||
for _, row := range rows {
|
|
||||||
changed, err := store.UpdateKandangHouseType(ctx, row.KandangID, row.HouseType)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("row %d kandang_id=%d update failed: %w", row.RowNumber, row.KandangID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
results = append(results, applyRowResult{
|
|
||||||
RowNumber: row.RowNumber,
|
|
||||||
KandangID: row.KandangID,
|
|
||||||
HouseType: row.HouseType,
|
|
||||||
Changed: changed,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
normalized, err := store.NormalizeNullHouseType(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("normalize null house_type to open_house failed: %w", err)
|
|
||||||
}
|
|
||||||
normalizedNullCount = normalized
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return results, normalizedNullCount, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r dbTxRunner) InTx(ctx context.Context, fn func(store kandangHouseTypeStore) error) error {
|
|
||||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
||||||
return fn(dbKandangHouseTypeStore{db: tx})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s dbKandangHouseTypeStore) UpdateKandangHouseType(ctx context.Context, kandangID uint, houseType string) (bool, error) {
|
|
||||||
result := s.db.WithContext(ctx).Exec(`
|
|
||||||
UPDATE kandangs
|
|
||||||
SET house_type = ?::house_type_enum,
|
|
||||||
updated_at = NOW()
|
|
||||||
WHERE id = ?
|
|
||||||
AND deleted_at IS NULL
|
|
||||||
AND house_type IS DISTINCT FROM ?::house_type_enum
|
|
||||||
`, houseType, kandangID, houseType)
|
|
||||||
if result.Error != nil {
|
|
||||||
return false, result.Error
|
|
||||||
}
|
|
||||||
return result.RowsAffected > 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s dbKandangHouseTypeStore) NormalizeNullHouseType(ctx context.Context) (int64, error) {
|
|
||||||
result := s.db.WithContext(ctx).Exec(`
|
|
||||||
UPDATE kandangs
|
|
||||||
SET house_type = 'open_house'::house_type_enum,
|
|
||||||
updated_at = NOW()
|
|
||||||
WHERE deleted_at IS NULL
|
|
||||||
AND house_type IS NULL
|
|
||||||
`)
|
|
||||||
if result.Error != nil {
|
|
||||||
return 0, result.Error
|
|
||||||
}
|
|
||||||
return result.RowsAffected, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func modeLabel(apply bool) string {
|
|
||||||
if apply {
|
|
||||||
return "APPLY"
|
|
||||||
}
|
|
||||||
return "DRY-RUN"
|
|
||||||
}
|
|
||||||
|
|
||||||
func applyStatus(changed bool) string {
|
|
||||||
if changed {
|
|
||||||
return "UPDATED"
|
|
||||||
}
|
|
||||||
return "UNCHANGED"
|
|
||||||
}
|
|
||||||
|
|
||||||
func countChangedRows(results []applyRowResult) int {
|
|
||||||
count := 0
|
|
||||||
for _, item := range results {
|
|
||||||
if item.Changed {
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return count
|
|
||||||
}
|
|
||||||
@@ -1,280 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/xuri/excelize/v2"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestParseKandangHouseTypeFile_ValidSingleRowAndDefaultHouseType(t *testing.T) {
|
|
||||||
filePath := createWorkbook(
|
|
||||||
t,
|
|
||||||
"kandang_house_type",
|
|
||||||
[]string{"kandang_id", "kandang_name", "house_type"},
|
|
||||||
[][]string{{"101", "Kandang A1", ""}},
|
|
||||||
)
|
|
||||||
|
|
||||||
sheet, rows, issues, err := parseKandangHouseTypeFile(filePath, "")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
if sheet != "kandang_house_type" {
|
|
||||||
t.Fatalf("expected sheet kandang_house_type, got %q", sheet)
|
|
||||||
}
|
|
||||||
if len(issues) != 0 {
|
|
||||||
t.Fatalf("expected no issues, got %+v", issues)
|
|
||||||
}
|
|
||||||
if len(rows) != 1 {
|
|
||||||
t.Fatalf("expected 1 row, got %d", len(rows))
|
|
||||||
}
|
|
||||||
if rows[0].KandangID != 101 {
|
|
||||||
t.Fatalf("expected kandang_id 101, got %d", rows[0].KandangID)
|
|
||||||
}
|
|
||||||
if rows[0].KandangName != "Kandang A1" {
|
|
||||||
t.Fatalf("expected kandang_name Kandang A1, got %q", rows[0].KandangName)
|
|
||||||
}
|
|
||||||
if rows[0].HouseType != "open_house" {
|
|
||||||
t.Fatalf("expected default house_type open_house, got %q", rows[0].HouseType)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseKandangHouseTypeFile_TypeHouseHeaderAlias(t *testing.T) {
|
|
||||||
filePath := createWorkbook(
|
|
||||||
t,
|
|
||||||
"kandang_house_type",
|
|
||||||
[]string{"kandang_id", "kandang_name", "type_house"},
|
|
||||||
[][]string{{"101", "Kandang A1", "close_house"}},
|
|
||||||
)
|
|
||||||
|
|
||||||
_, rows, issues, err := parseKandangHouseTypeFile(filePath, "kandang_house_type")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
if len(issues) != 0 {
|
|
||||||
t.Fatalf("expected no issues, got %+v", issues)
|
|
||||||
}
|
|
||||||
if len(rows) != 1 || rows[0].HouseType != "close_house" {
|
|
||||||
t.Fatalf("expected parsed close_house row, got %+v", rows)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseKandangHouseTypeFile_InvalidHouseType(t *testing.T) {
|
|
||||||
filePath := createWorkbook(
|
|
||||||
t,
|
|
||||||
"kandang_house_type",
|
|
||||||
[]string{"kandang_id", "kandang_name", "house_type"},
|
|
||||||
[][]string{{"101", "Kandang A1", "semi_house"}},
|
|
||||||
)
|
|
||||||
|
|
||||||
_, rows, issues, err := parseKandangHouseTypeFile(filePath, "")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
if len(rows) != 0 {
|
|
||||||
t.Fatalf("expected no valid rows, got %d", len(rows))
|
|
||||||
}
|
|
||||||
if !hasIssue(issues, 2, "house_type", "must be one of") {
|
|
||||||
t.Fatalf("expected invalid house_type issue, got %+v", issues)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseKandangHouseTypeFile_DuplicateKandangID(t *testing.T) {
|
|
||||||
filePath := createWorkbook(
|
|
||||||
t,
|
|
||||||
"kandang_house_type",
|
|
||||||
[]string{"kandang_id", "kandang_name", "house_type"},
|
|
||||||
[][]string{
|
|
||||||
{"101", "Kandang A1", "open_house"},
|
|
||||||
{"101", "Kandang A2", "close_house"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
_, rows, issues, err := parseKandangHouseTypeFile(filePath, "")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
if len(rows) != 1 {
|
|
||||||
t.Fatalf("expected first row valid and second invalid, got %d", len(rows))
|
|
||||||
}
|
|
||||||
if !hasIssue(issues, 3, "kandang_id", "duplicate value 101") {
|
|
||||||
t.Fatalf("expected duplicate kandang_id issue, got %+v", issues)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildNameMismatchIssues(t *testing.T) {
|
|
||||||
rows := []kandangHouseTypeImportRow{{
|
|
||||||
RowNumber: 2,
|
|
||||||
KandangID: 10,
|
|
||||||
KandangName: "Kandang Salah",
|
|
||||||
HouseType: "open_house",
|
|
||||||
}}
|
|
||||||
|
|
||||||
issues := buildNameMismatchIssues(rows, map[uint]string{10: "Kandang Benar"})
|
|
||||||
if !hasIssue(issues, 2, "kandang_name", "does not match") {
|
|
||||||
t.Fatalf("expected name mismatch issue, got %+v", issues)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApplyImportRows_Success(t *testing.T) {
|
|
||||||
store := &fakeStore{
|
|
||||||
changedByID: map[uint]bool{101: true, 102: false},
|
|
||||||
normalizeResult: 3,
|
|
||||||
}
|
|
||||||
runner := &fakeTransactionRunner{store: store}
|
|
||||||
|
|
||||||
rows := []kandangHouseTypeImportRow{
|
|
||||||
{RowNumber: 2, KandangID: 101, HouseType: "open_house"},
|
|
||||||
{RowNumber: 3, KandangID: 102, HouseType: "close_house"},
|
|
||||||
}
|
|
||||||
|
|
||||||
results, normalized, err := applyImportRows(context.Background(), runner, rows)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
if runner.txCalls != 1 {
|
|
||||||
t.Fatalf("expected 1 tx call, got %d", runner.txCalls)
|
|
||||||
}
|
|
||||||
if len(results) != 2 {
|
|
||||||
t.Fatalf("expected 2 row results, got %d", len(results))
|
|
||||||
}
|
|
||||||
if normalized != 3 {
|
|
||||||
t.Fatalf("expected normalized count 3, got %d", normalized)
|
|
||||||
}
|
|
||||||
if !results[0].Changed || results[1].Changed {
|
|
||||||
t.Fatalf("unexpected changed flags: %+v", results)
|
|
||||||
}
|
|
||||||
if len(store.updateCalls) != 2 {
|
|
||||||
t.Fatalf("expected 2 update calls, got %d", len(store.updateCalls))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApplyImportRows_FailOnUpdate(t *testing.T) {
|
|
||||||
store := &fakeStore{
|
|
||||||
updateErrByID: map[uint]error{101: errors.New("boom")},
|
|
||||||
}
|
|
||||||
runner := &fakeTransactionRunner{store: store}
|
|
||||||
|
|
||||||
rows := []kandangHouseTypeImportRow{{RowNumber: 2, KandangID: 101, HouseType: "open_house"}}
|
|
||||||
|
|
||||||
_, _, err := applyImportRows(context.Background(), runner, rows)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf("expected error, got nil")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "update failed") {
|
|
||||||
t.Fatalf("expected update failed error, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCountChangedRows(t *testing.T) {
|
|
||||||
count := countChangedRows([]applyRowResult{{Changed: true}, {Changed: false}, {Changed: true}})
|
|
||||||
if count != 2 {
|
|
||||||
t.Fatalf("expected 2 changed rows, got %d", count)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type fakeTransactionRunner struct {
|
|
||||||
store *fakeStore
|
|
||||||
txCalls int
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeTransactionRunner) InTx(_ context.Context, fn func(store kandangHouseTypeStore) error) error {
|
|
||||||
f.txCalls++
|
|
||||||
return fn(f.store)
|
|
||||||
}
|
|
||||||
|
|
||||||
type updateCall struct {
|
|
||||||
kandangID uint
|
|
||||||
houseType string
|
|
||||||
}
|
|
||||||
|
|
||||||
type fakeStore struct {
|
|
||||||
updateCalls []updateCall
|
|
||||||
changedByID map[uint]bool
|
|
||||||
updateErrByID map[uint]error
|
|
||||||
normalizeResult int64
|
|
||||||
normalizeErr error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeStore) UpdateKandangHouseType(_ context.Context, kandangID uint, houseType string) (bool, error) {
|
|
||||||
f.updateCalls = append(f.updateCalls, updateCall{kandangID: kandangID, houseType: houseType})
|
|
||||||
if err, exists := f.updateErrByID[kandangID]; exists {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if changed, exists := f.changedByID[kandangID]; exists {
|
|
||||||
return changed, nil
|
|
||||||
}
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeStore) NormalizeNullHouseType(_ context.Context) (int64, error) {
|
|
||||||
if f.normalizeErr != nil {
|
|
||||||
return 0, f.normalizeErr
|
|
||||||
}
|
|
||||||
return f.normalizeResult, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func createWorkbook(t *testing.T, sheetName string, headers []string, rows [][]string) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
f := excelize.NewFile()
|
|
||||||
if sheetName == "" {
|
|
||||||
sheetName = "Sheet1"
|
|
||||||
}
|
|
||||||
defaultSheet := f.GetSheetName(0)
|
|
||||||
if defaultSheet != sheetName {
|
|
||||||
idx, err := f.NewSheet(sheetName)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed creating sheet: %v", err)
|
|
||||||
}
|
|
||||||
f.SetActiveSheet(idx)
|
|
||||||
_ = f.DeleteSheet(defaultSheet)
|
|
||||||
}
|
|
||||||
|
|
||||||
for idx, header := range headers {
|
|
||||||
cell, err := excelize.CoordinatesToCellName(idx+1, 1)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed computing header cell: %v", err)
|
|
||||||
}
|
|
||||||
if err := f.SetCellValue(sheetName, cell, header); err != nil {
|
|
||||||
t.Fatalf("failed setting header cell: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for rowIdx, row := range rows {
|
|
||||||
for colIdx, value := range row {
|
|
||||||
cell, err := excelize.CoordinatesToCellName(colIdx+1, rowIdx+2)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed computing row cell: %v", err)
|
|
||||||
}
|
|
||||||
if err := f.SetCellValue(sheetName, cell, value); err != nil {
|
|
||||||
t.Fatalf("failed setting row cell: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
path := filepath.Join(t.TempDir(), "kandang_house_type.xlsx")
|
|
||||||
if err := f.SaveAs(path); err != nil {
|
|
||||||
t.Fatalf("failed saving workbook: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
||||||
func hasIssue(issues []validationIssue, row int, field string, contains string) bool {
|
|
||||||
for _, issue := range issues {
|
|
||||||
if issue.Row != row {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if issue.Field != field {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if strings.Contains(issue.Message, contains) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gitlab.com/mbugroup/lti-api.git/internal/config"
|
|
||||||
"gitlab.com/mbugroup/lti-api.git/internal/database"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
type options struct {
|
|
||||||
FilePath string
|
|
||||||
Apply bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
var opts options
|
|
||||||
flag.StringVar(&opts.FilePath, "file", "", "Path to .sql file (required)")
|
|
||||||
flag.BoolVar(&opts.Apply, "apply", false, "Apply SQL to database. If false, run as dry-run")
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
opts.FilePath = strings.TrimSpace(opts.FilePath)
|
|
||||||
if opts.FilePath == "" {
|
|
||||||
log.Fatal("--file is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
sqlContent, err := readSQLFile(opts.FilePath)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("failed reading sql file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
mode := "dry-run"
|
|
||||||
if opts.Apply {
|
|
||||||
mode = "apply"
|
|
||||||
}
|
|
||||||
fmt.Printf("Mode: %s\n", mode)
|
|
||||||
fmt.Printf("File: %s\n", opts.FilePath)
|
|
||||||
fmt.Printf("SQL bytes: %d\n", len(sqlContent))
|
|
||||||
|
|
||||||
if !opts.Apply {
|
|
||||||
fmt.Println("Dry-run only. Add --apply to execute the SQL file.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
db := database.Connect(config.DBHost, config.DBName)
|
|
||||||
if err := executeSQL(db, sqlContent); err != nil {
|
|
||||||
log.Fatalf("failed executing sql file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("DONE: SQL executed successfully")
|
|
||||||
}
|
|
||||||
|
|
||||||
func readSQLFile(path string) (string, error) {
|
|
||||||
raw, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
sql := strings.TrimSpace(strings.TrimPrefix(string(raw), "\ufeff"))
|
|
||||||
if sql == "" {
|
|
||||||
return "", fmt.Errorf("sql file is empty")
|
|
||||||
}
|
|
||||||
|
|
||||||
return sql, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func executeSQL(db *gorm.DB, sql string) error {
|
|
||||||
return db.Transaction(func(tx *gorm.DB) error {
|
|
||||||
return tx.Exec(sql).Error
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
# Farm Depreciation Manual Inputs Import
|
|
||||||
|
|
||||||
Command ini dipakai untuk bulk import data ke tabel `farm_depreciation_manual_inputs` dari file Excel (`.xlsx`).
|
|
||||||
|
|
||||||
## Command
|
|
||||||
|
|
||||||
```bash
|
|
||||||
go run ./cmd/import-farm-depreciation-manual-inputs --file <path.xlsx> [--sheet <name>] [--apply]
|
|
||||||
```
|
|
||||||
|
|
||||||
## Flags
|
|
||||||
|
|
||||||
- `--file` (required): path file `.xlsx`.
|
|
||||||
- `--sheet` (optional): nama sheet. Jika tidak diisi, command pakai sheet pertama.
|
|
||||||
- `--apply` (optional): default `false` (dry-run). Jika `true`, command menulis ke database.
|
|
||||||
|
|
||||||
## Mode
|
|
||||||
|
|
||||||
- Dry-run (default):
|
|
||||||
- parsing dan validasi semua baris.
|
|
||||||
- validasi `project_flock_id` terhadap farm aktif kategori `LAYING`.
|
|
||||||
- menampilkan `PLAN` + daftar error.
|
|
||||||
- tidak menulis data.
|
|
||||||
|
|
||||||
- Apply (`--apply`):
|
|
||||||
- semua validasi tetap dijalankan dulu.
|
|
||||||
- jika ada 1 error, proses dihentikan.
|
|
||||||
- jika valid, upsert dijalankan dalam 1 transaksi (fail-fast).
|
|
||||||
- setelah upsert, snapshot di `farm_depreciation_snapshots` dihapus mulai `cutover_date` untuk `project_flock_id` terkait.
|
|
||||||
|
|
||||||
## Format Excel
|
|
||||||
|
|
||||||
Template tersedia di:
|
|
||||||
|
|
||||||
- `docs/templates/farm_depreciation_manual_inputs.xlsx`
|
|
||||||
|
|
||||||
Header wajib ada di baris 1 (case-insensitive, trim-spaces):
|
|
||||||
|
|
||||||
- `project_flock_id` (required, integer > 0)
|
|
||||||
- `total_cost` (required, numeric >= 0)
|
|
||||||
- `cutover_date` (required, format `YYYY-MM-DD`)
|
|
||||||
- `note` (optional, max 1000 karakter)
|
|
||||||
|
|
||||||
Catatan:
|
|
||||||
|
|
||||||
- Dalam 1 file tidak boleh ada duplikat `project_flock_id`.
|
|
||||||
- `project_flock_id` harus mengarah ke `project_flocks` yang `deleted_at IS NULL` dan `category = LAYING`.
|
|
||||||
|
|
||||||
## Contoh
|
|
||||||
|
|
||||||
Dry-run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
env DB_HOST=127.0.0.1 DB_PORT=5432 DB_NAME=lti DB_USER=postgres DB_PASSWORD=postgres \
|
|
||||||
go run ./cmd/import-farm-depreciation-manual-inputs \
|
|
||||||
--file docs/templates/farm_depreciation_manual_inputs.xlsx
|
|
||||||
```
|
|
||||||
|
|
||||||
Apply:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
env DB_HOST=127.0.0.1 DB_PORT=5432 DB_NAME=lti DB_USER=postgres DB_PASSWORD=postgres \
|
|
||||||
go run ./cmd/import-farm-depreciation-manual-inputs \
|
|
||||||
--file /path/to/farm_depreciation_manual_inputs.xlsx \
|
|
||||||
--sheet manual_inputs \
|
|
||||||
--apply
|
|
||||||
```
|
|
||||||
|
|
||||||
## Error Umum
|
|
||||||
|
|
||||||
- `required header is missing`: header wajib tidak ditemukan.
|
|
||||||
- `must be a positive integer`: `project_flock_id` bukan integer valid.
|
|
||||||
- `must be greater than or equal to 0`: `total_cost` negatif.
|
|
||||||
- `must follow format YYYY-MM-DD`: `cutover_date` tidak sesuai format.
|
|
||||||
- `duplicate value ...`: `project_flock_id` duplikat dalam file yang sama.
|
|
||||||
- `must reference an active LAYING project_flock`: farm tidak valid untuk import ini.
|
|
||||||
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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
@@ -1,93 +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.expense.depreciation.manage",
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@ package repository
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
||||||
@@ -24,7 +23,6 @@ type HppCostRepository interface {
|
|||||||
GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, startDate *time.Time, endDate *time.Time) (float64, float64, error)
|
GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(ctx context.Context, projectFlockKandangIDs []uint, startDate *time.Time, endDate *time.Time) (float64, float64, error)
|
||||||
GetProjectFlockIDByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (uint, error)
|
GetProjectFlockIDByProjectFlockKandangID(ctx context.Context, projectFlockKandangId uint) (uint, error)
|
||||||
GetTransferSourceSummary(ctx context.Context, projectFlockKandangId uint) (uint, float64, error)
|
GetTransferSourceSummary(ctx context.Context, projectFlockKandangId uint) (uint, float64, error)
|
||||||
GetManualDepreciationCostByProjectFlockID(ctx context.Context, projectFlockId uint) (float64, error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type HppRepositoryImpl struct {
|
type HppRepositoryImpl struct {
|
||||||
@@ -50,32 +48,12 @@ func (r *HppRepositoryImpl) GetProjectFlockKandangIDs(ctx context.Context, proje
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *HppRepositoryImpl) GetDocCost(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) {
|
func (r *HppRepositoryImpl) GetDocCost(ctx context.Context, projectFlockKandangIDs []uint) (float64, error) {
|
||||||
stockablePurchase := fifo.StockableKeyPurchaseItems.String()
|
|
||||||
stockableAdjustment := fifo.StockableKeyAdjustmentIn.String()
|
|
||||||
usableProjectChickin := fifo.UsableKeyProjectChickin.String()
|
|
||||||
|
|
||||||
var total float64
|
var total float64
|
||||||
err := r.db.WithContext(ctx).
|
err := r.db.WithContext(ctx).
|
||||||
Table("project_chickins AS pc").
|
Table("project_chickins AS pc").
|
||||||
Select(`
|
Select("COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0)").
|
||||||
COALESCE(SUM(sa.qty * CASE
|
Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?", fifo.UsableKeyProjectChickin.String(), fifo.StockableKeyPurchaseItems.String(), entity.StockAllocationStatusActive, entity.StockAllocationPurposeTraceChickin).
|
||||||
WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0)
|
Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id").
|
||||||
WHEN sa.stockable_type = ? THEN COALESCE(ast.price, 0)
|
|
||||||
ELSE 0
|
|
||||||
END), 0)`,
|
|
||||||
stockablePurchase,
|
|
||||||
stockableAdjustment,
|
|
||||||
).
|
|
||||||
Joins(
|
|
||||||
"JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id AND (sa.stockable_type = ? OR sa.stockable_type = ?) AND sa.status = ? AND sa.allocation_purpose = ?",
|
|
||||||
usableProjectChickin,
|
|
||||||
stockablePurchase,
|
|
||||||
stockableAdjustment,
|
|
||||||
entity.StockAllocationStatusActive,
|
|
||||||
entity.StockAllocationPurposeTraceChickin,
|
|
||||||
).
|
|
||||||
Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase).
|
|
||||||
Joins("LEFT JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", stockableAdjustment).
|
|
||||||
Where("pc.project_flock_kandang_id IN (?)", projectFlockKandangIDs).
|
Where("pc.project_flock_kandang_id IN (?)", projectFlockKandangIDs).
|
||||||
Scan(&total).Error
|
Scan(&total).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -107,7 +85,7 @@ func (r *HppRepositoryImpl) GetExpedisionCost(ctx context.Context, projectFlockK
|
|||||||
Joins("JOIN expense_realizations AS er ON er.expense_nonstock_id = en.id").
|
Joins("JOIN expense_realizations AS er ON er.expense_nonstock_id = en.id").
|
||||||
Joins("JOIN flags AS f ON f.flagable_id = en.nonstock_id AND f.flagable_type = ?", entity.FlagableTypeNonstock).
|
Joins("JOIN flags AS f ON f.flagable_id = en.nonstock_id AND f.flagable_type = ?", entity.FlagableTypeNonstock).
|
||||||
Where("en.project_flock_kandang_id IN (?)", projectFlockKandangIDs).
|
Where("en.project_flock_kandang_id IN (?)", projectFlockKandangIDs).
|
||||||
// Where("f.name = ?", utils.FlagEkspedisi).
|
Where("f.name = ?", utils.FlagEkspedisi).
|
||||||
Scan(&total).Error
|
Scan(&total).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
@@ -122,35 +100,15 @@ func (r *HppRepositoryImpl) GetFeedUsageCost(ctx context.Context, projectFlockKa
|
|||||||
date = &now
|
date = &now
|
||||||
}
|
}
|
||||||
|
|
||||||
stockablePurchase := fifo.StockableKeyPurchaseItems.String()
|
|
||||||
stockableAdjustment := fifo.StockableKeyAdjustmentIn.String()
|
|
||||||
usableRecordingStock := fifo.UsableKeyRecordingStock.String()
|
|
||||||
|
|
||||||
var total float64
|
var total float64
|
||||||
err := r.db.WithContext(ctx).
|
err := r.db.WithContext(ctx).
|
||||||
Table("recordings AS r").
|
Table("recordings AS r").
|
||||||
Select(`
|
Select("COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0)").
|
||||||
COALESCE(SUM(sa.qty * CASE
|
|
||||||
WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0)
|
|
||||||
WHEN sa.stockable_type = ? THEN COALESCE(ast.price, 0)
|
|
||||||
ELSE 0
|
|
||||||
END), 0)`,
|
|
||||||
stockablePurchase,
|
|
||||||
stockableAdjustment,
|
|
||||||
).
|
|
||||||
Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id").
|
Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id").
|
||||||
Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id").
|
Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id").
|
||||||
Joins("JOIN flags AS f ON f.flagable_id = pw.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct).
|
Joins("JOIN flags AS f ON f.flagable_id = pw.product_id AND f.flagable_type = ?", entity.FlagableTypeProduct).
|
||||||
Joins(
|
Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String(), entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume).
|
||||||
"JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND (sa.stockable_type = ? OR sa.stockable_type = ?) AND sa.status = ? AND sa.allocation_purpose = ?",
|
Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id").
|
||||||
usableRecordingStock,
|
|
||||||
stockablePurchase,
|
|
||||||
stockableAdjustment,
|
|
||||||
entity.StockAllocationStatusActive,
|
|
||||||
entity.StockAllocationPurposeConsume,
|
|
||||||
).
|
|
||||||
Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase).
|
|
||||||
Joins("LEFT JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", stockableAdjustment).
|
|
||||||
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
|
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
|
||||||
Where("r.record_datetime <= ?", *date).
|
Where("r.record_datetime <= ?", *date).
|
||||||
Where("f.name = ?", utils.FlagPakan).
|
Where("f.name = ?", utils.FlagPakan).
|
||||||
@@ -174,34 +132,15 @@ func (r *HppRepositoryImpl) GetOvkUsageCost(ctx context.Context, projectFlockKan
|
|||||||
utils.FlagVitamin,
|
utils.FlagVitamin,
|
||||||
utils.FlagKimia,
|
utils.FlagKimia,
|
||||||
}
|
}
|
||||||
stockablePurchase := fifo.StockableKeyPurchaseItems.String()
|
|
||||||
stockableAdjustment := fifo.StockableKeyAdjustmentIn.String()
|
|
||||||
usableRecordingStock := fifo.UsableKeyRecordingStock.String()
|
|
||||||
|
|
||||||
var total float64
|
var total float64
|
||||||
err := r.db.WithContext(ctx).
|
err := r.db.WithContext(ctx).
|
||||||
Table("recordings AS r").
|
Table("recordings AS r").
|
||||||
Select(`
|
Select("COALESCE(SUM(sa.qty * COALESCE(pi.price, 0)), 0)").
|
||||||
COALESCE(SUM(sa.qty * CASE
|
|
||||||
WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0)
|
|
||||||
WHEN sa.stockable_type = ? THEN COALESCE(ast.price, 0)
|
|
||||||
ELSE 0
|
|
||||||
END), 0)`,
|
|
||||||
stockablePurchase,
|
|
||||||
stockableAdjustment,
|
|
||||||
).
|
|
||||||
Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id").
|
Joins("JOIN recording_stocks AS rs ON rs.recording_id = r.id").
|
||||||
Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id").
|
Joins("JOIN product_warehouses AS pw ON pw.id = rs.product_warehouse_id").
|
||||||
Joins(
|
Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?", fifo.UsableKeyRecordingStock.String(), fifo.StockableKeyPurchaseItems.String(), entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume).
|
||||||
"JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = rs.id AND (sa.stockable_type = ? OR sa.stockable_type = ?) AND sa.status = ? AND sa.allocation_purpose = ?",
|
Joins("JOIN purchase_items AS pi ON pi.id = sa.stockable_id").
|
||||||
usableRecordingStock,
|
|
||||||
stockablePurchase,
|
|
||||||
stockableAdjustment,
|
|
||||||
entity.StockAllocationStatusActive,
|
|
||||||
entity.StockAllocationPurposeConsume,
|
|
||||||
).
|
|
||||||
Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase).
|
|
||||||
Joins("LEFT JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", stockableAdjustment).
|
|
||||||
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
|
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
|
||||||
Where("r.record_datetime <= ?", *date).
|
Where("r.record_datetime <= ?", *date).
|
||||||
Where("EXISTS (SELECT 1 FROM flags f WHERE f.flagable_id = pw.product_id AND f.flagable_type = ? AND f.name IN ?)", entity.FlagableTypeProduct, flags).
|
Where("EXISTS (SELECT 1 FROM flags f WHERE f.flagable_id = pw.product_id AND f.flagable_type = ? AND f.name IN ?)", entity.FlagableTypeProduct, flags).
|
||||||
@@ -230,7 +169,6 @@ func (r *HppRepositoryImpl) GetTotalPopulation(ctx context.Context, projectFlock
|
|||||||
func (r *HppRepositoryImpl) GetPulletCost(ctx context.Context, projectFlockKandangId uint) (float64, error) {
|
func (r *HppRepositoryImpl) GetPulletCost(ctx context.Context, projectFlockKandangId uint) (float64, error) {
|
||||||
stockablePurchase := fifo.StockableKeyPurchaseItems.String()
|
stockablePurchase := fifo.StockableKeyPurchaseItems.String()
|
||||||
stockableTransferIn := fifo.StockableKeyStockTransferIn.String()
|
stockableTransferIn := fifo.StockableKeyStockTransferIn.String()
|
||||||
stockableAdjustment := fifo.StockableKeyAdjustmentIn.String()
|
|
||||||
usableProjectChickin := fifo.UsableKeyProjectChickin.String()
|
usableProjectChickin := fifo.UsableKeyProjectChickin.String()
|
||||||
|
|
||||||
var total float64
|
var total float64
|
||||||
@@ -240,18 +178,13 @@ func (r *HppRepositoryImpl) GetPulletCost(ctx context.Context, projectFlockKanda
|
|||||||
COALESCE(SUM(sa.qty * CASE
|
COALESCE(SUM(sa.qty * CASE
|
||||||
WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0)
|
WHEN sa.stockable_type = ? THEN COALESCE(pi.price, 0)
|
||||||
WHEN sa.stockable_type = ? THEN COALESCE(tpi.price, 0)
|
WHEN sa.stockable_type = ? THEN COALESCE(tpi.price, 0)
|
||||||
WHEN sa.stockable_type = ? THEN COALESCE(ast.price, 0)
|
|
||||||
ELSE 0
|
ELSE 0
|
||||||
END), 0)`,
|
END), 0)`,
|
||||||
stockablePurchase,
|
stockablePurchase, stockableTransferIn).
|
||||||
stockableTransferIn,
|
|
||||||
stockableAdjustment,
|
|
||||||
).
|
|
||||||
Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id AND sa.status = ? AND sa.allocation_purpose = ?", usableProjectChickin, entity.StockAllocationStatusActive, entity.StockAllocationPurposeTraceChickin).
|
Joins("JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = pc.id AND sa.status = ? AND sa.allocation_purpose = ?", usableProjectChickin, entity.StockAllocationStatusActive, entity.StockAllocationPurposeTraceChickin).
|
||||||
Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase).
|
Joins("LEFT JOIN purchase_items AS pi ON pi.id = sa.stockable_id AND sa.stockable_type = ?", stockablePurchase).
|
||||||
Joins("LEFT JOIN stock_allocations AS tsa ON tsa.usable_type = ? AND tsa.usable_id = sa.stockable_id AND sa.stockable_type = ? AND tsa.stockable_type = ? AND tsa.status = ? AND tsa.allocation_purpose = ?", stockableTransferIn, stockableTransferIn, stockablePurchase, entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume).
|
Joins("LEFT JOIN stock_allocations AS tsa ON tsa.usable_type = ? AND tsa.usable_id = sa.stockable_id AND sa.stockable_type = ? AND tsa.stockable_type = ? AND tsa.status = ? AND tsa.allocation_purpose = ?", stockableTransferIn, stockableTransferIn, stockablePurchase, entity.StockAllocationStatusActive, entity.StockAllocationPurposeConsume).
|
||||||
Joins("LEFT JOIN purchase_items AS tpi ON tpi.id = tsa.stockable_id").
|
Joins("LEFT JOIN purchase_items AS tpi ON tpi.id = tsa.stockable_id").
|
||||||
Joins("LEFT JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND sa.stockable_type = ?", stockableAdjustment).
|
|
||||||
Where("pc.project_flock_kandang_id = ?", projectFlockKandangId).
|
Where("pc.project_flock_kandang_id = ?", projectFlockKandangId).
|
||||||
Scan(&total).Error
|
Scan(&total).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -282,33 +215,6 @@ func (r *HppRepositoryImpl) GetEggProduksiPiecesAndWeightKgByProjectFlockKandang
|
|||||||
return 0, 0, err
|
return 0, 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var adjustmentTotalWeight float64
|
|
||||||
adjustmentSubQuery := r.db.WithContext(ctx).
|
|
||||||
Table("recordings AS r").
|
|
||||||
Select("DISTINCT ast.id AS adjustment_id, ast.price AS price").
|
|
||||||
Joins("JOIN recording_eggs AS re ON re.recording_id = r.id").
|
|
||||||
Joins("JOIN stock_transfer_details AS std ON std.dest_product_warehouse_id = re.product_warehouse_id").
|
|
||||||
Joins(
|
|
||||||
"JOIN stock_allocations AS sa ON sa.usable_type = ? AND sa.usable_id = std.id AND sa.stockable_type = ? AND sa.status = ? AND sa.allocation_purpose = ?",
|
|
||||||
fifo.UsableKeyStockTransferOut.String(),
|
|
||||||
fifo.StockableKeyAdjustmentIn.String(),
|
|
||||||
entity.StockAllocationStatusActive,
|
|
||||||
entity.StockAllocationPurposeConsume,
|
|
||||||
).
|
|
||||||
Joins("JOIN adjustment_stocks AS ast ON ast.id = sa.stockable_id AND ast.product_warehouse_id = std.source_product_warehouse_id").
|
|
||||||
Where("r.project_flock_kandangs_id IN (?)", projectFlockKandangIDs).
|
|
||||||
Where("r.record_datetime <= ?", *date)
|
|
||||||
|
|
||||||
err = r.db.WithContext(ctx).
|
|
||||||
Table("(?) AS adjustment_sources", adjustmentSubQuery).
|
|
||||||
Select("COALESCE(SUM(adjustment_sources.price), 0)").
|
|
||||||
Scan(&adjustmentTotalWeight).Error
|
|
||||||
if err != nil {
|
|
||||||
return 0, 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
totals.TotalWeightKg += adjustmentTotalWeight
|
|
||||||
|
|
||||||
return totals.TotalPieces, totals.TotalWeightKg, nil
|
return totals.TotalPieces, totals.TotalWeightKg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,25 +311,3 @@ func (r *HppRepositoryImpl) GetTransferSourceSummary(ctx context.Context, projec
|
|||||||
|
|
||||||
return summary.ProjectFlockID, summary.TotalQty, nil
|
return summary.ProjectFlockID, summary.TotalQty, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *HppRepositoryImpl) GetManualDepreciationCostByProjectFlockID(ctx context.Context, projectFlockId uint) (float64, error) {
|
|
||||||
type row struct {
|
|
||||||
TotalCost float64
|
|
||||||
}
|
|
||||||
|
|
||||||
var selected row
|
|
||||||
err := r.db.WithContext(ctx).
|
|
||||||
Table("farm_depreciation_manual_inputs").
|
|
||||||
Select("total_cost").
|
|
||||||
Where("project_flock_id = ?", projectFlockId).
|
|
||||||
Limit(1).
|
|
||||||
Take(&selected).Error
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return selected.TotalCost, nil
|
|
||||||
}
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,248 +0,0 @@
|
|||||||
package repository
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"math"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/glebarez/sqlite"
|
|
||||||
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
|
||||||
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
|
||||||
"gitlab.com/mbugroup/lti-api.git/internal/utils/fifo"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestHppV2RepositoryGetEggProduksiIncludesTransferredAdjustmentStock(t *testing.T) {
|
|
||||||
db := setupHppV2RepositoryTestDB(t)
|
|
||||||
|
|
||||||
mustExecHppV2(t, db,
|
|
||||||
`INSERT INTO recordings (id, project_flock_kandangs_id, record_datetime) VALUES (1, 101, '2026-04-19 10:00:00')`,
|
|
||||||
`INSERT INTO recording_eggs (id, recording_id, product_warehouse_id, qty, weight, project_flock_kandang_id) VALUES (1, 1, 401, 80, 8, 101)`,
|
|
||||||
`INSERT INTO stock_transfers (id, transfer_date) VALUES (1, '2026-04-18 08:00:00')`,
|
|
||||||
`INSERT INTO stock_transfer_details (id, stock_transfer_id, source_product_warehouse_id, dest_product_warehouse_id) VALUES (1, 1, 301, 401)`,
|
|
||||||
`INSERT INTO stock_allocations (id, usable_type, usable_id, stockable_type, stockable_id, status, allocation_purpose) VALUES (1, 'STOCK_TRANSFER_OUT', 1, 'ADJUSTMENT_IN', 501, 'ACTIVE', 'CONSUME')`,
|
|
||||||
`INSERT INTO adjustment_stocks (id, product_warehouse_id, total_qty, price, created_at) VALUES (501, 301, 20, 2.5, '2026-04-18 07:30:00')`,
|
|
||||||
)
|
|
||||||
|
|
||||||
repo := &HppV2RepositoryImpl{db: db}
|
|
||||||
endDate := mustJakartaTime(t, "2026-04-20 00:00:00")
|
|
||||||
|
|
||||||
totalPieces, totalWeightKg, err := repo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{101}, &endDate)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertFloatEquals(t, totalPieces, 100)
|
|
||||||
assertFloatEquals(t, totalWeightKg, 10.5)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHppV2RepositoryGetEggTerjualUsesEndDateForSameDayFarmSales(t *testing.T) {
|
|
||||||
db := setupHppV2RepositoryTestDB(t)
|
|
||||||
|
|
||||||
mustExecHppV2(t, db,
|
|
||||||
`INSERT INTO kandangs (id, location_id) VALUES (1, 10), (2, 10)`,
|
|
||||||
`INSERT INTO project_flock_kandangs (id, kandang_id) VALUES (101, 1), (102, 2)`,
|
|
||||||
`INSERT INTO warehouses (id, type, location_id) VALUES (201, 'LOKASI', 10)`,
|
|
||||||
`INSERT INTO product_warehouses (id, warehouse_id, product_id, project_flock_kandang_id) VALUES (301, 201, 900, NULL)`,
|
|
||||||
`INSERT INTO flags (id, flagable_type, flagable_id, name) VALUES (1, 'products', 900, 'TELUR')`,
|
|
||||||
`INSERT INTO recordings (id, project_flock_kandangs_id, record_datetime, deleted_at) VALUES (1, 101, '2026-04-19 08:00:00', NULL), (2, 102, '2026-04-19 09:00:00', NULL)`,
|
|
||||||
`INSERT INTO recording_eggs (id, recording_id, product_warehouse_id, qty, weight, project_flock_kandang_id) VALUES (1, 1, 301, 60, 6, 101), (2, 2, 301, 40, 4, 102)`,
|
|
||||||
`INSERT INTO marketing_products (id, product_warehouse_id) VALUES (401, 301)`,
|
|
||||||
`INSERT INTO marketing_delivery_products (id, marketing_product_id, usage_qty, total_weight, delivery_date) VALUES (501, 401, 50, 5, '2026-04-19 12:00:00')`,
|
|
||||||
)
|
|
||||||
|
|
||||||
repo := &HppV2RepositoryImpl{db: db}
|
|
||||||
startDate := mustJakartaTime(t, "2026-04-19 00:00:00")
|
|
||||||
endDate := mustJakartaTime(t, "2026-04-20 00:00:00")
|
|
||||||
|
|
||||||
totalPieces, totalWeightKg, err := repo.GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{101}, &startDate, &endDate)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertFloatEquals(t, totalPieces, 30)
|
|
||||||
assertFloatEquals(t, totalWeightKg, 3)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHppV2RepositoryGetEggTerjualProratesHistoricalFarmSalesFromAdjustments(t *testing.T) {
|
|
||||||
db := setupHppV2RepositoryTestDB(t)
|
|
||||||
|
|
||||||
mustExecHppV2(t, db,
|
|
||||||
`INSERT INTO kandangs (id, location_id) VALUES (1, 10), (2, 10)`,
|
|
||||||
`INSERT INTO project_flock_kandangs (id, kandang_id) VALUES (101, 1), (102, 2)`,
|
|
||||||
`INSERT INTO warehouses (id, type, location_id) VALUES (201, 'LOKASI', 10), (211, 'KANDANG', 10), (212, 'KANDANG', 10)`,
|
|
||||||
`INSERT INTO product_warehouses (id, warehouse_id, product_id, project_flock_kandang_id) VALUES (301, 201, 900, NULL), (311, 211, 900, 101), (312, 212, 900, 102)`,
|
|
||||||
`INSERT INTO flags (id, flagable_type, flagable_id, name) VALUES (1, 'products', 900, 'TELUR')`,
|
|
||||||
`INSERT INTO stock_transfers (id, transfer_date) VALUES (1, '2026-04-18 08:00:00'), (2, '2026-04-18 08:15:00')`,
|
|
||||||
`INSERT INTO stock_transfer_details (id, stock_transfer_id, source_product_warehouse_id, dest_product_warehouse_id) VALUES (1, 1, 311, 301), (2, 2, 312, 301)`,
|
|
||||||
`INSERT INTO adjustment_stocks (id, product_warehouse_id, usage_qty, price, function_code, transaction_type, created_at) VALUES
|
|
||||||
(801, 311, 70, 7, 'RECORDING_EGG_IN', 'RECORDING', '2026-04-18 07:00:00'),
|
|
||||||
(802, 312, 30, 3, 'RECORDING_EGG_IN', 'RECORDING', '2026-04-18 07:30:00')`,
|
|
||||||
`INSERT INTO marketing_products (id, product_warehouse_id) VALUES (401, 301)`,
|
|
||||||
`INSERT INTO marketing_delivery_products (id, marketing_product_id, usage_qty, total_weight, delivery_date) VALUES (501, 401, 20, 2, '2026-04-19 12:00:00')`,
|
|
||||||
)
|
|
||||||
|
|
||||||
repo := &HppV2RepositoryImpl{db: db}
|
|
||||||
startDate := mustJakartaTime(t, "2026-04-19 00:00:00")
|
|
||||||
endDate := mustJakartaTime(t, "2026-04-20 00:00:00")
|
|
||||||
|
|
||||||
totalPieces, totalWeightKg, err := repo.GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{101}, &startDate, &endDate)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertFloatEquals(t, totalPieces, 14)
|
|
||||||
assertFloatEquals(t, totalWeightKg, 1.4)
|
|
||||||
}
|
|
||||||
|
|
||||||
func setupHppV2RepositoryTestDB(t *testing.T) *gorm.DB {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed opening sqlite db: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
mustExecHppV2(t, db,
|
|
||||||
`CREATE TABLE recordings (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
project_flock_kandangs_id INTEGER NULL,
|
|
||||||
record_datetime DATETIME NULL,
|
|
||||||
deleted_at DATETIME NULL
|
|
||||||
)`,
|
|
||||||
`CREATE TABLE recording_eggs (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
recording_id INTEGER NULL,
|
|
||||||
product_warehouse_id INTEGER NULL,
|
|
||||||
qty NUMERIC(15,3) NULL,
|
|
||||||
weight NUMERIC(15,3) NULL,
|
|
||||||
project_flock_kandang_id INTEGER NULL
|
|
||||||
)`,
|
|
||||||
`CREATE TABLE stock_transfers (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
transfer_date DATETIME NULL
|
|
||||||
)`,
|
|
||||||
`CREATE TABLE stock_transfer_details (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
stock_transfer_id INTEGER NULL,
|
|
||||||
source_product_warehouse_id INTEGER NULL,
|
|
||||||
dest_product_warehouse_id INTEGER NULL
|
|
||||||
)`,
|
|
||||||
`CREATE TABLE stock_allocations (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
usable_type TEXT NULL,
|
|
||||||
usable_id INTEGER NULL,
|
|
||||||
stockable_type TEXT NULL,
|
|
||||||
stockable_id INTEGER NULL,
|
|
||||||
status TEXT NULL,
|
|
||||||
allocation_purpose TEXT NULL,
|
|
||||||
qty NUMERIC(15,3) NULL
|
|
||||||
)`,
|
|
||||||
`CREATE TABLE adjustment_stocks (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
product_warehouse_id INTEGER NULL,
|
|
||||||
total_qty NUMERIC(15,3) NULL,
|
|
||||||
usage_qty NUMERIC(15,3) NULL,
|
|
||||||
price NUMERIC(15,3) NULL,
|
|
||||||
grand_total NUMERIC(15,3) NULL,
|
|
||||||
function_code TEXT NULL,
|
|
||||||
transaction_type TEXT NULL,
|
|
||||||
created_at DATETIME NULL
|
|
||||||
)`,
|
|
||||||
`CREATE TABLE kandangs (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
location_id INTEGER NULL
|
|
||||||
)`,
|
|
||||||
`CREATE TABLE project_flock_kandangs (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
kandang_id INTEGER NULL,
|
|
||||||
project_flock_id INTEGER NULL
|
|
||||||
)`,
|
|
||||||
`CREATE TABLE warehouses (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
type TEXT NULL,
|
|
||||||
location_id INTEGER NULL
|
|
||||||
)`,
|
|
||||||
`CREATE TABLE product_warehouses (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
warehouse_id INTEGER NULL,
|
|
||||||
product_id INTEGER NULL,
|
|
||||||
project_flock_kandang_id INTEGER NULL
|
|
||||||
)`,
|
|
||||||
`CREATE TABLE marketing_products (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
product_warehouse_id INTEGER NULL
|
|
||||||
)`,
|
|
||||||
`CREATE TABLE marketing_delivery_products (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
marketing_product_id INTEGER NULL,
|
|
||||||
usage_qty NUMERIC(15,3) NULL,
|
|
||||||
total_weight NUMERIC(15,3) NULL,
|
|
||||||
delivery_date DATETIME NULL
|
|
||||||
)`,
|
|
||||||
`CREATE TABLE flags (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
flagable_type TEXT NULL,
|
|
||||||
flagable_id INTEGER NULL,
|
|
||||||
name TEXT NULL
|
|
||||||
)`,
|
|
||||||
)
|
|
||||||
|
|
||||||
return db
|
|
||||||
}
|
|
||||||
|
|
||||||
func mustExecHppV2(t *testing.T, db *gorm.DB, statements ...string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
for _, statement := range statements {
|
|
||||||
if err := db.Exec(statement).Error; err != nil {
|
|
||||||
t.Fatalf("failed executing statement %q: %v", statement, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func mustJakartaTime(t *testing.T, raw string) time.Time {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
location, err := time.LoadLocation("Asia/Jakarta")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed loading timezone: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
value, err := time.ParseInLocation("2006-01-02 15:04:05", raw, location)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed parsing time %q: %v", raw, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
func assertFloatEquals(t *testing.T, got float64, want float64) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
if math.Abs(got-want) > 0.000001 {
|
|
||||||
t.Fatalf("expected %.6f, got %.6f", want, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHppV2RepositoryConstantsStayAlignedWithProductionQueries(t *testing.T) {
|
|
||||||
if fifo.UsableKeyStockTransferOut.String() != "STOCK_TRANSFER_OUT" {
|
|
||||||
t.Fatalf("unexpected stock transfer usable key: %s", fifo.UsableKeyStockTransferOut.String())
|
|
||||||
}
|
|
||||||
if fifo.StockableKeyAdjustmentIn.String() != "ADJUSTMENT_IN" {
|
|
||||||
t.Fatalf("unexpected adjustment stockable key: %s", fifo.StockableKeyAdjustmentIn.String())
|
|
||||||
}
|
|
||||||
if entity.StockAllocationStatusActive != "ACTIVE" {
|
|
||||||
t.Fatalf("unexpected active stock allocation status: %s", entity.StockAllocationStatusActive)
|
|
||||||
}
|
|
||||||
if entity.StockAllocationPurposeConsume != "CONSUME" {
|
|
||||||
t.Fatalf("unexpected consume stock allocation purpose: %s", entity.StockAllocationPurposeConsume)
|
|
||||||
}
|
|
||||||
if string(utils.AdjustmentTransactionSubtypeRecordingEggIn) != "RECORDING_EGG_IN" {
|
|
||||||
t.Fatalf("unexpected adjustment function code: %s", utils.AdjustmentTransactionSubtypeRecordingEggIn)
|
|
||||||
}
|
|
||||||
if string(utils.AdjustmentTransactionTypeRecording) != "RECORDING" {
|
|
||||||
t.Fatalf("unexpected adjustment transaction type: %s", utils.AdjustmentTransactionTypeRecording)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
depreciationStartAgeDayCloseHouse = 155
|
|
||||||
depreciationStartAgeDayOpenHouse = 176
|
|
||||||
)
|
|
||||||
|
|
||||||
func NormalizeDepreciationHouseType(raw string) string {
|
|
||||||
return strings.TrimSpace(strings.ToLower(raw))
|
|
||||||
}
|
|
||||||
|
|
||||||
func DepreciationStartAgeDay(houseType string) int {
|
|
||||||
switch NormalizeDepreciationHouseType(houseType) {
|
|
||||||
case "close_house":
|
|
||||||
return depreciationStartAgeDayCloseHouse
|
|
||||||
case "open_house":
|
|
||||||
return depreciationStartAgeDayOpenHouse
|
|
||||||
default:
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func FlockAgeDay(originDate time.Time, periodDate time.Time) int {
|
|
||||||
origin := time.Date(originDate.Year(), originDate.Month(), originDate.Day(), 0, 0, 0, 0, originDate.Location())
|
|
||||||
period := time.Date(periodDate.Year(), periodDate.Month(), periodDate.Day(), 0, 0, 0, 0, periodDate.Location())
|
|
||||||
if period.Before(origin) {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return int(period.Sub(origin).Hours()/24) + 1
|
|
||||||
}
|
|
||||||
|
|
||||||
func DepreciationScheduleDay(originDate time.Time, periodDate time.Time, houseType string) int {
|
|
||||||
ageDay := FlockAgeDay(originDate, periodDate)
|
|
||||||
startAgeDay := DepreciationStartAgeDay(houseType)
|
|
||||||
if ageDay <= 0 || startAgeDay <= 0 || ageDay < startAgeDay {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return ageDay - startAgeDay + 1
|
|
||||||
}
|
|
||||||
|
|
||||||
func CalculateDepreciationAtDayN(
|
|
||||||
initialPulletCost float64,
|
|
||||||
dayN int,
|
|
||||||
houseType string,
|
|
||||||
percentByHouseType map[string]map[int]float64,
|
|
||||||
) (float64, float64, float64) {
|
|
||||||
return CalculateDepreciationFromDayRange(initialPulletCost, 1, dayN, houseType, percentByHouseType)
|
|
||||||
}
|
|
||||||
|
|
||||||
func CalculateDepreciationFromDayRange(
|
|
||||||
initialPulletCost float64,
|
|
||||||
startDay int,
|
|
||||||
endDay int,
|
|
||||||
houseType string,
|
|
||||||
percentByHouseType map[string]map[int]float64,
|
|
||||||
) (float64, float64, float64) {
|
|
||||||
if initialPulletCost <= 0 || endDay <= 0 {
|
|
||||||
return 0, 0, 0
|
|
||||||
}
|
|
||||||
if startDay <= 0 {
|
|
||||||
startDay = 1
|
|
||||||
}
|
|
||||||
if endDay < startDay {
|
|
||||||
return 0, 0, 0
|
|
||||||
}
|
|
||||||
|
|
||||||
normalizedHouseType := NormalizeDepreciationHouseType(houseType)
|
|
||||||
housePercent, exists := percentByHouseType[normalizedHouseType]
|
|
||||||
if !exists {
|
|
||||||
return 0, 0, 0
|
|
||||||
}
|
|
||||||
|
|
||||||
current := initialPulletCost
|
|
||||||
pulletCostDayN := 0.0
|
|
||||||
depreciationValue := 0.0
|
|
||||||
depreciationPercent := 0.0
|
|
||||||
for day := startDay; day <= endDay; day++ {
|
|
||||||
pct := housePercent[day]
|
|
||||||
dep := current * (pct / 100)
|
|
||||||
if day == endDay {
|
|
||||||
pulletCostDayN = current
|
|
||||||
depreciationValue = dep
|
|
||||||
depreciationPercent = pct
|
|
||||||
}
|
|
||||||
current -= dep
|
|
||||||
if current < 0 {
|
|
||||||
current = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return pulletCostDayN, depreciationValue, depreciationPercent
|
|
||||||
}
|
|
||||||
|
|
||||||
func CalculateEffectiveDepreciationPercent(totalDepreciationValue, totalPulletCostDayN float64) float64 {
|
|
||||||
if totalPulletCostDayN <= 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return (totalDepreciationValue / totalPulletCostDayN) * 100
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestDepreciationScheduleDay_UsesHouseTypeOffsets(t *testing.T) {
|
|
||||||
openOrigin := mustDepreciationDate(t, "2026-01-01")
|
|
||||||
if got := DepreciationScheduleDay(openOrigin, mustDepreciationDate(t, "2026-06-24"), "open_house"); got != 0 {
|
|
||||||
t.Fatalf("expected open house day before start to be 0, got %d", got)
|
|
||||||
}
|
|
||||||
if got := DepreciationScheduleDay(openOrigin, mustDepreciationDate(t, "2026-06-25"), "open_house"); got != 1 {
|
|
||||||
t.Fatalf("expected open house start day to map to schedule day 1, got %d", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
closeOrigin := mustDepreciationDate(t, "2026-01-01")
|
|
||||||
if got := DepreciationScheduleDay(closeOrigin, mustDepreciationDate(t, "2026-06-03"), "close_house"); got != 0 {
|
|
||||||
t.Fatalf("expected close house day before start to be 0, got %d", got)
|
|
||||||
}
|
|
||||||
if got := DepreciationScheduleDay(closeOrigin, mustDepreciationDate(t, "2026-06-04"), "close_house"); got != 1 {
|
|
||||||
t.Fatalf("expected close house start day to map to schedule day 1, got %d", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCalculateDepreciationAtDayN_UsesRemainingBasisRecursively(t *testing.T) {
|
|
||||||
percentByHouseType := map[string]map[int]float64{
|
|
||||||
"close_house": {
|
|
||||||
1: 10,
|
|
||||||
2: 20,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
pulletCostDayN, depreciationValue, depreciationPercent := CalculateDepreciationAtDayN(1000, 2, "close_house", percentByHouseType)
|
|
||||||
if pulletCostDayN != 900 {
|
|
||||||
t.Fatalf("expected remaining basis entering day 2 to be 900, got %v", pulletCostDayN)
|
|
||||||
}
|
|
||||||
if depreciationValue != 180 {
|
|
||||||
t.Fatalf("expected day 2 depreciation to be 180, got %v", depreciationValue)
|
|
||||||
}
|
|
||||||
if depreciationPercent != 20 {
|
|
||||||
t.Fatalf("expected day 2 depreciation percent to be 20, got %v", depreciationPercent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCalculateDepreciationFromDayRange_StartsFromProvidedScheduleDay(t *testing.T) {
|
|
||||||
percentByHouseType := map[string]map[int]float64{
|
|
||||||
"close_house": {
|
|
||||||
1: 10,
|
|
||||||
2: 20,
|
|
||||||
3: 5,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
pulletCostDayN, depreciationValue, depreciationPercent := CalculateDepreciationFromDayRange(1000, 2, 3, "close_house", percentByHouseType)
|
|
||||||
if pulletCostDayN != 800 {
|
|
||||||
t.Fatalf("expected remaining basis entering day 3 to be 800, got %v", pulletCostDayN)
|
|
||||||
}
|
|
||||||
if depreciationValue != 40 {
|
|
||||||
t.Fatalf("expected day 3 depreciation to be 40, got %v", depreciationValue)
|
|
||||||
}
|
|
||||||
if depreciationPercent != 5 {
|
|
||||||
t.Fatalf("expected day 3 depreciation percent to be 5, got %v", depreciationPercent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func mustDepreciationDate(t *testing.T, raw string) time.Time {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
location, err := time.LoadLocation("Asia/Jakarta")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed loading timezone: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
value, err := time.ParseInLocation("2006-01-02", raw, location)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed parsing date %q: %v", raw, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
@@ -46,7 +46,6 @@ func (s *hppService) CalculateHppCost(projectFlockKandangId uint, date *time.Tim
|
|||||||
|
|
||||||
location, err := time.LoadLocation("Asia/Jakarta")
|
location, err := time.LoadLocation("Asia/Jakarta")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,21 +54,16 @@ func (s *hppService) CalculateHppCost(projectFlockKandangId uint, date *time.Tim
|
|||||||
|
|
||||||
depresiasiTransfer, err := s.GetDepresiasiTransfer(projectFlockKandangId, &endOfDay)
|
depresiasiTransfer, err := s.GetDepresiasiTransfer(projectFlockKandangId, &endOfDay)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
totalProductionCost, err := s.GetTotalProductionCost(projectFlockKandangId, &endOfDay, depresiasiTransfer)
|
totalProductionCost, err := s.GetTotalProductionCost(projectFlockKandangId, &endOfDay, depresiasiTransfer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
result, err := s.GetHppEstimationDanRealisasi(totalProductionCost, projectFlockKandangId, &startOfDay, &endOfDay)
|
|
||||||
if err != nil {
|
|
||||||
|
|
||||||
return nil, err
|
return s.GetHppEstimationDanRealisasi(totalProductionCost, projectFlockKandangId, &startOfDay, &endOfDay)
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *hppService) GetTotalDepresiasiFlockGrowing(sourceProjectFlockID uint, date *time.Time) (float64, error) {
|
func (s *hppService) GetTotalDepresiasiFlockGrowing(sourceProjectFlockID uint, date *time.Time) (float64, error) {
|
||||||
@@ -79,48 +73,40 @@ func (s *hppService) GetTotalDepresiasiFlockGrowing(sourceProjectFlockID uint, d
|
|||||||
}
|
}
|
||||||
|
|
||||||
if s.hppRepo == nil {
|
if s.hppRepo == nil {
|
||||||
|
|
||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
kandangIDs, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), sourceProjectFlockID)
|
kandangIDs, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), sourceProjectFlockID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
docCost, err := s.hppRepo.GetDocCost(context.Background(), kandangIDs)
|
docCost, err := s.hppRepo.GetDocCost(context.Background(), kandangIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
budgetCost, err := s.hppRepo.GetBudgetCostByProjectFlockId(context.Background(), sourceProjectFlockID)
|
budgetCost, err := s.hppRepo.GetBudgetCostByProjectFlockId(context.Background(), sourceProjectFlockID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
expedisionCost, err := s.hppRepo.GetExpedisionCost(context.Background(), kandangIDs)
|
expedisionCost, err := s.hppRepo.GetExpedisionCost(context.Background(), kandangIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
feedCost, err := s.hppRepo.GetFeedUsageCost(context.Background(), kandangIDs, date)
|
feedCost, err := s.hppRepo.GetFeedUsageCost(context.Background(), kandangIDs, date)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
ovkCost, err := s.hppRepo.GetOvkUsageCost(context.Background(), kandangIDs, date)
|
ovkCost, err := s.hppRepo.GetOvkUsageCost(context.Background(), kandangIDs, date)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
total := docCost + budgetCost + expedisionCost + feedCost + ovkCost
|
return docCost + budgetCost + expedisionCost + feedCost + ovkCost, nil
|
||||||
return total, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *hppService) GetTotalProductionCost(projectFlockKandangId uint, endDate *time.Time, depresiasiTransfer float64) (float64, error) {
|
func (s *hppService) GetTotalProductionCost(projectFlockKandangId uint, endDate *time.Time, depresiasiTransfer float64) (float64, error) {
|
||||||
@@ -131,40 +117,30 @@ func (s *hppService) GetTotalProductionCost(projectFlockKandangId uint, endDate
|
|||||||
|
|
||||||
costPullet, err := s.hppRepo.GetPulletCost(context.Background(), projectFlockKandangId)
|
costPullet, err := s.hppRepo.GetPulletCost(context.Background(), projectFlockKandangId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
costFeed, err := s.hppRepo.GetFeedUsageCost(context.Background(), []uint{projectFlockKandangId}, endDate)
|
costFeed, err := s.hppRepo.GetFeedUsageCost(context.Background(), []uint{projectFlockKandangId}, endDate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
costOvk, err := s.hppRepo.GetOvkUsageCost(context.Background(), []uint{projectFlockKandangId}, endDate)
|
costOvk, err := s.hppRepo.GetOvkUsageCost(context.Background(), []uint{projectFlockKandangId}, endDate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
costExpedision, err := s.hppRepo.GetExpedisionCost(context.Background(), []uint{projectFlockKandangId})
|
costExpedision, err := s.hppRepo.GetExpedisionCost(context.Background(), []uint{projectFlockKandangId})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
costBudget, err := s.GetBudgetKandangLaying(projectFlockKandangId, endDate)
|
costBudget, err := s.GetBudgetKandangLaying(projectFlockKandangId, endDate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// fmt.Println(costBudget, costExpedision, costOvk, costFeed, costPullet, depresiasiTransfer)
|
return depresiasiTransfer + costPullet + costFeed + costOvk + costExpedision + costBudget, nil
|
||||||
|
|
||||||
// depresiasiTransfer = 0
|
|
||||||
|
|
||||||
total := depresiasiTransfer + costPullet + costFeed + costOvk + costExpedision + costBudget
|
|
||||||
return total, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *hppService) GetBudgetKandangLaying(projectFlockKandangId uint, endDate *time.Time) (float64, error) {
|
func (s *hppService) GetBudgetKandangLaying(projectFlockKandangId uint, endDate *time.Time) (float64, error) {
|
||||||
@@ -174,57 +150,48 @@ func (s *hppService) GetBudgetKandangLaying(projectFlockKandangId uint, endDate
|
|||||||
// }
|
// }
|
||||||
|
|
||||||
if s.hppRepo == nil {
|
if s.hppRepo == nil {
|
||||||
|
|
||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
projectFlockId, err := s.hppRepo.GetProjectFlockIDByProjectFlockKandangID(context.Background(), projectFlockKandangId)
|
projectFlockId, err := s.hppRepo.GetProjectFlockIDByProjectFlockKandangID(context.Background(), projectFlockKandangId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
projectFlockKandangIds, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), projectFlockId)
|
projectFlockKandangIds, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), projectFlockId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
eggProduksiPiecesFlock, _, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), projectFlockKandangIds, endDate)
|
eggProduksiPiecesFlock, _, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), projectFlockKandangIds, endDate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
eggProduksiPiecesKandang, _, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, endDate)
|
eggProduksiPiecesKandang, _, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, endDate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
totalBudgetCost, err := s.hppRepo.GetBudgetCostByProjectFlockId(context.Background(), projectFlockId)
|
totalBudgetCost, err := s.hppRepo.GetBudgetCostByProjectFlockId(context.Background(), projectFlockId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if eggProduksiPiecesFlock == 0 {
|
if eggProduksiPiecesFlock == 0 {
|
||||||
|
|
||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
result := (totalBudgetCost * eggProduksiPiecesKandang) / eggProduksiPiecesFlock
|
return (totalBudgetCost * eggProduksiPiecesKandang) / eggProduksiPiecesFlock, nil
|
||||||
return result, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *hppService) GetDepresiasiTransfer(projectFlockKandangId uint, endDate *time.Time) (float64, error) {
|
func (s *hppService) GetDepresiasiTransfer(projectFlockKandangId uint, endDate *time.Time) (float64, error) {
|
||||||
if endDate == nil {
|
// if endDate == nil {
|
||||||
now := time.Now()
|
// now := time.Now()
|
||||||
endDate = &now
|
// endDate = &now
|
||||||
}
|
// }
|
||||||
|
|
||||||
if s.hppRepo == nil {
|
if s.hppRepo == nil {
|
||||||
|
|
||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,13 +199,6 @@ func (s *hppService) GetDepresiasiTransfer(projectFlockKandangId uint, endDate *
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
if sourceProjectFlockID == 0 || transferTotalQty <= 0 {
|
|
||||||
result, fallbackErr := s.getManualDepresiasiTransferFallback(projectFlockKandangId)
|
|
||||||
if fallbackErr != nil {
|
|
||||||
return 0, fallbackErr
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
kandangIDsGrowing, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), sourceProjectFlockID)
|
kandangIDsGrowing, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), sourceProjectFlockID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -258,81 +218,22 @@ func (s *hppService) GetDepresiasiTransfer(projectFlockKandangId uint, endDate *
|
|||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
result := (totalDepresiasiFlockGrowing * transferTotalQty) / totalPopulationFlockGrowing
|
return (totalDepresiasiFlockGrowing * transferTotalQty) / totalPopulationFlockGrowing, nil
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *hppService) getManualDepresiasiTransferFallback(projectFlockKandangId uint) (float64, error) {
|
|
||||||
projectFlockID, err := s.hppRepo.GetProjectFlockIDByProjectFlockKandangID(context.Background(), projectFlockKandangId)
|
|
||||||
if err != nil {
|
|
||||||
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
if projectFlockID == 0 {
|
|
||||||
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
manualCost, err := s.hppRepo.GetManualDepreciationCostByProjectFlockID(context.Background(), projectFlockID)
|
|
||||||
if err != nil {
|
|
||||||
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
if manualCost <= 0 {
|
|
||||||
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
kandangIDs, err := s.hppRepo.GetProjectFlockKandangIDs(context.Background(), projectFlockID)
|
|
||||||
if err != nil {
|
|
||||||
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
if len(kandangIDs) == 0 {
|
|
||||||
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
totalUsageQty, err := s.hppRepo.GetTotalPopulation(context.Background(), kandangIDs)
|
|
||||||
if err != nil {
|
|
||||||
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
if totalUsageQty <= 0 {
|
|
||||||
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
kandangUsageQty, err := s.hppRepo.GetTotalPopulation(context.Background(), []uint{projectFlockKandangId})
|
|
||||||
if err != nil {
|
|
||||||
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
if kandangUsageQty <= 0 {
|
|
||||||
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
result := manualCost * (kandangUsageQty / totalUsageQty)
|
|
||||||
return result, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *hppService) GetHppEstimationDanRealisasi(totalProductionCost float64, projectFlockKandangId uint, startDate *time.Time, endDate *time.Time) (*HppCostResponse, error) {
|
func (s *hppService) GetHppEstimationDanRealisasi(totalProductionCost float64, projectFlockKandangId uint, startDate *time.Time, endDate *time.Time) (*HppCostResponse, error) {
|
||||||
|
|
||||||
if s.hppRepo == nil {
|
if s.hppRepo == nil {
|
||||||
|
|
||||||
return &HppCostResponse{}, nil
|
return &HppCostResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
estimPieces, estimWeightKg, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, endDate)
|
estimPieces, estimWeightKg, err := s.hppRepo.GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, endDate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
realPieces, realWeightKg, err := s.hppRepo.GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, startDate, endDate)
|
realPieces, realWeightKg, err := s.hppRepo.GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(context.Background(), []uint{projectFlockKandangId}, startDate, endDate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,21 +261,12 @@ func (s *hppService) GetHppEstimationDanRealisasi(totalProductionCost float64, p
|
|||||||
real.HargaButir = roundToTwoDecimals(totalProductionCost / realPieces)
|
real.HargaButir = roundToTwoDecimals(totalProductionCost / realPieces)
|
||||||
}
|
}
|
||||||
|
|
||||||
result := &HppCostResponse{
|
return &HppCostResponse{
|
||||||
Estimation: estimation,
|
Estimation: estimation,
|
||||||
Real: real,
|
Real: real,
|
||||||
}
|
}, nil
|
||||||
return result, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func roundToTwoDecimals(value float64) float64 {
|
func roundToTwoDecimals(value float64) float64 {
|
||||||
result := math.Round(value*100) / 100
|
return math.Round(value*100) / 100
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatTimePtr(value *time.Time) string {
|
|
||||||
if value == nil {
|
|
||||||
return "<nil>"
|
|
||||||
}
|
|
||||||
return value.Format(time.RFC3339)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
type HppV2DateWindow struct {
|
|
||||||
Start string `json:"start"`
|
|
||||||
End string `json:"end"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type HppV2Proration struct {
|
|
||||||
Basis string `json:"basis"`
|
|
||||||
Numerator float64 `json:"numerator"`
|
|
||||||
Denominator float64 `json:"denominator"`
|
|
||||||
Ratio float64 `json:"ratio"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type HppV2Reference struct {
|
|
||||||
Type string `json:"type"`
|
|
||||||
ID uint `json:"id"`
|
|
||||||
StockableType string `json:"stockable_type,omitempty"`
|
|
||||||
ProjectFlockKandangID *uint `json:"project_flock_kandang_id,omitempty"`
|
|
||||||
ProductID uint `json:"product_id,omitempty"`
|
|
||||||
ProductName string `json:"product_name,omitempty"`
|
|
||||||
Date string `json:"date,omitempty"`
|
|
||||||
Qty float64 `json:"qty"`
|
|
||||||
UnitPrice float64 `json:"unit_price"`
|
|
||||||
Total float64 `json:"total"`
|
|
||||||
AppliedTotal float64 `json:"applied_total"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type HppV2ComponentPart struct {
|
|
||||||
Code string `json:"code"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Scopes []string `json:"scopes,omitempty"`
|
|
||||||
Total float64 `json:"total"`
|
|
||||||
Proration *HppV2Proration `json:"proration,omitempty"`
|
|
||||||
Details map[string]any `json:"details,omitempty"`
|
|
||||||
References []HppV2Reference `json:"references,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type HppV2Component struct {
|
|
||||||
Code string `json:"code"`
|
|
||||||
Title string `json:"title"`
|
|
||||||
Scopes []string `json:"scopes,omitempty"`
|
|
||||||
Total float64 `json:"total"`
|
|
||||||
Parts []HppV2ComponentPart `json:"parts"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type HppV2Breakdown struct {
|
|
||||||
ProjectFlockKandangID uint `json:"project_flock_kandang_id"`
|
|
||||||
ProjectFlockID uint `json:"project_flock_id"`
|
|
||||||
ProjectFlockCategory string `json:"project_flock_category,omitempty"`
|
|
||||||
HouseType string `json:"house_type,omitempty"`
|
|
||||||
KandangID uint `json:"kandang_id,omitempty"`
|
|
||||||
KandangName string `json:"kandang_name,omitempty"`
|
|
||||||
LocationID uint `json:"location_id,omitempty"`
|
|
||||||
PeriodDate string `json:"period_date"`
|
|
||||||
Window HppV2DateWindow `json:"window"`
|
|
||||||
TotalPulletCost float64 `json:"total_pullet_cost"`
|
|
||||||
TotalProductionCost float64 `json:"total_production_cost"`
|
|
||||||
Components []HppV2Component `json:"components"`
|
|
||||||
Hpp HppCostResponse `json:"hpp"`
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,872 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
|
|
||||||
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
|
||||||
)
|
|
||||||
|
|
||||||
type hppV2RepoStub struct {
|
|
||||||
contextByPFK map[uint]*commonRepo.HppV2ProjectFlockKandangContext
|
|
||||||
pfkIDsByProject map[uint][]uint
|
|
||||||
latestTransferByPFK map[uint]*commonRepo.HppV2LatestTransferInputRow
|
|
||||||
manualInputByProject map[uint]*commonRepo.HppV2ManualDepreciationInputRow
|
|
||||||
snapshotByProjectKey map[string]*commonRepo.HppV2FarmDepreciationSnapshotRow
|
|
||||||
chickInDateByProject map[uint]*time.Time
|
|
||||||
depreciationByHouse map[string]map[int]float64
|
|
||||||
usageRowsByKey map[string][]commonRepo.HppV2UsageCostRow
|
|
||||||
adjustRowsByKey map[string][]commonRepo.HppV2AdjustmentCostRow
|
|
||||||
chickinRowsByKey map[string][]commonRepo.HppV2ChickinCostRow
|
|
||||||
expenseRowsByPFKKey map[string][]commonRepo.HppV2ExpenseCostRow
|
|
||||||
expenseRowsByFarmKey map[string][]commonRepo.HppV2ExpenseCostRow
|
|
||||||
totalPopulationByKey map[string]float64
|
|
||||||
transferSummaryByPFK map[uint]struct {
|
|
||||||
projectFlockID uint
|
|
||||||
totalQty float64
|
|
||||||
}
|
|
||||||
eggProductionByPFK map[uint]struct {
|
|
||||||
pieces float64
|
|
||||||
kg float64
|
|
||||||
}
|
|
||||||
eggSalesByPFK map[uint]struct {
|
|
||||||
pieces float64
|
|
||||||
kg float64
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *hppV2RepoStub) GetProjectFlockKandangContext(_ context.Context, projectFlockKandangId uint) (*commonRepo.HppV2ProjectFlockKandangContext, error) {
|
|
||||||
row := s.contextByPFK[projectFlockKandangId]
|
|
||||||
if row == nil {
|
|
||||||
return nil, fmt.Errorf("pfk %d not found", projectFlockKandangId)
|
|
||||||
}
|
|
||||||
return row, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *hppV2RepoStub) GetProjectFlockKandangIDs(_ context.Context, projectFlockId uint) ([]uint, error) {
|
|
||||||
return append([]uint{}, s.pfkIDsByProject[projectFlockId]...), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *hppV2RepoStub) GetLatestTransferInputByProjectFlockKandangID(_ context.Context, projectFlockKandangId uint, _ time.Time) (*commonRepo.HppV2LatestTransferInputRow, error) {
|
|
||||||
return s.latestTransferByPFK[projectFlockKandangId], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *hppV2RepoStub) GetManualDepreciationInputByProjectFlockID(_ context.Context, projectFlockID uint) (*commonRepo.HppV2ManualDepreciationInputRow, error) {
|
|
||||||
return s.manualInputByProject[projectFlockID], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *hppV2RepoStub) GetFarmDepreciationSnapshotByProjectFlockIDAndPeriod(_ context.Context, projectFlockID uint, periodDate time.Time) (*commonRepo.HppV2FarmDepreciationSnapshotRow, error) {
|
|
||||||
if s.snapshotByProjectKey == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return s.snapshotByProjectKey[fmt.Sprintf("%d|%s", projectFlockID, periodDate.Format("2006-01-02"))], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *hppV2RepoStub) GetEarliestChickInDateByProjectFlockID(_ context.Context, projectFlockID uint) (*time.Time, error) {
|
|
||||||
return s.chickInDateByProject[projectFlockID], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *hppV2RepoStub) GetDepreciationPercents(_ context.Context, houseTypes []string, maxDay int) (map[string]map[int]float64, error) {
|
|
||||||
result := make(map[string]map[int]float64)
|
|
||||||
for _, houseType := range houseTypes {
|
|
||||||
source := s.depreciationByHouse[houseType]
|
|
||||||
if len(source) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
result[houseType] = make(map[int]float64)
|
|
||||||
for day, pct := range source {
|
|
||||||
if day <= maxDay {
|
|
||||||
result[houseType][day] = pct
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *hppV2RepoStub) ListUsageCostRowsByProductFlags(_ context.Context, projectFlockKandangIDs []uint, flagNames []string, _ *time.Time) ([]commonRepo.HppV2UsageCostRow, error) {
|
|
||||||
return append([]commonRepo.HppV2UsageCostRow{}, s.usageRowsByKey[stubKey(projectFlockKandangIDs, flagNames)]...), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *hppV2RepoStub) ListAdjustmentCostRowsByProductFlags(_ context.Context, projectFlockKandangIDs []uint, flagNames []string, _ *time.Time) ([]commonRepo.HppV2AdjustmentCostRow, error) {
|
|
||||||
return append([]commonRepo.HppV2AdjustmentCostRow{}, s.adjustRowsByKey[stubKey(projectFlockKandangIDs, flagNames)]...), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *hppV2RepoStub) ListExpenseRealizationRowsByProjectFlockKandangIDs(_ context.Context, projectFlockKandangIDs []uint, _ *time.Time, ekspedisi bool) ([]commonRepo.HppV2ExpenseCostRow, error) {
|
|
||||||
return append([]commonRepo.HppV2ExpenseCostRow{}, s.expenseRowsByPFKKey[expenseStubKey(projectFlockKandangIDs, ekspedisi)]...), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *hppV2RepoStub) ListExpenseRealizationRowsByProjectFlockID(_ context.Context, projectFlockID uint, _ *time.Time, ekspedisi bool) ([]commonRepo.HppV2ExpenseCostRow, error) {
|
|
||||||
return append([]commonRepo.HppV2ExpenseCostRow{}, s.expenseRowsByFarmKey[expenseFarmKey(projectFlockID, ekspedisi)]...), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *hppV2RepoStub) ListChickinCostRowsByProductFlags(_ context.Context, projectFlockKandangIDs []uint, flagNames []string, _ *time.Time, excludeTransferToLaying bool) ([]commonRepo.HppV2ChickinCostRow, error) {
|
|
||||||
return append([]commonRepo.HppV2ChickinCostRow{}, s.chickinRowsByKey[chickinStubKey(projectFlockKandangIDs, flagNames, excludeTransferToLaying)]...), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *hppV2RepoStub) GetFeedUsageCost(_ context.Context, _ []uint, _ *time.Time) (float64, error) {
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *hppV2RepoStub) GetTotalPopulation(_ context.Context, projectFlockKandangIDs []uint) (float64, error) {
|
|
||||||
return s.totalPopulationByKey[stubKey(projectFlockKandangIDs, nil)], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *hppV2RepoStub) GetEggProduksiPiecesAndWeightKgByProjectFlockKandangIds(_ context.Context, projectFlockKandangIDs []uint, _ *time.Time) (float64, float64, error) {
|
|
||||||
totalPieces := 0.0
|
|
||||||
totalKg := 0.0
|
|
||||||
for _, projectFlockKandangID := range projectFlockKandangIDs {
|
|
||||||
row := s.eggProductionByPFK[projectFlockKandangID]
|
|
||||||
totalPieces += row.pieces
|
|
||||||
totalKg += row.kg
|
|
||||||
}
|
|
||||||
return totalPieces, totalKg, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *hppV2RepoStub) GetEggTerjualPiecesAndWeightKgByProjectFlockKandangIds(_ context.Context, projectFlockKandangIDs []uint, _ *time.Time, _ *time.Time) (float64, float64, error) {
|
|
||||||
if len(projectFlockKandangIDs) != 1 {
|
|
||||||
return 0, 0, nil
|
|
||||||
}
|
|
||||||
row := s.eggSalesByPFK[projectFlockKandangIDs[0]]
|
|
||||||
return row.pieces, row.kg, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *hppV2RepoStub) GetTransferSourceSummary(_ context.Context, projectFlockKandangId uint) (uint, float64, error) {
|
|
||||||
row := s.transferSummaryByPFK[projectFlockKandangId]
|
|
||||||
return row.projectFlockID, row.totalQty, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHppV2CalculateHppBreakdown_ComposesPakanSlices(t *testing.T) {
|
|
||||||
repo := &hppV2RepoStub{
|
|
||||||
contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{
|
|
||||||
10: {
|
|
||||||
ProjectFlockKandangID: 10,
|
|
||||||
ProjectFlockID: 2,
|
|
||||||
ProjectFlockCategory: "LAYING",
|
|
||||||
KandangID: 100,
|
|
||||||
KandangName: "Kandang A",
|
|
||||||
LocationID: 16,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
pfkIDsByProject: map[uint][]uint{
|
|
||||||
1: {101, 102},
|
|
||||||
},
|
|
||||||
usageRowsByKey: map[string][]commonRepo.HppV2UsageCostRow{
|
|
||||||
stubKey([]uint{101, 102}, []string{"PAKAN"}): {
|
|
||||||
{StockableType: "purchase_items", StockableID: 9001, SourceProductID: 8, SourceProductName: "Pakan Growing", Qty: 100, UnitPrice: 40, TotalCost: 4000},
|
|
||||||
},
|
|
||||||
stubKey([]uint{10}, []string{"PAKAN"}): {
|
|
||||||
{StockableType: "purchase_items", StockableID: 9002, SourceProductID: 9, SourceProductName: "Pakan Laying", Qty: 50, UnitPrice: 30, TotalCost: 1500},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
adjustRowsByKey: map[string][]commonRepo.HppV2AdjustmentCostRow{
|
|
||||||
stubKey([]uint{101, 102}, []string{"PAKAN-CUTOVER"}): {
|
|
||||||
{AdjustmentID: 8001, ProductID: 11, ProductName: "Pakan Growing Cut-over", Qty: 20, Price: 30, GrandTotal: 600},
|
|
||||||
},
|
|
||||||
stubKey([]uint{10}, []string{"PAKAN-CUTOVER"}): {
|
|
||||||
{AdjustmentID: 8002, ProductID: 12, ProductName: "Pakan Laying Cut-over", Qty: 10, Price: 30, GrandTotal: 300},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
totalPopulationByKey: map[string]float64{
|
|
||||||
stubKey([]uint{101, 102}, nil): 1000,
|
|
||||||
},
|
|
||||||
transferSummaryByPFK: map[uint]struct {
|
|
||||||
projectFlockID uint
|
|
||||||
totalQty float64
|
|
||||||
}{
|
|
||||||
10: {projectFlockID: 1, totalQty: 250},
|
|
||||||
},
|
|
||||||
eggProductionByPFK: map[uint]struct {
|
|
||||||
pieces float64
|
|
||||||
kg float64
|
|
||||||
}{
|
|
||||||
10: {pieces: 100, kg: 10},
|
|
||||||
},
|
|
||||||
eggSalesByPFK: map[uint]struct {
|
|
||||||
pieces float64
|
|
||||||
kg float64
|
|
||||||
}{
|
|
||||||
10: {pieces: 40, kg: 4},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
svc := NewHppV2Service(repo)
|
|
||||||
result, err := svc.CalculateHppBreakdown(10, mustDate(t, "2026-04-19"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
if result == nil {
|
|
||||||
t.Fatal("expected breakdown result")
|
|
||||||
}
|
|
||||||
if got := result.TotalPulletCost; got != 1150 {
|
|
||||||
t.Fatalf("expected total pullet cost 1150, got %v", got)
|
|
||||||
}
|
|
||||||
if got := result.TotalProductionCost; got != 1800 {
|
|
||||||
t.Fatalf("expected total production cost 1800, got %v", got)
|
|
||||||
}
|
|
||||||
if len(result.Components) != 1 {
|
|
||||||
t.Fatalf("expected 1 component, got %d", len(result.Components))
|
|
||||||
}
|
|
||||||
component := result.Components[0]
|
|
||||||
if component.Code != "PAKAN" {
|
|
||||||
t.Fatalf("expected PAKAN component, got %s", component.Code)
|
|
||||||
}
|
|
||||||
partTotals := map[string]float64{}
|
|
||||||
for _, part := range component.Parts {
|
|
||||||
partTotals[part.Code] = part.Total
|
|
||||||
}
|
|
||||||
if partTotals[hppV2PartGrowingNormal] != 1000 {
|
|
||||||
t.Fatalf("expected growing normal 1000, got %v", partTotals[hppV2PartGrowingNormal])
|
|
||||||
}
|
|
||||||
if partTotals[hppV2PartGrowingCutover] != 150 {
|
|
||||||
t.Fatalf("expected growing cutover 150, got %v", partTotals[hppV2PartGrowingCutover])
|
|
||||||
}
|
|
||||||
if partTotals[hppV2PartLayingNormal] != 1500 {
|
|
||||||
t.Fatalf("expected laying normal 1500, got %v", partTotals[hppV2PartLayingNormal])
|
|
||||||
}
|
|
||||||
if partTotals[hppV2PartLayingCutover] != 300 {
|
|
||||||
t.Fatalf("expected laying cutover 300, got %v", partTotals[hppV2PartLayingCutover])
|
|
||||||
}
|
|
||||||
if component.Parts[0].Proration == nil || component.Parts[0].Proration.Ratio != 0.25 {
|
|
||||||
t.Fatalf("expected growing proration ratio 0.25, got %+v", component.Parts[0].Proration)
|
|
||||||
}
|
|
||||||
if result.Hpp.Estimation.HargaKg != 180 {
|
|
||||||
t.Fatalf("expected estimation harga/kg 180, got %v", result.Hpp.Estimation.HargaKg)
|
|
||||||
}
|
|
||||||
if result.Hpp.Real.HargaKg != 450 {
|
|
||||||
t.Fatalf("expected real harga/kg 450, got %v", result.Hpp.Real.HargaKg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHppV2CalculateHppBreakdown_ManualCutoverUsesLayingSlicesOnly(t *testing.T) {
|
|
||||||
repo := &hppV2RepoStub{
|
|
||||||
contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{
|
|
||||||
20: {
|
|
||||||
ProjectFlockKandangID: 20,
|
|
||||||
ProjectFlockID: 3,
|
|
||||||
ProjectFlockCategory: "LAYING",
|
|
||||||
KandangID: 200,
|
|
||||||
KandangName: "Kandang B",
|
|
||||||
LocationID: 17,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
usageRowsByKey: map[string][]commonRepo.HppV2UsageCostRow{
|
|
||||||
stubKey([]uint{20}, []string{"PAKAN"}): {
|
|
||||||
{StockableType: "purchase_items", StockableID: 9100, SourceProductID: 21, SourceProductName: "Pakan Laying", Qty: 20, UnitPrice: 10, TotalCost: 200},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
adjustRowsByKey: map[string][]commonRepo.HppV2AdjustmentCostRow{
|
|
||||||
stubKey([]uint{20}, []string{"PAKAN-CUTOVER"}): {
|
|
||||||
{AdjustmentID: 8100, ProductID: 22, ProductName: "Pakan Laying Cut-over", Qty: 30, Price: 10, GrandTotal: 300},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
eggProductionByPFK: map[uint]struct {
|
|
||||||
pieces float64
|
|
||||||
kg float64
|
|
||||||
}{
|
|
||||||
20: {pieces: 50, kg: 5},
|
|
||||||
},
|
|
||||||
eggSalesByPFK: map[uint]struct {
|
|
||||||
pieces float64
|
|
||||||
kg float64
|
|
||||||
}{
|
|
||||||
20: {pieces: 25, kg: 2.5},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
svc := NewHppV2Service(repo)
|
|
||||||
result, err := svc.CalculateHppBreakdown(20, mustDate(t, "2026-04-19"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
if result.TotalProductionCost != 500 {
|
|
||||||
t.Fatalf("expected total production cost 500, got %v", result.TotalProductionCost)
|
|
||||||
}
|
|
||||||
component := result.Components[0]
|
|
||||||
if len(component.Parts) != 2 {
|
|
||||||
t.Fatalf("expected 2 laying parts, got %d", len(component.Parts))
|
|
||||||
}
|
|
||||||
for _, part := range component.Parts {
|
|
||||||
if strings.HasPrefix(part.Code, "growing_") {
|
|
||||||
t.Fatalf("expected no growing parts, got %s", part.Code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if result.Hpp.Estimation.HargaKg != 100 {
|
|
||||||
t.Fatalf("expected estimation harga/kg 100, got %v", result.Hpp.Estimation.HargaKg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHppV2CalculateHppBreakdown_IncludesOvkComponent(t *testing.T) {
|
|
||||||
repo := &hppV2RepoStub{
|
|
||||||
contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{
|
|
||||||
30: {
|
|
||||||
ProjectFlockKandangID: 30,
|
|
||||||
ProjectFlockID: 4,
|
|
||||||
ProjectFlockCategory: "LAYING",
|
|
||||||
KandangID: 300,
|
|
||||||
KandangName: "Kandang C",
|
|
||||||
LocationID: 18,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
pfkIDsByProject: map[uint][]uint{
|
|
||||||
5: {301, 302},
|
|
||||||
},
|
|
||||||
usageRowsByKey: map[string][]commonRepo.HppV2UsageCostRow{
|
|
||||||
stubKey([]uint{30}, []string{"PAKAN"}): {
|
|
||||||
{StockableType: "purchase_items", StockableID: 9200, SourceProductID: 31, SourceProductName: "Pakan Laying", Qty: 20, UnitPrice: 25, TotalCost: 500},
|
|
||||||
},
|
|
||||||
stubKey([]uint{301, 302}, []string{string(utils.FlagOVK), string(utils.FlagObat), string(utils.FlagVitamin), string(utils.FlagKimia)}): {
|
|
||||||
{StockableType: "purchase_items", StockableID: 9201, SourceProductID: 32, SourceProductName: "OVK Growing", Qty: 40, UnitPrice: 10, TotalCost: 400},
|
|
||||||
},
|
|
||||||
stubKey([]uint{30}, []string{string(utils.FlagOVK), string(utils.FlagObat), string(utils.FlagVitamin), string(utils.FlagKimia)}): {
|
|
||||||
{StockableType: "purchase_items", StockableID: 9202, SourceProductID: 33, SourceProductName: "OVK Laying", Qty: 15, UnitPrice: 10, TotalCost: 150},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
adjustRowsByKey: map[string][]commonRepo.HppV2AdjustmentCostRow{
|
|
||||||
stubKey([]uint{301, 302}, []string{"OVK"}): {
|
|
||||||
{AdjustmentID: 8201, ProductID: 34, ProductName: "OVK Growing Cut-over", Qty: 10, Price: 10, GrandTotal: 100},
|
|
||||||
},
|
|
||||||
stubKey([]uint{30}, []string{"OVK"}): {
|
|
||||||
{AdjustmentID: 8202, ProductID: 35, ProductName: "OVK Laying Cut-over", Qty: 5, Price: 10, GrandTotal: 50},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
totalPopulationByKey: map[string]float64{
|
|
||||||
stubKey([]uint{301, 302}, nil): 1000,
|
|
||||||
},
|
|
||||||
transferSummaryByPFK: map[uint]struct {
|
|
||||||
projectFlockID uint
|
|
||||||
totalQty float64
|
|
||||||
}{
|
|
||||||
30: {projectFlockID: 5, totalQty: 500},
|
|
||||||
},
|
|
||||||
eggProductionByPFK: map[uint]struct {
|
|
||||||
pieces float64
|
|
||||||
kg float64
|
|
||||||
}{
|
|
||||||
30: {pieces: 120, kg: 12},
|
|
||||||
},
|
|
||||||
eggSalesByPFK: map[uint]struct {
|
|
||||||
pieces float64
|
|
||||||
kg float64
|
|
||||||
}{
|
|
||||||
30: {pieces: 60, kg: 6},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
svc := NewHppV2Service(repo)
|
|
||||||
result, err := svc.CalculateHppBreakdown(30, mustDate(t, "2026-04-19"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
if result == nil {
|
|
||||||
t.Fatal("expected breakdown result")
|
|
||||||
}
|
|
||||||
if len(result.Components) != 2 {
|
|
||||||
t.Fatalf("expected 2 components, got %d", len(result.Components))
|
|
||||||
}
|
|
||||||
|
|
||||||
componentTotals := map[string]float64{}
|
|
||||||
for _, component := range result.Components {
|
|
||||||
componentTotals[component.Code] = component.Total
|
|
||||||
}
|
|
||||||
|
|
||||||
if componentTotals[hppV2ComponentPakan] != 500 {
|
|
||||||
t.Fatalf("expected pakan total 500, got %v", componentTotals[hppV2ComponentPakan])
|
|
||||||
}
|
|
||||||
if componentTotals[hppV2ComponentOvk] != 450 {
|
|
||||||
t.Fatalf("expected ovk total 450, got %v", componentTotals[hppV2ComponentOvk])
|
|
||||||
}
|
|
||||||
if result.TotalPulletCost != 250 {
|
|
||||||
t.Fatalf("expected total pullet cost 250, got %v", result.TotalPulletCost)
|
|
||||||
}
|
|
||||||
if result.TotalProductionCost != 700 {
|
|
||||||
t.Fatalf("expected total production cost 700, got %v", result.TotalProductionCost)
|
|
||||||
}
|
|
||||||
if result.Hpp.Estimation.HargaKg != 58.33 {
|
|
||||||
t.Fatalf("expected estimation harga/kg 58.33, got %v", result.Hpp.Estimation.HargaKg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHppV2CalculateHppBreakdown_IncludesDocAndDirectPulletChickin(t *testing.T) {
|
|
||||||
repo := &hppV2RepoStub{
|
|
||||||
contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{
|
|
||||||
35: {
|
|
||||||
ProjectFlockKandangID: 35,
|
|
||||||
ProjectFlockID: 8,
|
|
||||||
ProjectFlockCategory: "LAYING",
|
|
||||||
KandangID: 350,
|
|
||||||
KandangName: "Kandang E",
|
|
||||||
LocationID: 20,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
pfkIDsByProject: map[uint][]uint{
|
|
||||||
9: {901, 902},
|
|
||||||
},
|
|
||||||
totalPopulationByKey: map[string]float64{
|
|
||||||
stubKey([]uint{901, 902}, nil): 1000,
|
|
||||||
},
|
|
||||||
transferSummaryByPFK: map[uint]struct {
|
|
||||||
projectFlockID uint
|
|
||||||
totalQty float64
|
|
||||||
}{
|
|
||||||
35: {projectFlockID: 9, totalQty: 250},
|
|
||||||
},
|
|
||||||
chickinRowsByKey: map[string][]commonRepo.HppV2ChickinCostRow{
|
|
||||||
chickinStubKey([]uint{901, 902}, []string{string(utils.FlagDOC)}, false): {
|
|
||||||
{ProjectChickinID: 1, ProjectFlockKandangID: 901, ChickInDate: mustTime(t, "2026-04-01"), StockableType: "purchase_items", StockableID: 1001, SourceProductID: 77, SourceProductName: "DOC", Qty: 1000, UnitPrice: 2, TotalCost: 2000},
|
|
||||||
},
|
|
||||||
chickinStubKey([]uint{35}, []string{string(utils.FlagPullet), string(utils.FlagLayer)}, true): {
|
|
||||||
{ProjectChickinID: 2, ProjectFlockKandangID: 35, ChickInDate: mustTime(t, "2026-04-15"), StockableType: "purchase_items", StockableID: 1002, SourceProductID: 78, SourceProductName: "Pullet", Qty: 50, UnitPrice: 20, TotalCost: 1000},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
eggProductionByPFK: map[uint]struct {
|
|
||||||
pieces float64
|
|
||||||
kg float64
|
|
||||||
}{
|
|
||||||
35: {pieces: 100, kg: 10},
|
|
||||||
},
|
|
||||||
eggSalesByPFK: map[uint]struct {
|
|
||||||
pieces float64
|
|
||||||
kg float64
|
|
||||||
}{
|
|
||||||
35: {pieces: 80, kg: 8},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
svc := NewHppV2Service(repo)
|
|
||||||
result, err := svc.CalculateHppBreakdown(35, mustDate(t, "2026-04-19"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
componentTotals := map[string]float64{}
|
|
||||||
for _, component := range result.Components {
|
|
||||||
componentTotals[component.Code] = component.Total
|
|
||||||
}
|
|
||||||
|
|
||||||
if componentTotals[hppV2ComponentDocChickin] != 500 {
|
|
||||||
t.Fatalf("expected doc chickin total 500, got %v", componentTotals[hppV2ComponentDocChickin])
|
|
||||||
}
|
|
||||||
if componentTotals[hppV2ComponentDirectPulletPurchase] != 1000 {
|
|
||||||
t.Fatalf("expected direct pullet purchase total 1000, got %v", componentTotals[hppV2ComponentDirectPulletPurchase])
|
|
||||||
}
|
|
||||||
if result.TotalPulletCost != 500 {
|
|
||||||
t.Fatalf("expected total pullet cost 500, got %v", result.TotalPulletCost)
|
|
||||||
}
|
|
||||||
if result.TotalProductionCost != 1000 {
|
|
||||||
t.Fatalf("expected total production cost 1000, got %v", result.TotalProductionCost)
|
|
||||||
}
|
|
||||||
if result.Hpp.Estimation.HargaKg != 100 {
|
|
||||||
t.Fatalf("expected estimation harga/kg 100, got %v", result.Hpp.Estimation.HargaKg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHppV2CalculateHppBreakdown_IncludesBopRegularAndEkspedisi(t *testing.T) {
|
|
||||||
repo := &hppV2RepoStub{
|
|
||||||
contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{
|
|
||||||
40: {
|
|
||||||
ProjectFlockKandangID: 40,
|
|
||||||
ProjectFlockID: 6,
|
|
||||||
ProjectFlockCategory: "LAYING",
|
|
||||||
KandangID: 400,
|
|
||||||
KandangName: "Kandang D",
|
|
||||||
LocationID: 19,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
pfkIDsByProject: map[uint][]uint{
|
|
||||||
6: {40, 41},
|
|
||||||
7: {701, 702},
|
|
||||||
},
|
|
||||||
totalPopulationByKey: map[string]float64{
|
|
||||||
stubKey([]uint{701, 702}, nil): 1000,
|
|
||||||
},
|
|
||||||
transferSummaryByPFK: map[uint]struct {
|
|
||||||
projectFlockID uint
|
|
||||||
totalQty float64
|
|
||||||
}{
|
|
||||||
40: {projectFlockID: 7, totalQty: 200},
|
|
||||||
},
|
|
||||||
expenseRowsByPFKKey: map[string][]commonRepo.HppV2ExpenseCostRow{
|
|
||||||
expenseStubKey([]uint{701, 702}, false): {
|
|
||||||
{ExpenseRealizationID: 1, NonstockID: 11, NonstockName: "Growing BOP", Qty: 1, Price: 500, TotalCost: 500, RealizationDate: mustTime(t, "2026-04-10")},
|
|
||||||
},
|
|
||||||
expenseStubKey([]uint{40}, false): {
|
|
||||||
{ExpenseRealizationID: 2, NonstockID: 12, NonstockName: "Laying BOP", Qty: 1, Price: 80, TotalCost: 80, RealizationDate: mustTime(t, "2026-04-19")},
|
|
||||||
},
|
|
||||||
expenseStubKey([]uint{701, 702}, true): {
|
|
||||||
{ExpenseRealizationID: 3, NonstockID: 13, NonstockName: "Growing Expedition", Qty: 1, Price: 100, TotalCost: 100, RealizationDate: mustTime(t, "2026-04-11")},
|
|
||||||
},
|
|
||||||
expenseStubKey([]uint{40}, true): {
|
|
||||||
{ExpenseRealizationID: 4, NonstockID: 14, NonstockName: "Laying Expedition", Qty: 1, Price: 40, TotalCost: 40, RealizationDate: mustTime(t, "2026-04-19")},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expenseRowsByFarmKey: map[string][]commonRepo.HppV2ExpenseCostRow{
|
|
||||||
expenseFarmKey(7, false): {
|
|
||||||
{ExpenseRealizationID: 5, NonstockID: 15, NonstockName: "Growing Farm BOP", Qty: 1, Price: 300, TotalCost: 300, RealizationDate: mustTime(t, "2026-04-12")},
|
|
||||||
},
|
|
||||||
expenseFarmKey(6, false): {
|
|
||||||
{ExpenseRealizationID: 6, NonstockID: 16, NonstockName: "Laying Farm BOP", Qty: 1, Price: 100, TotalCost: 100, RealizationDate: mustTime(t, "2026-04-19")},
|
|
||||||
},
|
|
||||||
expenseFarmKey(7, true): {
|
|
||||||
{ExpenseRealizationID: 7, NonstockID: 17, NonstockName: "Growing Farm Expedition", Qty: 1, Price: 50, TotalCost: 50, RealizationDate: mustTime(t, "2026-04-12")},
|
|
||||||
},
|
|
||||||
expenseFarmKey(6, true): {
|
|
||||||
{ExpenseRealizationID: 8, NonstockID: 18, NonstockName: "Laying Farm Expedition", Qty: 1, Price: 60, TotalCost: 60, RealizationDate: mustTime(t, "2026-04-19")},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
eggProductionByPFK: map[uint]struct {
|
|
||||||
pieces float64
|
|
||||||
kg float64
|
|
||||||
}{
|
|
||||||
40: {pieces: 30, kg: 3},
|
|
||||||
41: {pieces: 70, kg: 7},
|
|
||||||
},
|
|
||||||
eggSalesByPFK: map[uint]struct {
|
|
||||||
pieces float64
|
|
||||||
kg float64
|
|
||||||
}{
|
|
||||||
40: {pieces: 50, kg: 5},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
svc := NewHppV2Service(repo)
|
|
||||||
result, err := svc.CalculateHppBreakdown(40, mustDate(t, "2026-04-19"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
componentTotals := map[string]float64{}
|
|
||||||
for _, component := range result.Components {
|
|
||||||
componentTotals[component.Code] = component.Total
|
|
||||||
}
|
|
||||||
|
|
||||||
if componentTotals[hppV2ComponentBopRegular] != 270 {
|
|
||||||
t.Fatalf("expected regular BOP total 270, got %v", componentTotals[hppV2ComponentBopRegular])
|
|
||||||
}
|
|
||||||
if componentTotals[hppV2ComponentBopEksp] != 88 {
|
|
||||||
t.Fatalf("expected expedition BOP total 88, got %v", componentTotals[hppV2ComponentBopEksp])
|
|
||||||
}
|
|
||||||
if result.TotalPulletCost != 190 {
|
|
||||||
t.Fatalf("expected total pullet cost 190, got %v", result.TotalPulletCost)
|
|
||||||
}
|
|
||||||
if result.TotalProductionCost != 168 {
|
|
||||||
t.Fatalf("expected total production cost 168, got %v", result.TotalProductionCost)
|
|
||||||
}
|
|
||||||
if result.Hpp.Estimation.HargaKg != 56 {
|
|
||||||
t.Fatalf("expected estimation harga/kg 56, got %v", result.Hpp.Estimation.HargaKg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHppV2CalculateHppBreakdown_AddsDepreciationForNormalTransfer(t *testing.T) {
|
|
||||||
sourceChickIn := mustTime(t, "2026-01-01")
|
|
||||||
reportDate := sourceChickIn.AddDate(0, 0, 154)
|
|
||||||
|
|
||||||
repo := &hppV2RepoStub{
|
|
||||||
contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{
|
|
||||||
50: {
|
|
||||||
ProjectFlockKandangID: 50,
|
|
||||||
ProjectFlockID: 10,
|
|
||||||
ProjectFlockCategory: "LAYING",
|
|
||||||
KandangID: 500,
|
|
||||||
KandangName: "Kandang F",
|
|
||||||
LocationID: 21,
|
|
||||||
HouseType: "close_house",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
pfkIDsByProject: map[uint][]uint{
|
|
||||||
11: {501},
|
|
||||||
},
|
|
||||||
latestTransferByPFK: map[uint]*commonRepo.HppV2LatestTransferInputRow{
|
|
||||||
50: {
|
|
||||||
ProjectFlockKandangID: 50,
|
|
||||||
SourceProjectFlockID: 11,
|
|
||||||
TransferDate: mustTime(t, "2026-05-20"),
|
|
||||||
TransferQty: 100,
|
|
||||||
TransferID: 701,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
chickInDateByProject: map[uint]*time.Time{
|
|
||||||
11: &sourceChickIn,
|
|
||||||
},
|
|
||||||
depreciationByHouse: map[string]map[int]float64{
|
|
||||||
"close_house": {
|
|
||||||
1: 10,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
usageRowsByKey: map[string][]commonRepo.HppV2UsageCostRow{
|
|
||||||
stubKey([]uint{501}, []string{"PAKAN"}): {
|
|
||||||
{StockableType: "purchase_items", StockableID: 9301, SourceProductID: 41, SourceProductName: "Pakan Growing", Qty: 25, UnitPrice: 40, TotalCost: 1000},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
totalPopulationByKey: map[string]float64{
|
|
||||||
stubKey([]uint{501}, nil): 100,
|
|
||||||
},
|
|
||||||
transferSummaryByPFK: map[uint]struct {
|
|
||||||
projectFlockID uint
|
|
||||||
totalQty float64
|
|
||||||
}{
|
|
||||||
50: {projectFlockID: 11, totalQty: 100},
|
|
||||||
},
|
|
||||||
eggProductionByPFK: map[uint]struct {
|
|
||||||
pieces float64
|
|
||||||
kg float64
|
|
||||||
}{
|
|
||||||
50: {pieces: 20, kg: 10},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
svc := NewHppV2Service(repo)
|
|
||||||
result, err := svc.CalculateHppBreakdown(50, &reportDate)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.TotalPulletCost != 1000 {
|
|
||||||
t.Fatalf("expected total pullet cost 1000, got %v", result.TotalPulletCost)
|
|
||||||
}
|
|
||||||
if result.TotalProductionCost != 100 {
|
|
||||||
t.Fatalf("expected total production cost 100, got %v", result.TotalProductionCost)
|
|
||||||
}
|
|
||||||
|
|
||||||
var depreciation *HppV2Component
|
|
||||||
for i := range result.Components {
|
|
||||||
if result.Components[i].Code == hppV2ComponentDepreciation {
|
|
||||||
depreciation = &result.Components[i]
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if depreciation == nil {
|
|
||||||
t.Fatal("expected depreciation component")
|
|
||||||
}
|
|
||||||
if depreciation.Total != 100 {
|
|
||||||
t.Fatalf("expected depreciation total 100, got %v", depreciation.Total)
|
|
||||||
}
|
|
||||||
if len(depreciation.Parts) != 1 {
|
|
||||||
t.Fatalf("expected single depreciation part, got %d", len(depreciation.Parts))
|
|
||||||
}
|
|
||||||
if depreciation.Parts[0].Details["schedule_day"] != 1 {
|
|
||||||
t.Fatalf("expected schedule day 1, got %+v", depreciation.Parts[0].Details)
|
|
||||||
}
|
|
||||||
if depreciation.Parts[0].Details["origin_date"] != "2026-01-01" {
|
|
||||||
t.Fatalf("expected origin date 2026-01-01, got %+v", depreciation.Parts[0].Details)
|
|
||||||
}
|
|
||||||
if result.Hpp.Estimation.HargaKg != 10 {
|
|
||||||
t.Fatalf("expected estimation harga/kg 10, got %v", result.Hpp.Estimation.HargaKg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHppV2CalculateHppBreakdown_AddsDepreciationForManualCutoverFromCutoverDate(t *testing.T) {
|
|
||||||
originDate := mustTime(t, "2026-01-01")
|
|
||||||
cutoverDate := originDate.AddDate(0, 0, 155)
|
|
||||||
|
|
||||||
repo := &hppV2RepoStub{
|
|
||||||
contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{
|
|
||||||
60: {
|
|
||||||
ProjectFlockKandangID: 60,
|
|
||||||
ProjectFlockID: 12,
|
|
||||||
ProjectFlockCategory: "LAYING",
|
|
||||||
KandangID: 600,
|
|
||||||
KandangName: "Kandang G",
|
|
||||||
LocationID: 22,
|
|
||||||
HouseType: "close_house",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
pfkIDsByProject: map[uint][]uint{
|
|
||||||
12: {60},
|
|
||||||
},
|
|
||||||
manualInputByProject: map[uint]*commonRepo.HppV2ManualDepreciationInputRow{
|
|
||||||
12: {
|
|
||||||
ID: 801,
|
|
||||||
ProjectFlockID: 12,
|
|
||||||
TotalCost: 1000,
|
|
||||||
CutoverDate: cutoverDate,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
chickInDateByProject: map[uint]*time.Time{
|
|
||||||
12: &originDate,
|
|
||||||
},
|
|
||||||
depreciationByHouse: map[string]map[int]float64{
|
|
||||||
"close_house": {
|
|
||||||
1: 10,
|
|
||||||
2: 20,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
totalPopulationByKey: map[string]float64{
|
|
||||||
stubKey([]uint{60}, nil): 100,
|
|
||||||
},
|
|
||||||
eggProductionByPFK: map[uint]struct {
|
|
||||||
pieces float64
|
|
||||||
kg float64
|
|
||||||
}{
|
|
||||||
60: {pieces: 20, kg: 10},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
svc := NewHppV2Service(repo)
|
|
||||||
result, err := svc.CalculateHppBreakdown(60, &cutoverDate)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.TotalPulletCost != 1000 {
|
|
||||||
t.Fatalf("expected total pullet cost 1000, got %v", result.TotalPulletCost)
|
|
||||||
}
|
|
||||||
if result.TotalProductionCost != 200 {
|
|
||||||
t.Fatalf("expected total production cost 200, got %v", result.TotalProductionCost)
|
|
||||||
}
|
|
||||||
|
|
||||||
componentTotals := map[string]float64{}
|
|
||||||
for _, component := range result.Components {
|
|
||||||
componentTotals[component.Code] = component.Total
|
|
||||||
}
|
|
||||||
if componentTotals[hppV2ComponentManualPulletCost] != 1000 {
|
|
||||||
t.Fatalf("expected manual pullet cost 1000, got %v", componentTotals[hppV2ComponentManualPulletCost])
|
|
||||||
}
|
|
||||||
if componentTotals[hppV2ComponentDepreciation] != 200 {
|
|
||||||
t.Fatalf("expected depreciation 200, got %v", componentTotals[hppV2ComponentDepreciation])
|
|
||||||
}
|
|
||||||
|
|
||||||
var depreciation *HppV2Component
|
|
||||||
for i := range result.Components {
|
|
||||||
if result.Components[i].Code == hppV2ComponentDepreciation {
|
|
||||||
depreciation = &result.Components[i]
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if depreciation == nil || len(depreciation.Parts) != 1 {
|
|
||||||
t.Fatalf("expected one depreciation part, got %+v", depreciation)
|
|
||||||
}
|
|
||||||
if depreciation.Parts[0].Details["schedule_day"] != 2 {
|
|
||||||
t.Fatalf("expected schedule day 2, got %+v", depreciation.Parts[0].Details)
|
|
||||||
}
|
|
||||||
if depreciation.Parts[0].Details["start_schedule_day"] != 2 {
|
|
||||||
t.Fatalf("expected start schedule day 2, got %+v", depreciation.Parts[0].Details)
|
|
||||||
}
|
|
||||||
if result.Hpp.Estimation.HargaKg != 20 {
|
|
||||||
t.Fatalf("expected estimation harga/kg 20, got %v", result.Hpp.Estimation.HargaKg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHppV2CalculateHppBreakdown_UsesFarmSnapshotDepreciationProratedByEggProduction(t *testing.T) {
|
|
||||||
reportDate := mustTime(t, "2026-06-05")
|
|
||||||
|
|
||||||
repo := &hppV2RepoStub{
|
|
||||||
contextByPFK: map[uint]*commonRepo.HppV2ProjectFlockKandangContext{
|
|
||||||
70: {
|
|
||||||
ProjectFlockKandangID: 70,
|
|
||||||
ProjectFlockID: 15,
|
|
||||||
ProjectFlockCategory: "LAYING",
|
|
||||||
KandangID: 700,
|
|
||||||
KandangName: "Kandang Snapshot",
|
|
||||||
LocationID: 25,
|
|
||||||
HouseType: "close_house",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
pfkIDsByProject: map[uint][]uint{
|
|
||||||
15: {70, 71},
|
|
||||||
},
|
|
||||||
snapshotByProjectKey: map[string]*commonRepo.HppV2FarmDepreciationSnapshotRow{
|
|
||||||
"15|2026-06-05": {
|
|
||||||
ID: 901,
|
|
||||||
ProjectFlockID: 15,
|
|
||||||
PeriodDate: reportDate,
|
|
||||||
DepreciationPercentEffective: 10,
|
|
||||||
DepreciationValue: 1000,
|
|
||||||
PulletCostDayNTotal: 10000,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
eggProductionByPFK: map[uint]struct {
|
|
||||||
pieces float64
|
|
||||||
kg float64
|
|
||||||
}{
|
|
||||||
70: {pieces: 200, kg: 20},
|
|
||||||
71: {pieces: 800, kg: 80},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
svc := NewHppV2Service(repo)
|
|
||||||
result, err := svc.CalculateHppBreakdown(70, &reportDate)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
if result == nil {
|
|
||||||
t.Fatal("expected breakdown result")
|
|
||||||
}
|
|
||||||
|
|
||||||
var depreciation *HppV2Component
|
|
||||||
for i := range result.Components {
|
|
||||||
if result.Components[i].Code == hppV2ComponentDepreciation {
|
|
||||||
depreciation = &result.Components[i]
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if depreciation == nil {
|
|
||||||
t.Fatal("expected depreciation component")
|
|
||||||
}
|
|
||||||
if depreciation.Total != 200 {
|
|
||||||
t.Fatalf("expected depreciation total 200, got %v", depreciation.Total)
|
|
||||||
}
|
|
||||||
if result.TotalProductionCost != 200 {
|
|
||||||
t.Fatalf("expected total production cost 200, got %v", result.TotalProductionCost)
|
|
||||||
}
|
|
||||||
if len(depreciation.Parts) != 1 {
|
|
||||||
t.Fatalf("expected one depreciation part, got %d", len(depreciation.Parts))
|
|
||||||
}
|
|
||||||
if depreciation.Parts[0].Code != hppV2PartDepreciationFarmSnapshot {
|
|
||||||
t.Fatalf("expected farm snapshot depreciation part, got %s", depreciation.Parts[0].Code)
|
|
||||||
}
|
|
||||||
if depreciation.Parts[0].Proration == nil || depreciation.Parts[0].Proration.Ratio != 0.2 {
|
|
||||||
t.Fatalf("expected proration ratio 0.2, got %+v", depreciation.Parts[0].Proration)
|
|
||||||
}
|
|
||||||
if depreciation.Parts[0].Details["snapshot_id"] != uint(901) {
|
|
||||||
t.Fatalf("expected snapshot id 901, got %+v", depreciation.Parts[0].Details)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func stubKey(ids []uint, flags []string) string {
|
|
||||||
idParts := make([]string, 0, len(ids))
|
|
||||||
for _, id := range ids {
|
|
||||||
idParts = append(idParts, fmt.Sprintf("%d", id))
|
|
||||||
}
|
|
||||||
sort.Strings(idParts)
|
|
||||||
|
|
||||||
flagParts := append([]string{}, flags...)
|
|
||||||
sort.Strings(flagParts)
|
|
||||||
|
|
||||||
return strings.Join(idParts, ",") + "|" + strings.Join(flagParts, ",")
|
|
||||||
}
|
|
||||||
|
|
||||||
func mustDate(t *testing.T, raw string) *time.Time {
|
|
||||||
t.Helper()
|
|
||||||
loc, err := time.LoadLocation("Asia/Jakarta")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to load timezone: %v", err)
|
|
||||||
}
|
|
||||||
value, err := time.ParseInLocation("2006-01-02", raw, loc)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to parse date %s: %v", raw, err)
|
|
||||||
}
|
|
||||||
return &value
|
|
||||||
}
|
|
||||||
|
|
||||||
func mustTime(t *testing.T, raw string) time.Time {
|
|
||||||
t.Helper()
|
|
||||||
value := mustDate(t, raw)
|
|
||||||
return *value
|
|
||||||
}
|
|
||||||
|
|
||||||
func expenseStubKey(ids []uint, ekspedisi bool) string {
|
|
||||||
return stubKey(ids, []string{fmt.Sprintf("ekspedisi=%t", ekspedisi)})
|
|
||||||
}
|
|
||||||
|
|
||||||
func expenseFarmKey(projectFlockID uint, ekspedisi bool) string {
|
|
||||||
return fmt.Sprintf("farm=%d|ekspedisi=%t", projectFlockID, ekspedisi)
|
|
||||||
}
|
|
||||||
|
|
||||||
func chickinStubKey(ids []uint, flags []string, excludeTransferToLaying bool) string {
|
|
||||||
return stubKey(ids, append(append([]string{}, flags...), fmt.Sprintf("exclude_transfer_to_laying=%t", excludeTransferToLaying)))
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
const farmDepreciationSnapshotTable = "farm_depreciation_snapshots"
|
|
||||||
|
|
||||||
func NormalizeDateOnlyUTC(value time.Time) time.Time {
|
|
||||||
if value.IsZero() {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
v := value.UTC()
|
|
||||||
return time.Date(v.Year(), v.Month(), v.Day(), 0, 0, 0, 0, time.UTC)
|
|
||||||
}
|
|
||||||
|
|
||||||
func MinNonZeroDateOnlyUTC(values ...time.Time) time.Time {
|
|
||||||
var out time.Time
|
|
||||||
for _, value := range values {
|
|
||||||
if value.IsZero() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
normalized := NormalizeDateOnlyUTC(value)
|
|
||||||
if out.IsZero() || normalized.Before(out) {
|
|
||||||
out = normalized
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func InvalidateFarmDepreciationSnapshotsFromDate(ctx context.Context, db *gorm.DB, farmIDs []uint, fromDate time.Time) error {
|
|
||||||
if db == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if fromDate.IsZero() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
fromDate = NormalizeDateOnlyUTC(fromDate)
|
|
||||||
query := db.WithContext(ctx).
|
|
||||||
Table(farmDepreciationSnapshotTable).
|
|
||||||
Where("period_date >= ?", fromDate)
|
|
||||||
if len(farmIDs) > 0 {
|
|
||||||
query = query.Where("project_flock_id IN ?", farmIDs)
|
|
||||||
}
|
|
||||||
return query.Delete(nil).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func ResolveProjectFlockIDsByProjectFlockKandangIDs(ctx context.Context, db *gorm.DB, pfkIDs []uint) ([]uint, error) {
|
|
||||||
if db == nil || len(pfkIDs) == 0 {
|
|
||||||
return []uint{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var projectFlockIDs []uint
|
|
||||||
if err := db.WithContext(ctx).
|
|
||||||
Table("project_flock_kandangs").
|
|
||||||
Distinct("project_flock_id").
|
|
||||||
Where("id IN ?", pfkIDs).
|
|
||||||
Pluck("project_flock_id", &projectFlockIDs).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return projectFlockIDs, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func ResolveProjectFlockIDsByExpenseID(ctx context.Context, db *gorm.DB, expenseID uint) ([]uint, error) {
|
|
||||||
if db == nil || expenseID == 0 {
|
|
||||||
return []uint{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
query := `
|
|
||||||
WITH direct_farms AS (
|
|
||||||
SELECT DISTINCT pfk.project_flock_id
|
|
||||||
FROM expense_nonstocks ens
|
|
||||||
JOIN project_flock_kandangs pfk ON pfk.id = ens.project_flock_kandang_id
|
|
||||||
WHERE ens.expense_id = @expense_id
|
|
||||||
),
|
|
||||||
json_farms AS (
|
|
||||||
SELECT DISTINCT (jsonb_array_elements_text(e.project_flock_id::jsonb))::bigint AS project_flock_id
|
|
||||||
FROM expenses e
|
|
||||||
WHERE e.id = @expense_id
|
|
||||||
AND e.project_flock_id IS NOT NULL
|
|
||||||
)
|
|
||||||
SELECT DISTINCT project_flock_id
|
|
||||||
FROM (
|
|
||||||
SELECT project_flock_id FROM direct_farms
|
|
||||||
UNION ALL
|
|
||||||
SELECT project_flock_id FROM json_farms
|
|
||||||
) x
|
|
||||||
`
|
|
||||||
|
|
||||||
var ids []uint
|
|
||||||
if err := db.WithContext(ctx).Raw(query, map[string]any{
|
|
||||||
"expense_id": expenseID,
|
|
||||||
}).Scan(&ids).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return ids, nil
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
-6
@@ -1,6 +0,0 @@
|
|||||||
ALTER TABLE kandangs
|
|
||||||
DROP COLUMN IF EXISTS house_type;
|
|
||||||
|
|
||||||
DROP TABLE IF EXISTS house_depreciation_standards;
|
|
||||||
|
|
||||||
DROP TYPE IF EXISTS house_type_enum;
|
|
||||||
-18
@@ -1,18 +0,0 @@
|
|||||||
CREATE TYPE house_type_enum AS ENUM ('open_house', 'close_house');
|
|
||||||
|
|
||||||
CREATE TABLE house_depreciation_standards (
|
|
||||||
id BIGSERIAL PRIMARY KEY,
|
|
||||||
name VARCHAR(100),
|
|
||||||
effective_date DATE,
|
|
||||||
house_type house_type_enum NOT NULL,
|
|
||||||
day INT NOT NULL
|
|
||||||
CHECK (day >= 0),
|
|
||||||
depreciation_percent NUMERIC(15, 6) NOT NULL
|
|
||||||
CHECK (depreciation_percent >= 0 AND depreciation_percent <= 100),
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
|
||||||
CONSTRAINT house_depreciation_standards_house_type_day_unique UNIQUE (house_type, day)
|
|
||||||
);
|
|
||||||
|
|
||||||
ALTER TABLE kandangs
|
|
||||||
ADD COLUMN house_type house_type_enum;
|
|
||||||
-4
@@ -1,4 +0,0 @@
|
|||||||
DROP INDEX IF EXISTS idx_farm_depreciation_snapshots_project_flock_id;
|
|
||||||
DROP INDEX IF EXISTS idx_farm_depreciation_snapshots_period_date;
|
|
||||||
DROP TABLE IF EXISTS farm_depreciation_snapshots;
|
|
||||||
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
CREATE TABLE IF NOT EXISTS farm_depreciation_snapshots (
|
|
||||||
id BIGSERIAL PRIMARY KEY,
|
|
||||||
project_flock_id BIGINT NOT NULL
|
|
||||||
REFERENCES project_flocks(id)
|
|
||||||
ON UPDATE CASCADE
|
|
||||||
ON DELETE CASCADE,
|
|
||||||
period_date DATE NOT NULL,
|
|
||||||
depreciation_percent_effective NUMERIC(15, 6) NOT NULL DEFAULT 0,
|
|
||||||
depreciation_value NUMERIC(18, 3) NOT NULL DEFAULT 0,
|
|
||||||
pullet_cost_day_n_total NUMERIC(18, 3) NOT NULL DEFAULT 0,
|
|
||||||
components JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
CONSTRAINT farm_depreciation_snapshots_unique UNIQUE (project_flock_id, period_date)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_farm_depreciation_snapshots_period_date
|
|
||||||
ON farm_depreciation_snapshots (period_date);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_farm_depreciation_snapshots_project_flock_id
|
|
||||||
ON farm_depreciation_snapshots (project_flock_id);
|
|
||||||
|
|
||||||
-2
@@ -1,2 +0,0 @@
|
|||||||
DROP INDEX IF EXISTS idx_farm_depreciation_manual_inputs_project_flock_id;
|
|
||||||
DROP TABLE IF EXISTS farm_depreciation_manual_inputs;
|
|
||||||
-16
@@ -1,16 +0,0 @@
|
|||||||
CREATE TABLE IF NOT EXISTS farm_depreciation_manual_inputs (
|
|
||||||
id BIGSERIAL PRIMARY KEY,
|
|
||||||
project_flock_id BIGINT NOT NULL
|
|
||||||
REFERENCES project_flocks(id)
|
|
||||||
ON UPDATE CASCADE
|
|
||||||
ON DELETE CASCADE,
|
|
||||||
total_cost NUMERIC(18, 3) NOT NULL DEFAULT 0
|
|
||||||
CHECK (total_cost >= 0),
|
|
||||||
note TEXT NULL,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
CONSTRAINT farm_depreciation_manual_inputs_unique UNIQUE (project_flock_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_farm_depreciation_manual_inputs_project_flock_id
|
|
||||||
ON farm_depreciation_manual_inputs (project_flock_id);
|
|
||||||
-4
@@ -1,4 +0,0 @@
|
|||||||
DROP INDEX IF EXISTS idx_farm_depreciation_manual_inputs_cutover_date;
|
|
||||||
|
|
||||||
ALTER TABLE farm_depreciation_manual_inputs
|
|
||||||
DROP COLUMN IF EXISTS cutover_date;
|
|
||||||
-12
@@ -1,12 +0,0 @@
|
|||||||
ALTER TABLE farm_depreciation_manual_inputs
|
|
||||||
ADD COLUMN IF NOT EXISTS cutover_date DATE;
|
|
||||||
|
|
||||||
UPDATE farm_depreciation_manual_inputs
|
|
||||||
SET cutover_date = COALESCE(cutover_date, DATE(created_at))
|
|
||||||
WHERE cutover_date IS NULL;
|
|
||||||
|
|
||||||
ALTER TABLE farm_depreciation_manual_inputs
|
|
||||||
ALTER COLUMN cutover_date SET NOT NULL;
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_farm_depreciation_manual_inputs_cutover_date
|
|
||||||
ON farm_depreciation_manual_inputs (cutover_date);
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
package entities
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
type FarmDepreciationManualInput struct {
|
|
||||||
Id uint `gorm:"primaryKey"`
|
|
||||||
ProjectFlockId uint `gorm:"not null;uniqueIndex:idx_farm_depreciation_manual_inputs_unique"`
|
|
||||||
TotalCost float64 `gorm:"type:numeric(18,3);not null;default:0"`
|
|
||||||
CutoverDate time.Time `gorm:"type:date;not null"`
|
|
||||||
Note *string `gorm:"type:text"`
|
|
||||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
|
||||||
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
|
||||||
ProjectFlock ProjectFlock `gorm:"foreignKey:ProjectFlockId;references:Id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (FarmDepreciationManualInput) TableName() string {
|
|
||||||
return "farm_depreciation_manual_inputs"
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
package entities
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type FarmDepreciationSnapshot struct {
|
|
||||||
Id uint `gorm:"primaryKey"`
|
|
||||||
ProjectFlockId uint `gorm:"not null;uniqueIndex:idx_farm_depreciation_snapshots_unique,priority:1"`
|
|
||||||
PeriodDate time.Time `gorm:"type:date;not null;uniqueIndex:idx_farm_depreciation_snapshots_unique,priority:2"`
|
|
||||||
DepreciationPercentEffective float64 `gorm:"type:numeric(15,6);not null;default:0"`
|
|
||||||
DepreciationValue float64 `gorm:"type:numeric(18,3);not null;default:0"`
|
|
||||||
PulletCostDayNTotal float64 `gorm:"type:numeric(18,3);not null;default:0"`
|
|
||||||
Components []byte `gorm:"type:jsonb;default:'{}'::jsonb"`
|
|
||||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
|
||||||
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (FarmDepreciationSnapshot) TableName() string {
|
|
||||||
return "farm_depreciation_snapshots"
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
package entities
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
type HouseDepreciationStandard struct {
|
|
||||||
Id uint `gorm:"primaryKey"`
|
|
||||||
HouseType string `gorm:"type:house_type_enum;not null;uniqueIndex:house_depreciation_standards_house_type_day_unique,priority:1"`
|
|
||||||
DayNumber int `gorm:"column:day;not null;uniqueIndex:house_depreciation_standards_house_type_day_unique,priority:2"`
|
|
||||||
DepreciationPercent float64 `gorm:"type:numeric(15,6);not null"`
|
|
||||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
|
||||||
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (HouseDepreciationStandard) TableName() string {
|
|
||||||
return "house_depreciation_standards"
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,6 @@ type Kandang struct {
|
|||||||
Id uint `gorm:"primaryKey"`
|
Id uint `gorm:"primaryKey"`
|
||||||
Name string `gorm:"type:varchar(50);not null;uniqueIndex:kandangs_name_unique,where:deleted_at IS NULL"`
|
Name string `gorm:"type:varchar(50);not null;uniqueIndex:kandangs_name_unique,where:deleted_at IS NULL"`
|
||||||
Status string `gorm:"type:varchar(50);not null"`
|
Status string `gorm:"type:varchar(50);not null"`
|
||||||
HouseType *string `gorm:"type:house_type_enum"`
|
|
||||||
LocationId uint `gorm:"not null"`
|
LocationId uint `gorm:"not null"`
|
||||||
KandangGroupId uint `gorm:"not null"`
|
KandangGroupId uint `gorm:"not null"`
|
||||||
Capacity float64 `gorm:"not null"`
|
Capacity float64 `gorm:"not null"`
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -48,7 +48,6 @@ const (
|
|||||||
)
|
)
|
||||||
const (
|
const (
|
||||||
P_ReportExpenseGetAll = "lti.repport.expense.list"
|
P_ReportExpenseGetAll = "lti.repport.expense.list"
|
||||||
P_ReportExpenseDepreciationManage = "lti.repport.expense.depreciation.manage"
|
|
||||||
P_ReportDeliveryGetAll = "lti.repport.delivery.list"
|
P_ReportDeliveryGetAll = "lti.repport.delivery.list"
|
||||||
P_ReportPurchaseSupplierGetAll = "lti.repport.purchasesupplier.list"
|
P_ReportPurchaseSupplierGetAll = "lti.repport.purchasesupplier.list"
|
||||||
P_ReportDebtSupplierGetAll = "lti.repport.debtsupplier.list"
|
P_ReportDebtSupplierGetAll = "lti.repport.debtsupplier.list"
|
||||||
|
|||||||
@@ -18,11 +18,11 @@ type DashboardModule struct{}
|
|||||||
|
|
||||||
func (DashboardModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) {
|
func (DashboardModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *validator.Validate) {
|
||||||
dashboardRepo := rDashboard.NewDashboardRepository(db)
|
dashboardRepo := rDashboard.NewDashboardRepository(db)
|
||||||
hppV2CostRepo := commonRepo.NewHppV2CostRepository(db)
|
hppCostRepo := commonRepo.NewHppCostRepository(db)
|
||||||
userRepo := rUser.NewUserRepository(db)
|
userRepo := rUser.NewUserRepository(db)
|
||||||
|
|
||||||
hppV2Svc := commonService.NewHppV2Service(hppV2CostRepo)
|
hppSvc := commonService.NewHppService(hppCostRepo)
|
||||||
dashboardService := sDashboard.NewDashboardService(dashboardRepo, validate, hppV2Svc)
|
dashboardService := sDashboard.NewDashboardService(dashboardRepo, validate, hppSvc)
|
||||||
userService := sUser.NewUserService(userRepo, validate)
|
userService := sUser.NewUserService(userRepo, validate)
|
||||||
|
|
||||||
DashboardRoutes(router, userService, dashboardService)
|
DashboardRoutes(router, userService, dashboardService)
|
||||||
|
|||||||
@@ -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(weekExpr).Order("1 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("%s, %s", weekExpr, groupExpr)
|
groupBy := fmt.Sprintf("u.week, %s", groupExpr)
|
||||||
orderBy := fmt.Sprintf("1 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,10 +30,10 @@ type dashboardService struct {
|
|||||||
Log *logrus.Logger
|
Log *logrus.Logger
|
||||||
Validate *validator.Validate
|
Validate *validator.Validate
|
||||||
Repository repository.DashboardRepository
|
Repository repository.DashboardRepository
|
||||||
HppSvc commonService.HppV2Service
|
HppSvc commonService.HppService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDashboardService(repo repository.DashboardRepository, validate *validator.Validate, hppSvc commonService.HppV2Service) DashboardService {
|
func NewDashboardService(repo repository.DashboardRepository, validate *validator.Validate, hppSvc commonService.HppService) DashboardService {
|
||||||
return &dashboardService{
|
return &dashboardService{
|
||||||
Log: utils.Log,
|
Log: utils.Log,
|
||||||
Validate: validate,
|
Validate: validate,
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
|
||||||
|
|
||||||
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
|
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
|
||||||
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
|
commonSvc "gitlab.com/mbugroup/lti-api.git/internal/common/service"
|
||||||
@@ -359,7 +358,6 @@ func (s *expenseService) CreateOne(c *fiber.Ctx, req *validation.Create) (*expen
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
s.invalidateDepreciationSnapshotsByExpense(c.Context(), nil, uint(expense.Id), expenseDate, nil)
|
|
||||||
return responseDTO, nil
|
return responseDTO, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,7 +385,6 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateBody := make(map[string]any)
|
updateBody := make(map[string]any)
|
||||||
var requestedTransactionDate *time.Time
|
|
||||||
|
|
||||||
if req.TransactionDate != nil {
|
if req.TransactionDate != nil {
|
||||||
expenseDate, err := utils.ParseDateString(*req.TransactionDate)
|
expenseDate, err := utils.ParseDateString(*req.TransactionDate)
|
||||||
@@ -395,7 +392,6 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
|
|||||||
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid transaction_date format")
|
return nil, fiber.NewError(fiber.StatusBadRequest, "Invalid transaction_date format")
|
||||||
}
|
}
|
||||||
updateBody["transaction_date"] = expenseDate
|
updateBody["transaction_date"] = expenseDate
|
||||||
requestedTransactionDate = &expenseDate
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Category != nil {
|
if req.Category != nil {
|
||||||
@@ -433,8 +429,6 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
|
|||||||
return responseDTO, nil
|
return responseDTO, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var invalidationFromDate time.Time
|
|
||||||
var invalidationFarmIDs []uint
|
|
||||||
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
|
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
|
||||||
|
|
||||||
expenseRepoTx := repository.NewExpenseRepository(tx)
|
expenseRepoTx := repository.NewExpenseRepository(tx)
|
||||||
@@ -452,16 +446,6 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
|
|||||||
if err := s.ensureProjectFlockNotClosedForExpense(c.Context(), currentExpense); err != nil {
|
if err := s.ensureProjectFlockNotClosedForExpense(c.Context(), currentExpense); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
oldFarmIDs, resolveOldFarmErr := commonSvc.ResolveProjectFlockIDsByExpenseID(c.Context(), tx, id)
|
|
||||||
if resolveOldFarmErr != nil {
|
|
||||||
s.Log.Warnf("Failed to resolve old expense farm ids for invalidation (expense_id=%d): %+v", id, resolveOldFarmErr)
|
|
||||||
}
|
|
||||||
invalidationFarmIDs = append(invalidationFarmIDs, oldFarmIDs...)
|
|
||||||
|
|
||||||
invalidationFromDate = currentExpense.TransactionDate
|
|
||||||
if requestedTransactionDate != nil {
|
|
||||||
invalidationFromDate = commonSvc.MinNonZeroDateOnlyUTC(currentExpense.TransactionDate, *requestedTransactionDate)
|
|
||||||
}
|
|
||||||
categoryChanged := false
|
categoryChanged := false
|
||||||
var newCategory string
|
var newCategory string
|
||||||
if req.Category != nil && *req.Category != currentExpense.Category {
|
if req.Category != nil && *req.Category != currentExpense.Category {
|
||||||
@@ -647,12 +631,6 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
newFarmIDs, resolveNewFarmErr := commonSvc.ResolveProjectFlockIDsByExpenseID(c.Context(), tx, id)
|
|
||||||
if resolveNewFarmErr != nil {
|
|
||||||
s.Log.Warnf("Failed to resolve new expense farm ids for invalidation (expense_id=%d): %+v", id, resolveNewFarmErr)
|
|
||||||
}
|
|
||||||
invalidationFarmIDs = append(invalidationFarmIDs, newFarmIDs...)
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -667,7 +645,6 @@ func (s expenseService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
s.invalidateDepreciationSnapshots(c.Context(), nil, invalidationFarmIDs, invalidationFromDate)
|
|
||||||
return responseDTO, nil
|
return responseDTO, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -694,10 +671,6 @@ func (s expenseService) DeleteOne(c *fiber.Ctx, id uint64) error {
|
|||||||
if err := s.ensureProjectFlockNotClosedForExpense(c.Context(), expense); err != nil {
|
if err := s.ensureProjectFlockNotClosedForExpense(c.Context(), expense); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
farmIDs, resolveFarmErr := commonSvc.ResolveProjectFlockIDsByExpenseID(c.Context(), s.Repository.DB(), idUint)
|
|
||||||
if resolveFarmErr != nil {
|
|
||||||
s.Log.Warnf("Failed to resolve expense farm ids before delete (expense_id=%d): %+v", idUint, resolveFarmErr)
|
|
||||||
}
|
|
||||||
if err := s.Repository.DeleteOne(c.Context(), idUint); err != nil {
|
if err := s.Repository.DeleteOne(c.Context(), idUint); err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
s.Log.Errorf("Expense not found for ID %d: %+v", id, err)
|
s.Log.Errorf("Expense not found for ID %d: %+v", id, err)
|
||||||
@@ -707,8 +680,6 @@ func (s expenseService) DeleteOne(c *fiber.Ctx, id uint64) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
s.Log.Infof("Successfully deleted expense with ID %d", id)
|
s.Log.Infof("Successfully deleted expense with ID %d", id)
|
||||||
invalidationFromDate := commonSvc.MinNonZeroDateOnlyUTC(expense.TransactionDate, expense.RealizationDate)
|
|
||||||
s.invalidateDepreciationSnapshots(c.Context(), nil, farmIDs, invalidationFromDate)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -829,8 +800,6 @@ func (s *expenseService) CreateRealization(c *fiber.Ctx, expenseID uint, req *va
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
invalidateFromDate := commonSvc.MinNonZeroDateOnlyUTC(expense.TransactionDate, realizationDate, expense.RealizationDate)
|
|
||||||
s.invalidateDepreciationSnapshotsByExpense(c.Context(), nil, expenseID, invalidateFromDate, nil)
|
|
||||||
return responseDTO, nil
|
return responseDTO, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -888,13 +857,6 @@ func (s *expenseService) CompleteExpense(c *fiber.Ctx, id uint, notes *string) (
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
expense, expenseErr := s.Repository.GetByID(c.Context(), id, nil)
|
|
||||||
if expenseErr != nil {
|
|
||||||
s.Log.Warnf("Failed to load expense for depreciation invalidation after complete (expense_id=%d): %+v", id, expenseErr)
|
|
||||||
} else {
|
|
||||||
invalidateFromDate := commonSvc.MinNonZeroDateOnlyUTC(expense.TransactionDate, expense.RealizationDate)
|
|
||||||
s.invalidateDepreciationSnapshotsByExpense(c.Context(), nil, id, invalidateFromDate, nil)
|
|
||||||
}
|
|
||||||
return responseDTO, nil
|
return responseDTO, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -922,12 +884,6 @@ func (s *expenseService) UpdateRealization(c *fiber.Ctx, expenseID uint, req *va
|
|||||||
if err := s.ensureProjectFlockNotClosedForExpense(c.Context(), expense); err != nil {
|
if err := s.ensureProjectFlockNotClosedForExpense(c.Context(), expense); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
invalidateFromDate := commonSvc.MinNonZeroDateOnlyUTC(expense.TransactionDate, expense.RealizationDate)
|
|
||||||
if req.RealizationDate != nil {
|
|
||||||
if parsedDate, parseErr := utils.ParseDateString(*req.RealizationDate); parseErr == nil {
|
|
||||||
invalidateFromDate = commonSvc.MinNonZeroDateOnlyUTC(invalidateFromDate, parsedDate)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
latestApproval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowExpense, expenseID, nil)
|
latestApproval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowExpense, expenseID, nil)
|
||||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate workflow")
|
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to validate workflow")
|
||||||
@@ -1040,7 +996,6 @@ func (s *expenseService) UpdateRealization(c *fiber.Ctx, expenseID uint, req *va
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
s.invalidateDepreciationSnapshotsByExpense(c.Context(), nil, expenseID, invalidateFromDate, nil)
|
|
||||||
return responseDTO, nil
|
return responseDTO, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1102,7 +1057,6 @@ func (s *expenseService) Approval(c *fiber.Ctx, req *validation.ApprovalRequest,
|
|||||||
}
|
}
|
||||||
|
|
||||||
var results []expenseDto.ExpenseDetailDTO
|
var results []expenseDto.ExpenseDetailDTO
|
||||||
invalidateFromDateByExpenseID := make(map[uint]time.Time)
|
|
||||||
|
|
||||||
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
|
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(tx *gorm.DB) error {
|
||||||
|
|
||||||
@@ -1115,17 +1069,6 @@ func (s *expenseService) Approval(c *fiber.Ctx, req *validation.ApprovalRequest,
|
|||||||
); err != nil {
|
); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
expenseForInvalidation, err := expenseRepoTx.GetByID(c.Context(), id, nil)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
return fiber.NewError(fiber.StatusNotFound, "Expense not found")
|
|
||||||
}
|
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to load expense")
|
|
||||||
}
|
|
||||||
invalidateFromDateByExpenseID[id] = commonSvc.MinNonZeroDateOnlyUTC(
|
|
||||||
expenseForInvalidation.TransactionDate,
|
|
||||||
expenseForInvalidation.RealizationDate,
|
|
||||||
)
|
|
||||||
|
|
||||||
latestApproval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowExpense, id, nil)
|
latestApproval, err := s.ApprovalSvc.LatestByTarget(c.Context(), utils.ApprovalWorkflowExpense, id, nil)
|
||||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
@@ -1227,73 +1170,10 @@ func (s *expenseService) Approval(c *fiber.Ctx, req *validation.ApprovalRequest,
|
|||||||
}
|
}
|
||||||
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed approve expenses")
|
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed approve expenses")
|
||||||
}
|
}
|
||||||
for expenseID, invalidateFromDate := range invalidateFromDateByExpenseID {
|
|
||||||
s.invalidateDepreciationSnapshotsByExpense(c.Context(), nil, expenseID, invalidateFromDate, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
return results, nil
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *expenseService) invalidateDepreciationSnapshotsByExpense(
|
|
||||||
ctx context.Context,
|
|
||||||
tx *gorm.DB,
|
|
||||||
expenseID uint,
|
|
||||||
fromDate time.Time,
|
|
||||||
fallbackFarmIDs []uint,
|
|
||||||
) {
|
|
||||||
targetDB := s.Repository.DB()
|
|
||||||
if tx != nil {
|
|
||||||
targetDB = tx
|
|
||||||
}
|
|
||||||
|
|
||||||
farmIDs := append([]uint{}, fallbackFarmIDs...)
|
|
||||||
if expenseID != 0 {
|
|
||||||
resolvedFarmIDs, err := commonSvc.ResolveProjectFlockIDsByExpenseID(ctx, targetDB, expenseID)
|
|
||||||
if err != nil {
|
|
||||||
s.Log.Warnf("Failed to resolve expense farm ids for invalidation (expense_id=%d): %+v", expenseID, err)
|
|
||||||
} else {
|
|
||||||
farmIDs = append(farmIDs, resolvedFarmIDs...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s.invalidateDepreciationSnapshots(ctx, tx, farmIDs, fromDate)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *expenseService) invalidateDepreciationSnapshots(
|
|
||||||
ctx context.Context,
|
|
||||||
tx *gorm.DB,
|
|
||||||
farmIDs []uint,
|
|
||||||
fromDate time.Time,
|
|
||||||
) {
|
|
||||||
if fromDate.IsZero() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
targetDB := s.Repository.DB()
|
|
||||||
if tx != nil {
|
|
||||||
targetDB = tx
|
|
||||||
}
|
|
||||||
farmIDs = utils.UniqueUintSlice(farmIDs)
|
|
||||||
if len(farmIDs) == 0 {
|
|
||||||
if err := commonSvc.InvalidateFarmDepreciationSnapshotsFromDate(ctx, targetDB, nil, fromDate); err != nil {
|
|
||||||
s.Log.Warnf(
|
|
||||||
"Failed to invalidate depreciation snapshots globally (from=%s): %+v",
|
|
||||||
fromDate.Format("2006-01-02"),
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := commonSvc.InvalidateFarmDepreciationSnapshotsFromDate(ctx, targetDB, farmIDs, fromDate); err != nil {
|
|
||||||
s.Log.Warnf(
|
|
||||||
"Failed to invalidate depreciation snapshots (farm_ids=%v, from=%s): %+v",
|
|
||||||
farmIDs,
|
|
||||||
fromDate.Format("2006-01-02"),
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *expenseService) generatePoNumber(ctx *gorm.DB, expenseID uint) (string, error) {
|
func (s *expenseService) generatePoNumber(ctx *gorm.DB, expenseID uint) (string, error) {
|
||||||
|
|
||||||
expenseRepoTx := repository.NewExpenseRepository(ctx)
|
expenseRepoTx := repository.NewExpenseRepository(ctx)
|
||||||
|
|||||||
@@ -58,8 +58,6 @@ type MarketingDeliveryProductDTO struct {
|
|||||||
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:"-"`
|
|
||||||
WeightPerConvertion *float64 `json:"-"`
|
|
||||||
ProductWarehouse *productwarehouseDTO.ProductWarehousNestedDTO `json:"product_warehouse,omitempty"`
|
ProductWarehouse *productwarehouseDTO.ProductWarehousNestedDTO `json:"product_warehouse,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,8 +67,6 @@ type DeliveryItemDTO struct {
|
|||||||
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"`
|
|
||||||
TotalPeti *float64 `json:"total_peti"`
|
|
||||||
TotalPrice float64 `json:"total_price"`
|
TotalPrice float64 `json:"total_price"`
|
||||||
VehicleNumber string `json:"vehicle_number"`
|
VehicleNumber string `json:"vehicle_number"`
|
||||||
}
|
}
|
||||||
@@ -160,7 +156,6 @@ func ToMarketingDeliveryProductDTO(e entity.MarketingDeliveryProduct) MarketingD
|
|||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,17 +327,9 @@ func groupDeliveryProducts(products []MarketingDeliveryProductDTO, soNumber stri
|
|||||||
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,50 +541,17 @@ 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
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
return totalWeight, totalPrice
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,10 +831,13 @@ 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 {
|
||||||
|
totalPeti := totalWeight / *weightPerConvertion
|
||||||
|
totalPrice = math.Round(totalPeti*unitPrice*100) / 100
|
||||||
return totalWeight, totalPrice
|
return totalWeight, totalPrice
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
totalPrice = math.Round(totalWeight*unitPrice*100) / 100
|
totalPrice = math.Round(totalWeight*unitPrice*100) / 100
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,6 @@ type DeliveryProduct struct {
|
|||||||
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,gte=0"`
|
|
||||||
TotalWeight *float64 `json:"total_weight" validate:"omitempty,gte=0"`
|
|
||||||
TotalPrice *float64 `json:"total_price" validate:"omitempty,gte=0"`
|
|
||||||
DeliveryDate string `json:"delivery_date" validate:"omitempty,datetime=2006-01-02"`
|
DeliveryDate string `json:"delivery_date" validate:"omitempty,datetime=2006-01-02"`
|
||||||
VehicleNumber string `json:"vehicle_number" validate:"omitempty,max=50"`
|
VehicleNumber string `json:"vehicle_number" validate:"omitempty,max=50"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -419,11 +419,6 @@ func (s *chickinService) CreateOne(c *fiber.Ctx, req *validation.Create) ([]enti
|
|||||||
if len(result) == 0 {
|
if len(result) == 0 {
|
||||||
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load created chickins")
|
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to load created chickins")
|
||||||
}
|
}
|
||||||
invalidateFromDate := time.Time{}
|
|
||||||
for i := range result {
|
|
||||||
invalidateFromDate = commonSvc.MinNonZeroDateOnlyUTC(invalidateFromDate, result[i].ChickInDate)
|
|
||||||
}
|
|
||||||
s.invalidateDepreciationSnapshots(c.Context(), nil, []uint{req.ProjectFlockKandangId}, invalidateFromDate)
|
|
||||||
|
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
@@ -467,8 +462,6 @@ func (s chickinService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
invalidateFromDate := commonSvc.MinNonZeroDateOnlyUTC(chickin.ChickInDate, updated.ChickInDate)
|
|
||||||
s.invalidateDepreciationSnapshots(c.Context(), nil, []uint{updated.ProjectFlockKandangId}, invalidateFromDate)
|
|
||||||
|
|
||||||
if updated.UsageQty > 0 {
|
if updated.UsageQty > 0 {
|
||||||
if err := s.syncChickinTraceForProductWarehouse(c.Context(), nil, updated.ProductWarehouseId); err != nil {
|
if err := s.syncChickinTraceForProductWarehouse(c.Context(), nil, updated.ProductWarehouseId); err != nil {
|
||||||
@@ -573,7 +566,6 @@ func (s chickinService) DeleteOne(c *fiber.Ctx, id uint) error {
|
|||||||
consumeAllocAfter,
|
consumeAllocAfter,
|
||||||
traceAllocAfter,
|
traceAllocAfter,
|
||||||
)
|
)
|
||||||
s.invalidateDepreciationSnapshots(c.Context(), tx, []uint{lockedChickin.ProjectFlockKandangId}, lockedChickin.ChickInDate)
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -1168,7 +1160,6 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit
|
|||||||
if action == entity.ApprovalActionApproved {
|
if action == entity.ApprovalActionApproved {
|
||||||
step = utils.ChickinStepDisetujui
|
step = utils.ChickinStepDisetujui
|
||||||
}
|
}
|
||||||
invalidateFromByPFK := make(map[uint]time.Time, len(approvableIDs))
|
|
||||||
|
|
||||||
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error {
|
err = s.Repository.DB().WithContext(c.Context()).Transaction(func(dbTransaction *gorm.DB) error {
|
||||||
if err := s.ensurePopulationRouteScope(c.Context(), dbTransaction); err != nil {
|
if err := s.ensurePopulationRouteScope(c.Context(), dbTransaction); err != nil {
|
||||||
@@ -1213,12 +1204,6 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to get chickins for approval %d", approvableID))
|
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to get chickins for approval %d", approvableID))
|
||||||
}
|
}
|
||||||
for _, chickin := range chickins {
|
|
||||||
invalidateFromByPFK[approvableID] = commonSvc.MinNonZeroDateOnlyUTC(
|
|
||||||
invalidateFromByPFK[approvableID],
|
|
||||||
chickin.ChickInDate,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
kandangForApproval, err := s.ProjectflockKandangRepo.GetByID(c.Context(), approvableID)
|
kandangForApproval, err := s.ProjectflockKandangRepo.GetByID(c.Context(), approvableID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1296,12 +1281,6 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to get pending chickins for rejection %d", approvableID))
|
return fiber.NewError(fiber.StatusInternalServerError, fmt.Sprintf("Failed to get pending chickins for rejection %d", approvableID))
|
||||||
}
|
}
|
||||||
for _, chickin := range chickins {
|
|
||||||
invalidateFromByPFK[approvableID] = commonSvc.MinNonZeroDateOnlyUTC(
|
|
||||||
invalidateFromByPFK[approvableID],
|
|
||||||
chickin.ChickInDate,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(chickins) == 0 {
|
if len(chickins) == 0 {
|
||||||
continue
|
continue
|
||||||
@@ -1349,9 +1328,6 @@ func (s chickinService) Approval(c *fiber.Ctx, req *validation.Approve) ([]entit
|
|||||||
}
|
}
|
||||||
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to record approval")
|
return nil, fiber.NewError(fiber.StatusInternalServerError, "Failed to record approval")
|
||||||
}
|
}
|
||||||
for projectFlockKandangID, invalidateFromDate := range invalidateFromByPFK {
|
|
||||||
s.invalidateDepreciationSnapshots(c.Context(), nil, []uint{projectFlockKandangID}, invalidateFromDate)
|
|
||||||
}
|
|
||||||
|
|
||||||
updated := make([]entity.ProjectChickin, 0)
|
updated := make([]entity.ProjectChickin, 0)
|
||||||
for _, kandangID := range approvableIDs {
|
for _, kandangID := range approvableIDs {
|
||||||
@@ -1861,57 +1837,6 @@ func normalizeDateOnlyUTC(value time.Time) time.Time {
|
|||||||
return time.Date(value.UTC().Year(), value.UTC().Month(), value.UTC().Day(), 0, 0, 0, 0, time.UTC)
|
return time.Date(value.UTC().Year(), value.UTC().Month(), value.UTC().Day(), 0, 0, 0, 0, time.UTC)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s chickinService) invalidateDepreciationSnapshots(
|
|
||||||
ctx context.Context,
|
|
||||||
tx *gorm.DB,
|
|
||||||
projectFlockKandangIDs []uint,
|
|
||||||
fromDate time.Time,
|
|
||||||
) {
|
|
||||||
if fromDate.IsZero() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
projectFlockKandangIDs = uniqueUint(projectFlockKandangIDs)
|
|
||||||
if len(projectFlockKandangIDs) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
targetDB := s.Repository.DB()
|
|
||||||
if tx != nil {
|
|
||||||
targetDB = tx
|
|
||||||
}
|
|
||||||
|
|
||||||
farmIDs, err := commonSvc.ResolveProjectFlockIDsByProjectFlockKandangIDs(ctx, targetDB, projectFlockKandangIDs)
|
|
||||||
if err != nil {
|
|
||||||
s.Log.Warnf(
|
|
||||||
"Failed to resolve farm ids for chickin depreciation invalidation (pfk_ids=%v): %+v",
|
|
||||||
projectFlockKandangIDs,
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
farmIDs = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(farmIDs) == 0 {
|
|
||||||
if err := commonSvc.InvalidateFarmDepreciationSnapshotsFromDate(ctx, targetDB, nil, fromDate); err != nil {
|
|
||||||
s.Log.Warnf(
|
|
||||||
"Failed to invalidate depreciation snapshots globally (from=%s): %+v",
|
|
||||||
fromDate.Format("2006-01-02"),
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := commonSvc.InvalidateFarmDepreciationSnapshotsFromDate(ctx, targetDB, farmIDs, fromDate); err != nil {
|
|
||||||
s.Log.Warnf(
|
|
||||||
"Failed to invalidate depreciation snapshots (farm_ids=%v, from=%s): %+v",
|
|
||||||
farmIDs,
|
|
||||||
fromDate.Format("2006-01-02"),
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *chickinService) syncChickinTraceForProductWarehouse(ctx context.Context, tx *gorm.DB, productWarehouseID uint) error {
|
func (s *chickinService) syncChickinTraceForProductWarehouse(ctx context.Context, tx *gorm.DB, productWarehouseID uint) error {
|
||||||
if productWarehouseID == 0 {
|
if productWarehouseID == 0 {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -13,11 +13,11 @@ func ProjectFlockKandangRoutes(v1 fiber.Router, u user.UserService, s projectFlo
|
|||||||
ctrl := controller.NewProjectFlockKandangController(s)
|
ctrl := controller.NewProjectFlockKandangController(s)
|
||||||
|
|
||||||
route := v1.Group("/project-flock-kandangs")
|
route := v1.Group("/project-flock-kandangs")
|
||||||
// route.Use(m.Auth(u))
|
route.Use(m.Auth(u))
|
||||||
route.Get("/", ctrl.GetAll)
|
route.Get("/",m.RequirePermissions(m.P_ProjectFlockKandangsGetAll), ctrl.GetAll)
|
||||||
route.Get("/:id", m.RequirePermissions(m.P_ProjectFlockKandangsGetOne), ctrl.GetOne)
|
route.Get("/:id",m.RequirePermissions(m.P_ProjectFlockKandangsGetOne), ctrl.GetOne)
|
||||||
// route.Post("/:id/closing", m.RequirePermissions(m.PermissionProjectFlockClosing), ctrl.Closing)
|
// route.Post("/:id/closing", m.RequirePermissions(m.PermissionProjectFlockClosing), ctrl.Closing)
|
||||||
// route.Get("/:id/closing/check", m.RequirePermissions(m.PermissionProjectFlockClosing), ctrl.CheckClosing)
|
// route.Get("/:id/closing/check", m.RequirePermissions(m.PermissionProjectFlockClosing), ctrl.CheckClosing)
|
||||||
route.Post("/:id/closing", m.RequirePermissions(m.P_ProjectFlockKandangsClosing), ctrl.Closing)
|
route.Post("/:id/closing",m.RequirePermissions(m.P_ProjectFlockKandangsClosing), ctrl.Closing)
|
||||||
route.Get("/:id/closing/check", m.RequirePermissions(m.P_ProjectFlockKandangsCheckClosing), ctrl.CheckClosing)
|
route.Get("/:id/closing/check", m.RequirePermissions(m.P_ProjectFlockKandangsCheckClosing), ctrl.CheckClosing)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -517,14 +517,7 @@ func (s *recordingService) CreateOne(c *fiber.Ctx, req *validation.Create) (*ent
|
|||||||
return nil, transactionErr
|
return nil, transactionErr
|
||||||
}
|
}
|
||||||
|
|
||||||
created, err := s.GetOne(c, createdRecording.Id)
|
return s.GetOne(c, createdRecording.Id)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if created != nil {
|
|
||||||
s.invalidateDepreciationSnapshots(c.Context(), nil, created.ProjectFlockKandangId, created.RecordDatetime)
|
|
||||||
}
|
|
||||||
return created, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Recording, error) {
|
func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uint) (*entity.Recording, error) {
|
||||||
@@ -855,13 +848,6 @@ func (s recordingService) UpdateOne(c *fiber.Ctx, req *validation.Update, id uin
|
|||||||
if err := recordingutil.AttachProductionStandards(ctx, s.Repository.DB(), false, s.Log, updatedRecording); err != nil {
|
if err := recordingutil.AttachProductionStandards(ctx, s.Repository.DB(), false, s.Log, updatedRecording); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
invalidateFromDate := commonSvc.MinNonZeroDateOnlyUTC(recordingEntity.RecordDatetime, updatedRecording.RecordDatetime)
|
|
||||||
s.invalidateDepreciationSnapshots(
|
|
||||||
c.Context(),
|
|
||||||
nil,
|
|
||||||
updatedRecording.ProjectFlockKandangId,
|
|
||||||
invalidateFromDate,
|
|
||||||
)
|
|
||||||
return updatedRecording, nil
|
return updatedRecording, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -979,12 +965,6 @@ func (s recordingService) Approval(c *fiber.Ctx, req *validation.Approve) ([]ent
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
s.invalidateDepreciationSnapshots(
|
|
||||||
c.Context(),
|
|
||||||
nil,
|
|
||||||
recording.ProjectFlockKandangId,
|
|
||||||
recording.RecordDatetime,
|
|
||||||
)
|
|
||||||
updated = append(updated, *recording)
|
updated = append(updated, *recording)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1005,7 +985,7 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error {
|
|||||||
}
|
}
|
||||||
note := recordingutil.RecordingNote("Delete", id)
|
note := recordingutil.RecordingNote("Delete", id)
|
||||||
|
|
||||||
err = s.Repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
return s.Repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
recording, err := s.Repository.WithTx(tx).GetByID(ctx, id, nil)
|
recording, err := s.Repository.WithTx(tx).GetByID(ctx, id, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
@@ -1049,60 +1029,9 @@ func (s recordingService) DeleteOne(c *fiber.Ctx, id uint) error {
|
|||||||
s.Log.Errorf("Failed to recalculate recordings after delete: %+v", err)
|
s.Log.Errorf("Failed to recalculate recordings after delete: %+v", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
s.invalidateDepreciationSnapshots(ctx, tx, recording.ProjectFlockKandangId, recording.RecordDatetime)
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s recordingService) invalidateDepreciationSnapshots(
|
|
||||||
ctx context.Context,
|
|
||||||
tx *gorm.DB,
|
|
||||||
projectFlockKandangID uint,
|
|
||||||
fromDate time.Time,
|
|
||||||
) {
|
|
||||||
if projectFlockKandangID == 0 || fromDate.IsZero() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
targetDB := s.Repository.DB()
|
|
||||||
if tx != nil {
|
|
||||||
targetDB = tx
|
|
||||||
}
|
|
||||||
|
|
||||||
farmIDs, err := commonSvc.ResolveProjectFlockIDsByProjectFlockKandangIDs(ctx, targetDB, []uint{projectFlockKandangID})
|
|
||||||
if err != nil {
|
|
||||||
s.Log.Warnf(
|
|
||||||
"Failed to resolve farm for recording depreciation invalidation (pfk=%d): %+v",
|
|
||||||
projectFlockKandangID,
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
farmIDs = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(farmIDs) == 0 {
|
|
||||||
if err := commonSvc.InvalidateFarmDepreciationSnapshotsFromDate(ctx, targetDB, nil, fromDate); err != nil {
|
|
||||||
s.Log.Warnf(
|
|
||||||
"Failed to invalidate depreciation snapshots globally (from=%s): %+v",
|
|
||||||
fromDate.Format("2006-01-02"),
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := commonSvc.InvalidateFarmDepreciationSnapshotsFromDate(ctx, targetDB, farmIDs, fromDate); err != nil {
|
|
||||||
s.Log.Warnf(
|
|
||||||
"Failed to invalidate depreciation snapshots (farm_ids=%v, from=%s): %+v",
|
|
||||||
farmIDs,
|
|
||||||
fromDate.Format("2006-01-02"),
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *recordingService) resolveRecordingCategory(ctx context.Context, recording *entity.Recording) (string, error) {
|
func (s *recordingService) resolveRecordingCategory(ctx context.Context, recording *entity.Recording) (string, error) {
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ type Update struct {
|
|||||||
|
|
||||||
type Query struct {
|
type Query struct {
|
||||||
Page int `query:"page" validate:"omitempty,number,min=1"`
|
Page int `query:"page" validate:"omitempty,number,min=1"`
|
||||||
Limit int `query:"limit" validate:"omitempty,number,min=1"`
|
Limit int `query:"limit" validate:"omitempty,number,min=1,max=100"`
|
||||||
Offset int `query:"-" validate:"omitempty,number,min=0"`
|
Offset int `query:"-" validate:"omitempty,number,min=0"`
|
||||||
ProjectFlockKandangId uint `query:"project_flock_kandang_id" validate:"omitempty,number,min=1"`
|
ProjectFlockKandangId uint `query:"project_flock_kandang_id" validate:"omitempty,number,min=1"`
|
||||||
Search string `query:"search" validate:"omitempty,max=50"`
|
Search string `query:"search" validate:"omitempty,max=50"`
|
||||||
|
|||||||
@@ -377,7 +377,6 @@ func (s *transferLayingService) CreateOne(c *fiber.Ctx, req *validation.Create)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
s.invalidateDepreciationSnapshots(c.Context(), nil, []uint{req.TargetProjectFlockId}, transferDate)
|
|
||||||
return laying_transfer, nil
|
return laying_transfer, nil
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -589,13 +588,6 @@ func (s *transferLayingService) UpdateOne(c *fiber.Ctx, req *validation.Update,
|
|||||||
}
|
}
|
||||||
|
|
||||||
layingTransfer, _, err := s.GetOne(c, id)
|
layingTransfer, _, err := s.GetOne(c, id)
|
||||||
invalidateFromDate := commonSvc.MinNonZeroDateOnlyUTC(existingTransfer.TransferDate, transferDate)
|
|
||||||
s.invalidateDepreciationSnapshots(
|
|
||||||
c.Context(),
|
|
||||||
nil,
|
|
||||||
[]uint{existingTransfer.ToProjectFlockId, req.TargetProjectFlockId},
|
|
||||||
invalidateFromDate,
|
|
||||||
)
|
|
||||||
|
|
||||||
return layingTransfer, err
|
return layingTransfer, err
|
||||||
}
|
}
|
||||||
@@ -669,7 +661,6 @@ func (s transferLayingService) DeleteOne(c *fiber.Ctx, id uint) error {
|
|||||||
s.Log.Errorf("Failed to delete transferLaying: %+v", err)
|
s.Log.Errorf("Failed to delete transferLaying: %+v", err)
|
||||||
return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete transfer laying")
|
return fiber.NewError(fiber.StatusInternalServerError, "Failed to delete transfer laying")
|
||||||
}
|
}
|
||||||
s.invalidateDepreciationSnapshots(c.Context(), nil, []uint{transfer.ToProjectFlockId}, transfer.TransferDate)
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -807,14 +798,6 @@ func (s transferLayingService) Approval(c *fiber.Ctx, req *validation.Approve) (
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if transfer != nil {
|
|
||||||
s.invalidateDepreciationSnapshots(
|
|
||||||
c.Context(),
|
|
||||||
nil,
|
|
||||||
[]uint{transfer.ToProjectFlockId},
|
|
||||||
resolveDepreciationEffectiveDateForTransfer(transfer),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
updated = append(updated, *transfer)
|
updated = append(updated, *transfer)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -854,14 +837,6 @@ func (s transferLayingService) Execute(c *fiber.Ctx, id uint) (*entity.LayingTra
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if transfer != nil {
|
|
||||||
s.invalidateDepreciationSnapshots(
|
|
||||||
c.Context(),
|
|
||||||
nil,
|
|
||||||
[]uint{transfer.ToProjectFlockId},
|
|
||||||
resolveDepreciationEffectiveDateForTransfer(transfer),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return transfer, nil
|
return transfer, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -898,14 +873,6 @@ func (s transferLayingService) ExecuteWithBusinessDate(c *fiber.Ctx, id uint, bu
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if transfer != nil {
|
|
||||||
s.invalidateDepreciationSnapshots(
|
|
||||||
c.Context(),
|
|
||||||
nil,
|
|
||||||
[]uint{transfer.ToProjectFlockId},
|
|
||||||
resolveDepreciationEffectiveDateForTransfer(transfer),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return transfer, nil
|
return transfer, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1259,14 +1226,6 @@ func (s transferLayingService) Unexecute(c *fiber.Ctx, id uint) (*entity.LayingT
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if transfer != nil {
|
|
||||||
s.invalidateDepreciationSnapshots(
|
|
||||||
c.Context(),
|
|
||||||
nil,
|
|
||||||
[]uint{transfer.ToProjectFlockId},
|
|
||||||
resolveDepreciationEffectiveDateForTransfer(transfer),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return transfer, nil
|
return transfer, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1719,43 +1678,6 @@ func normalizeDateOnlyUTC(value time.Time) time.Time {
|
|||||||
return time.Date(value.UTC().Year(), value.UTC().Month(), value.UTC().Day(), 0, 0, 0, 0, time.UTC)
|
return time.Date(value.UTC().Year(), value.UTC().Month(), value.UTC().Day(), 0, 0, 0, 0, time.UTC)
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveDepreciationEffectiveDateForTransfer(transfer *entity.LayingTransfer) time.Time {
|
|
||||||
if transfer == nil {
|
|
||||||
return time.Time{}
|
|
||||||
}
|
|
||||||
if transfer.EffectiveMoveDate != nil && !transfer.EffectiveMoveDate.IsZero() {
|
|
||||||
return *transfer.EffectiveMoveDate
|
|
||||||
}
|
|
||||||
if transfer.EconomicCutoffDate != nil && !transfer.EconomicCutoffDate.IsZero() {
|
|
||||||
return *transfer.EconomicCutoffDate
|
|
||||||
}
|
|
||||||
return transfer.TransferDate
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s transferLayingService) invalidateDepreciationSnapshots(
|
|
||||||
ctx context.Context,
|
|
||||||
tx *gorm.DB,
|
|
||||||
farmIDs []uint,
|
|
||||||
fromDate time.Time,
|
|
||||||
) {
|
|
||||||
if fromDate.IsZero() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
targetDB := s.Repository.DB()
|
|
||||||
if tx != nil {
|
|
||||||
targetDB = tx
|
|
||||||
}
|
|
||||||
uniqueFarmIDs := utils.UniqueUintSlice(farmIDs)
|
|
||||||
if err := commonSvc.InvalidateFarmDepreciationSnapshotsFromDate(ctx, targetDB, uniqueFarmIDs, fromDate); err != nil {
|
|
||||||
s.Log.Warnf(
|
|
||||||
"Failed to invalidate farm depreciation snapshots (farms=%v, from=%s): %+v",
|
|
||||||
uniqueFarmIDs,
|
|
||||||
fromDate.Format("2006-01-02"),
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func isLegacyTransfer(transfer *entity.LayingTransfer) bool {
|
func isLegacyTransfer(transfer *entity.LayingTransfer) bool {
|
||||||
if transfer == nil {
|
if transfer == nil {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -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))
|
}
|
||||||
if req.Week > 0 && req.Week != computedWeek {
|
}
|
||||||
s.Log.WithFields(logrus.Fields{
|
weekBase := 1
|
||||||
"project_flock_kandang_id": req.ProjectFlockKandangId,
|
isLayingCategory := strings.EqualFold(category, string(utils.ProjectFlockCategoryLaying))
|
||||||
"uniform_date": uniformDate.Format("2006-01-02"),
|
if isLayingCategory {
|
||||||
"requested_week": req.Week,
|
weekBase = config.LayingWeekStart()
|
||||||
"computed_week": computedWeek,
|
}
|
||||||
}).Warn("Uniformity week mismatch detected; using computed week")
|
if req.Week < weekBase {
|
||||||
|
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))
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
|
||||||
s.Log.WithFields(logrus.Fields{
|
|
||||||
"uniformity_id": id,
|
|
||||||
"project_flock_kandang_id": targetPFKID,
|
|
||||||
"uniform_date": targetDate.Format("2006-01-02"),
|
|
||||||
"requested_week": *req.Week,
|
|
||||||
"computed_week": computedWeek,
|
|
||||||
}).Warn("Uniformity week mismatch detected on update; using computed week")
|
|
||||||
}
|
}
|
||||||
updateBody["week"] = computedWeek
|
}
|
||||||
if err := s.ensureUniqueUniformity(c.Context(), id, targetPFKID, computedWeek); err != nil {
|
weekBase := 1
|
||||||
|
isLayingCategory := strings.EqualFold(category, string(utils.ProjectFlockCategoryLaying))
|
||||||
|
if isLayingCategory {
|
||||||
|
weekBase = config.LayingWeekStart()
|
||||||
|
}
|
||||||
|
if targetWeek < weekBase {
|
||||||
|
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 = parsedWeek
|
|
||||||
|
week, err := strconv.Atoi(weekStr)
|
||||||
|
if err != nil || week <= 0 {
|
||||||
|
return nil, nil, fiber.NewError(fiber.StatusBadRequest, "week is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
file, err := c.FormFile("document")
|
file, err := c.FormFile("document")
|
||||||
|
|||||||
@@ -248,7 +248,7 @@ func (r *PurchaseRepositoryImpl) UpdateReceivingDetails(
|
|||||||
if upd.VehicleNumber != nil {
|
if upd.VehicleNumber != nil {
|
||||||
data["vehicle_number"] = upd.VehicleNumber
|
data["vehicle_number"] = upd.VehicleNumber
|
||||||
} else if upd.ClearVehicleNumber {
|
} else if upd.ClearVehicleNumber {
|
||||||
data["vehicle_number"] = ""
|
data["vehicle_number"] = gorm.Expr("NULL")
|
||||||
}
|
}
|
||||||
if upd.WarehouseID != nil && *upd.WarehouseID != 0 {
|
if upd.WarehouseID != nil && *upd.WarehouseID != 0 {
|
||||||
data["warehouse_id"] = upd.WarehouseID
|
data["warehouse_id"] = upd.WarehouseID
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -47,6 +48,7 @@ type PurchaseService interface {
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
priceTolerance = 0.0001
|
priceTolerance = 0.0001
|
||||||
|
purchaseVehicleNumberMaxLength = 15
|
||||||
)
|
)
|
||||||
|
|
||||||
type purchaseService struct {
|
type purchaseService struct {
|
||||||
@@ -675,12 +677,6 @@ func (s *purchaseService) CreateOne(c *fiber.Ctx, req *validation.CreatePurchase
|
|||||||
if err := s.attachLatestApproval(c.Context(), created); err != nil {
|
if err := s.attachLatestApproval(c.Context(), created); err != nil {
|
||||||
s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", created.Id, err)
|
s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", created.Id, err)
|
||||||
}
|
}
|
||||||
s.invalidateDepreciationSnapshots(
|
|
||||||
c.Context(),
|
|
||||||
nil,
|
|
||||||
collectPFKIDsFromPurchase(created),
|
|
||||||
resolvePurchaseDepreciationInvalidateDate(created, created.Items, now),
|
|
||||||
)
|
|
||||||
|
|
||||||
return created, nil
|
return created, nil
|
||||||
}
|
}
|
||||||
@@ -832,12 +828,6 @@ func (s *purchaseService) ApproveStaffPurchase(c *fiber.Ctx, id uint, req *valid
|
|||||||
if err := s.attachLatestApproval(c.Context(), updated); err != nil {
|
if err := s.attachLatestApproval(c.Context(), updated); err != nil {
|
||||||
s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err)
|
s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err)
|
||||||
}
|
}
|
||||||
s.invalidateDepreciationSnapshots(
|
|
||||||
c.Context(),
|
|
||||||
nil,
|
|
||||||
collectPFKIDsFromPurchase(updated),
|
|
||||||
resolvePurchaseDepreciationInvalidateDate(updated, updated.Items, time.Now().UTC()),
|
|
||||||
)
|
|
||||||
|
|
||||||
return updated, nil
|
return updated, nil
|
||||||
}
|
}
|
||||||
@@ -946,12 +936,6 @@ func (s *purchaseService) ApproveManagerPurchase(c *fiber.Ctx, id uint, req *val
|
|||||||
if err := s.attachLatestApproval(c.Context(), updated); err != nil {
|
if err := s.attachLatestApproval(c.Context(), updated); err != nil {
|
||||||
s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err)
|
s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err)
|
||||||
}
|
}
|
||||||
s.invalidateDepreciationSnapshots(
|
|
||||||
c.Context(),
|
|
||||||
nil,
|
|
||||||
collectPFKIDsFromPurchase(updated),
|
|
||||||
resolvePurchaseDepreciationInvalidateDate(updated, updated.Items, now),
|
|
||||||
)
|
|
||||||
|
|
||||||
return updated, nil
|
return updated, nil
|
||||||
}
|
}
|
||||||
@@ -1176,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
|
||||||
@@ -1439,16 +1432,6 @@ func (s *purchaseService) ReceiveProducts(c *fiber.Ctx, id uint, req *validation
|
|||||||
if err := s.attachLatestApproval(c.Context(), updated); err != nil {
|
if err := s.attachLatestApproval(c.Context(), updated); err != nil {
|
||||||
s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err)
|
s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err)
|
||||||
}
|
}
|
||||||
invalidateFromDate := resolvePurchaseDepreciationInvalidateDate(updated, updated.Items, time.Now().UTC())
|
|
||||||
if earliestReceived != nil {
|
|
||||||
invalidateFromDate = commonSvc.MinNonZeroDateOnlyUTC(invalidateFromDate, *earliestReceived)
|
|
||||||
}
|
|
||||||
s.invalidateDepreciationSnapshots(
|
|
||||||
c.Context(),
|
|
||||||
nil,
|
|
||||||
collectPFKIDsFromPurchase(updated),
|
|
||||||
invalidateFromDate,
|
|
||||||
)
|
|
||||||
|
|
||||||
receivingPayloads := make([]ExpenseReceivingPayload, 0, len(prepared))
|
receivingPayloads := make([]ExpenseReceivingPayload, 0, len(prepared))
|
||||||
for _, prep := range prepared {
|
for _, prep := range prepared {
|
||||||
@@ -1656,12 +1639,6 @@ func (s *purchaseService) DeleteItems(c *fiber.Ctx, id uint, req *validation.Del
|
|||||||
if err := s.attachLatestApproval(ctx, updated); err != nil {
|
if err := s.attachLatestApproval(ctx, updated); err != nil {
|
||||||
s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err)
|
s.Log.Warnf("Unable to attach latest approval for purchase %d: %+v", updated.Id, err)
|
||||||
}
|
}
|
||||||
s.invalidateDepreciationSnapshots(
|
|
||||||
ctx,
|
|
||||||
nil,
|
|
||||||
collectPFKIDsFromPurchaseItems(itemsToDelete),
|
|
||||||
resolvePurchaseDepreciationInvalidateDate(purchase, itemsToDelete, time.Now().UTC()),
|
|
||||||
)
|
|
||||||
|
|
||||||
return updated, nil
|
return updated, nil
|
||||||
}
|
}
|
||||||
@@ -1755,12 +1732,6 @@ func (s *purchaseService) DeletePurchase(c *fiber.Ctx, id uint) error {
|
|||||||
return utils.Internal("Failed to sync expense")
|
return utils.Internal("Failed to sync expense")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
s.invalidateDepreciationSnapshots(
|
|
||||||
ctx,
|
|
||||||
nil,
|
|
||||||
collectPFKIDsFromPurchaseItems(itemsToDelete),
|
|
||||||
resolvePurchaseDepreciationInvalidateDate(purchase, itemsToDelete, time.Now().UTC()),
|
|
||||||
)
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -2431,17 +2402,7 @@ func (s *purchaseService) rejectAndReload(
|
|||||||
if err := s.createPurchaseApproval(c.Context(), nil, purchaseID, step, entity.ApprovalActionRejected, actorID, notes, false); err != nil {
|
if err := s.createPurchaseApproval(c.Context(), nil, purchaseID, step, entity.ApprovalActionRejected, actorID, notes, false); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
updated, err := s.loadPurchase(c.Context(), purchaseID)
|
return s.loadPurchase(c.Context(), purchaseID)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
s.invalidateDepreciationSnapshots(
|
|
||||||
c.Context(),
|
|
||||||
nil,
|
|
||||||
collectPFKIDsFromPurchase(updated),
|
|
||||||
resolvePurchaseDepreciationInvalidateDate(updated, updated.Items, time.Now().UTC()),
|
|
||||||
)
|
|
||||||
return updated, nil
|
|
||||||
}
|
}
|
||||||
func (s *purchaseService) loadPurchase(
|
func (s *purchaseService) loadPurchase(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
@@ -2572,17 +2533,10 @@ func (s *purchaseService) resolveChickinLockedItemIDsByItemID(ctx context.Contex
|
|||||||
}
|
}
|
||||||
|
|
||||||
func collectPFKIDsFromPurchase(p *entity.Purchase) []uint {
|
func collectPFKIDsFromPurchase(p *entity.Purchase) []uint {
|
||||||
if p == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return collectPFKIDsFromPurchaseItems(p.Items)
|
|
||||||
}
|
|
||||||
|
|
||||||
func collectPFKIDsFromPurchaseItems(items []entity.PurchaseItem) []uint {
|
|
||||||
seen := make(map[uint]struct{})
|
seen := make(map[uint]struct{})
|
||||||
ids := make([]uint, 0)
|
ids := make([]uint, 0)
|
||||||
|
|
||||||
for _, item := range items {
|
for _, item := range p.Items {
|
||||||
if item.ProjectFlockKandangId == nil || *item.ProjectFlockKandangId == 0 {
|
if item.ProjectFlockKandangId == nil || *item.ProjectFlockKandangId == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -2595,82 +2549,6 @@ func collectPFKIDsFromPurchaseItems(items []entity.PurchaseItem) []uint {
|
|||||||
}
|
}
|
||||||
return ids
|
return ids
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolvePurchaseDepreciationInvalidateDate(
|
|
||||||
purchase *entity.Purchase,
|
|
||||||
items []entity.PurchaseItem,
|
|
||||||
fallback time.Time,
|
|
||||||
) time.Time {
|
|
||||||
fromDate := time.Time{}
|
|
||||||
if purchase != nil {
|
|
||||||
fromDate = commonSvc.MinNonZeroDateOnlyUTC(fromDate, purchase.CreatedAt)
|
|
||||||
if purchase.PoDate != nil {
|
|
||||||
fromDate = commonSvc.MinNonZeroDateOnlyUTC(fromDate, *purchase.PoDate)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, item := range items {
|
|
||||||
if item.ReceivedDate == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
fromDate = commonSvc.MinNonZeroDateOnlyUTC(fromDate, *item.ReceivedDate)
|
|
||||||
}
|
|
||||||
if fromDate.IsZero() {
|
|
||||||
fromDate = fallback
|
|
||||||
}
|
|
||||||
return fromDate
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *purchaseService) invalidateDepreciationSnapshots(
|
|
||||||
ctx context.Context,
|
|
||||||
tx *gorm.DB,
|
|
||||||
projectFlockKandangIDs []uint,
|
|
||||||
fromDate time.Time,
|
|
||||||
) {
|
|
||||||
if fromDate.IsZero() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
projectFlockKandangIDs = utils.UniqueUintSlice(projectFlockKandangIDs)
|
|
||||||
|
|
||||||
targetDB := s.PurchaseRepo.DB()
|
|
||||||
if tx != nil {
|
|
||||||
targetDB = tx
|
|
||||||
}
|
|
||||||
|
|
||||||
var farmIDs []uint
|
|
||||||
if len(projectFlockKandangIDs) > 0 {
|
|
||||||
resolvedFarmIDs, err := commonSvc.ResolveProjectFlockIDsByProjectFlockKandangIDs(ctx, targetDB, projectFlockKandangIDs)
|
|
||||||
if err != nil {
|
|
||||||
s.Log.Warnf(
|
|
||||||
"Failed to resolve farm ids for purchase depreciation invalidation (pfk_ids=%v): %+v",
|
|
||||||
projectFlockKandangIDs,
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
farmIDs = resolvedFarmIDs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(farmIDs) == 0 {
|
|
||||||
if err := commonSvc.InvalidateFarmDepreciationSnapshotsFromDate(ctx, targetDB, nil, fromDate); err != nil {
|
|
||||||
s.Log.Warnf(
|
|
||||||
"Failed to invalidate depreciation snapshots globally (from=%s): %+v",
|
|
||||||
fromDate.Format("2006-01-02"),
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := commonSvc.InvalidateFarmDepreciationSnapshotsFromDate(ctx, targetDB, farmIDs, fromDate); err != nil {
|
|
||||||
s.Log.Warnf(
|
|
||||||
"Failed to invalidate depreciation snapshots (farm_ids=%v, from=%s): %+v",
|
|
||||||
farmIDs,
|
|
||||||
fromDate.Format("2006-01-02"),
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *purchaseService) ensureProjectFlockNotClosedForPurchase(
|
func (s *purchaseService) ensureProjectFlockNotClosedForPurchase(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
purchase *entity.Purchase,
|
purchase *entity.Purchase,
|
||||||
|
|||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -90,75 +90,6 @@ func (c *RepportController) GetExpense(ctx *fiber.Ctx) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *RepportController) GetExpenseDepreciation(ctx *fiber.Ctx) error {
|
|
||||||
rows, meta, err := c.RepportService.GetExpenseDepreciation(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := struct {
|
|
||||||
Code int `json:"code"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
Meta dto.ExpenseDepreciationMetaDTO `json:"meta"`
|
|
||||||
Data []dto.ExpenseDepreciationRowDTO `json:"data"`
|
|
||||||
}{
|
|
||||||
Code: fiber.StatusOK,
|
|
||||||
Status: "success",
|
|
||||||
Message: "Get expense depreciation report successfully",
|
|
||||||
Meta: *meta,
|
|
||||||
Data: rows,
|
|
||||||
}
|
|
||||||
|
|
||||||
return ctx.Status(fiber.StatusOK).JSON(resp)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *RepportController) GetExpenseDepreciationManualInputs(ctx *fiber.Ctx) error {
|
|
||||||
rows, meta, err := c.RepportService.GetExpenseDepreciationManualInputs(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
resp := struct {
|
|
||||||
Code int `json:"code"`
|
|
||||||
Status string `json:"status"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
Meta dto.ExpenseDepreciationMetaDTO `json:"meta"`
|
|
||||||
Data []dto.ExpenseDepreciationManualInputRowDTO `json:"data"`
|
|
||||||
}{
|
|
||||||
Code: fiber.StatusOK,
|
|
||||||
Status: "success",
|
|
||||||
Message: "Get expense depreciation manual inputs successfully",
|
|
||||||
Meta: *meta,
|
|
||||||
Data: rows,
|
|
||||||
}
|
|
||||||
|
|
||||||
return ctx.Status(fiber.StatusOK).JSON(resp)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *RepportController) UpsertExpenseDepreciationManualInput(ctx *fiber.Ctx) error {
|
|
||||||
req := new(validation.ExpenseDepreciationManualInputUpsert)
|
|
||||||
if err := ctx.BodyParser(req); err != nil {
|
|
||||||
return fiber.NewError(fiber.StatusBadRequest, "Invalid request body")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := m.EnsureProjectFlockAccess(ctx, c.RepportService.DB(), req.ProjectFlockID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := c.RepportService.UpsertExpenseDepreciationManualInput(ctx, req)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return ctx.Status(fiber.StatusOK).JSON(response.Success{
|
|
||||||
Code: fiber.StatusOK,
|
|
||||||
Status: "success",
|
|
||||||
Message: "Upsert expense depreciation manual input successfully",
|
|
||||||
Data: result,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *RepportController) GetMarketing(ctx *fiber.Ctx) error {
|
func (c *RepportController) GetMarketing(ctx *fiber.Ctx) error {
|
||||||
query := &validation.MarketingQuery{
|
query := &validation.MarketingQuery{
|
||||||
Page: ctx.QueryInt("page", 1),
|
Page: ctx.QueryInt("page", 1),
|
||||||
@@ -498,29 +429,6 @@ func (c *RepportController) GetProductionResult(ctx *fiber.Ctx) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *RepportController) GetHppV2Breakdown(ctx *fiber.Ctx) error {
|
|
||||||
query := &validation.HppV2BreakdownQuery{
|
|
||||||
ProjectFlockKandangID: uint(ctx.QueryInt("project_flock_kandang_id", 0)),
|
|
||||||
Period: ctx.Query("period", ""),
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := m.EnsureProjectFlockKandangAccess(ctx, c.RepportService.DB(), 0, query.ProjectFlockKandangID); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := c.RepportService.GetHppV2Breakdown(ctx, query)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return ctx.Status(fiber.StatusOK).JSON(response.Success{
|
|
||||||
Code: fiber.StatusOK,
|
|
||||||
Status: "success",
|
|
||||||
Message: "Get HPP v2 breakdown successfully",
|
|
||||||
Data: data,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseCommaSeparatedInt64s(raw string) ([]int64, error) {
|
func parseCommaSeparatedInt64s(raw string) ([]int64, error) {
|
||||||
return parseCommaSeparatedInt64sWithField(raw, "supplier_ids")
|
return parseCommaSeparatedInt64sWithField(raw, "supplier_ids")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
package dto
|
|
||||||
|
|
||||||
type ExpenseDepreciationFiltersDTO struct {
|
|
||||||
AreaID string `json:"area_id"`
|
|
||||||
LocationID string `json:"location_id"`
|
|
||||||
ProjectFlockID string `json:"project_flock_id"`
|
|
||||||
Period string `json:"period"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ExpenseDepreciationMetaDTO struct {
|
|
||||||
Page int `json:"page"`
|
|
||||||
Limit int `json:"limit"`
|
|
||||||
TotalPages int64 `json:"total_pages"`
|
|
||||||
TotalResults int64 `json:"total_results"`
|
|
||||||
Filters ExpenseDepreciationFiltersDTO `json:"filters"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ExpenseDepreciationRowDTO struct {
|
|
||||||
ProjectFlockID int64 `json:"project_flock_id"`
|
|
||||||
FarmName string `json:"farm_name"`
|
|
||||||
Period string `json:"period"`
|
|
||||||
DepreciationPercentEffective float64 `json:"depreciation_percent_effective"`
|
|
||||||
DepreciationValue float64 `json:"depreciation_value"`
|
|
||||||
PulletCostDayNTotal float64 `json:"pullet_cost_day_n_total"`
|
|
||||||
Components any `json:"components"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ExpenseDepreciationManualInputRowDTO struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
ProjectFlockID int64 `json:"project_flock_id"`
|
|
||||||
FarmName string `json:"farm_name"`
|
|
||||||
TotalCost float64 `json:"total_cost"`
|
|
||||||
CutoverDate string `json:"cutover_date"`
|
|
||||||
Note *string `json:"note"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewExpenseDepreciationFiltersDTO(area, location, projectFlockID, period string) ExpenseDepreciationFiltersDTO {
|
|
||||||
return ExpenseDepreciationFiltersDTO{
|
|
||||||
AreaID: area,
|
|
||||||
LocationID: location,
|
|
||||||
ProjectFlockID: projectFlockID,
|
|
||||||
Period: period,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -33,11 +33,9 @@ func (RepportModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *
|
|||||||
recordingRepository := recordingRepo.NewRecordingRepository(db)
|
recordingRepository := recordingRepo.NewRecordingRepository(db)
|
||||||
approvalRepository := commonRepo.NewApprovalRepository(db)
|
approvalRepository := commonRepo.NewApprovalRepository(db)
|
||||||
hppCostRepository := commonRepo.NewHppCostRepository(db)
|
hppCostRepository := commonRepo.NewHppCostRepository(db)
|
||||||
hppV2CostRepository := commonRepo.NewHppV2CostRepository(db)
|
|
||||||
purchaseSupplierRepository := repportRepo.NewPurchaseSupplierRepository(db)
|
purchaseSupplierRepository := repportRepo.NewPurchaseSupplierRepository(db)
|
||||||
debtSupplierRepository := repportRepo.NewDebtSupplierRepository(db)
|
debtSupplierRepository := repportRepo.NewDebtSupplierRepository(db)
|
||||||
hppPerKandangRepository := repportRepo.NewHppPerKandangRepository(db)
|
hppPerKandangRepository := repportRepo.NewHppPerKandangRepository(db)
|
||||||
expenseDepreciationRepository := repportRepo.NewExpenseDepreciationRepository(db)
|
|
||||||
productionResultRepository := repportRepo.NewProductionResultRepository(db)
|
productionResultRepository := repportRepo.NewProductionResultRepository(db)
|
||||||
customerPaymentRepository := repportRepo.NewCustomerPaymentRepository(db)
|
customerPaymentRepository := repportRepo.NewCustomerPaymentRepository(db)
|
||||||
customerRepository := customerRepo.NewCustomerRepository(db)
|
customerRepository := customerRepo.NewCustomerRepository(db)
|
||||||
@@ -47,29 +45,7 @@ func (RepportModule) RegisterRoutes(router fiber.Router, db *gorm.DB, validate *
|
|||||||
|
|
||||||
approvalSvc := approvalService.NewApprovalService(approvalRepository)
|
approvalSvc := approvalService.NewApprovalService(approvalRepository)
|
||||||
hppSvc := approvalService.NewHppService(hppCostRepository)
|
hppSvc := approvalService.NewHppService(hppCostRepository)
|
||||||
hppV2Svc := approvalService.NewHppV2Service(hppV2CostRepository)
|
repportService := sRepport.NewRepportService(db, validate, expenseRealizationRepository, marketingDeliveryProductRepository, purchaseRepository, chickinRepository, recordingRepository, approvalSvc, hppSvc, purchaseSupplierRepository, debtSupplierRepository, hppPerKandangRepository, productionResultRepository, customerPaymentRepository, customerRepository, standardGrowthDetailRepository, productionStandardDetailRepository)
|
||||||
repportService := sRepport.NewRepportService(
|
|
||||||
db,
|
|
||||||
validate,
|
|
||||||
expenseRealizationRepository,
|
|
||||||
expenseDepreciationRepository,
|
|
||||||
marketingDeliveryProductRepository,
|
|
||||||
purchaseRepository,
|
|
||||||
chickinRepository,
|
|
||||||
recordingRepository,
|
|
||||||
approvalSvc,
|
|
||||||
hppSvc,
|
|
||||||
hppV2Svc,
|
|
||||||
hppCostRepository,
|
|
||||||
purchaseSupplierRepository,
|
|
||||||
debtSupplierRepository,
|
|
||||||
hppPerKandangRepository,
|
|
||||||
productionResultRepository,
|
|
||||||
customerPaymentRepository,
|
|
||||||
customerRepository,
|
|
||||||
standardGrowthDetailRepository,
|
|
||||||
productionStandardDetailRepository,
|
|
||||||
)
|
|
||||||
userService := sUser.NewUserService(userRepository, validate)
|
userService := sUser.NewUserService(userRepository, validate)
|
||||||
|
|
||||||
RepportRoutes(router, userService, repportService)
|
RepportRoutes(router, userService, repportService)
|
||||||
|
|||||||
@@ -1,329 +0,0 @@
|
|||||||
package repositories
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
|
||||||
"gitlab.com/mbugroup/lti-api.git/internal/utils"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"gorm.io/gorm/clause"
|
|
||||||
)
|
|
||||||
|
|
||||||
type FarmDepreciationCandidateRow struct {
|
|
||||||
ProjectFlockID uint
|
|
||||||
FarmName string
|
|
||||||
}
|
|
||||||
|
|
||||||
type FarmDepreciationLatestTransferRow struct {
|
|
||||||
ProjectFlockID uint
|
|
||||||
FarmName string
|
|
||||||
ProjectFlockKandangID uint
|
|
||||||
KandangID uint
|
|
||||||
KandangName string
|
|
||||||
HouseType *string
|
|
||||||
SourceProjectFlockID uint
|
|
||||||
TransferDate time.Time
|
|
||||||
TransferQty float64
|
|
||||||
TransferID uint
|
|
||||||
}
|
|
||||||
|
|
||||||
type FarmDepreciationManualInputRow struct {
|
|
||||||
Id uint
|
|
||||||
ProjectFlockID uint
|
|
||||||
FarmName string
|
|
||||||
TotalCost float64
|
|
||||||
CutoverDate time.Time
|
|
||||||
Note *string
|
|
||||||
}
|
|
||||||
|
|
||||||
type houseDepreciationPercentRow struct {
|
|
||||||
HouseType string
|
|
||||||
Day int
|
|
||||||
DepreciationPercent float64
|
|
||||||
}
|
|
||||||
|
|
||||||
type ExpenseDepreciationRepository interface {
|
|
||||||
GetCandidateFarms(ctx context.Context, period time.Time, areaIDs, locationIDs, projectFlockIDs []int64) ([]FarmDepreciationCandidateRow, error)
|
|
||||||
GetSnapshotsByPeriodAndFarmIDs(ctx context.Context, period time.Time, farmIDs []uint) ([]entity.FarmDepreciationSnapshot, error)
|
|
||||||
UpsertSnapshots(ctx context.Context, rows []entity.FarmDepreciationSnapshot) error
|
|
||||||
DeleteSnapshotsFromDate(ctx context.Context, fromDate time.Time, farmIDs []uint) error
|
|
||||||
GetLatestTransferInputsByFarms(ctx context.Context, period time.Time, farmIDs []uint) ([]FarmDepreciationLatestTransferRow, error)
|
|
||||||
GetDepreciationPercents(ctx context.Context, houseTypes []string, maxDay int) (map[string]map[int]float64, error)
|
|
||||||
GetLatestManualInputsByFarms(ctx context.Context, areaIDs, locationIDs, projectFlockIDs []int64) ([]FarmDepreciationManualInputRow, error)
|
|
||||||
UpsertManualInput(ctx context.Context, row *entity.FarmDepreciationManualInput) error
|
|
||||||
DB() *gorm.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
type expenseDepreciationRepository struct {
|
|
||||||
db *gorm.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewExpenseDepreciationRepository(db *gorm.DB) ExpenseDepreciationRepository {
|
|
||||||
return &expenseDepreciationRepository{db: db}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *expenseDepreciationRepository) DB() *gorm.DB {
|
|
||||||
return r.db
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *expenseDepreciationRepository) GetCandidateFarms(
|
|
||||||
ctx context.Context,
|
|
||||||
period time.Time,
|
|
||||||
areaIDs, locationIDs, projectFlockIDs []int64,
|
|
||||||
) ([]FarmDepreciationCandidateRow, error) {
|
|
||||||
rows := make([]FarmDepreciationCandidateRow, 0)
|
|
||||||
|
|
||||||
query := r.db.WithContext(ctx).
|
|
||||||
Table("project_flocks AS pf").
|
|
||||||
Select("DISTINCT pf.id AS project_flock_id, pf.flock_name AS farm_name").
|
|
||||||
Joins("JOIN project_flock_kandangs AS pfk ON pfk.project_flock_id = pf.id").
|
|
||||||
Where("pf.deleted_at IS NULL").
|
|
||||||
Where("pf.category = ?", utils.ProjectFlockCategoryLaying).
|
|
||||||
Where("(pfk.closed_at IS NULL OR DATE(pfk.closed_at) >= DATE(?))", period)
|
|
||||||
|
|
||||||
if len(areaIDs) > 0 {
|
|
||||||
query = query.Where("pf.area_id IN ?", areaIDs)
|
|
||||||
}
|
|
||||||
if len(locationIDs) > 0 {
|
|
||||||
query = query.Where("pf.location_id IN ?", locationIDs)
|
|
||||||
}
|
|
||||||
if len(projectFlockIDs) > 0 {
|
|
||||||
query = query.Where("pf.id IN ?", projectFlockIDs)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := query.Order("pf.id ASC").Scan(&rows).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return rows, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *expenseDepreciationRepository) GetSnapshotsByPeriodAndFarmIDs(
|
|
||||||
ctx context.Context,
|
|
||||||
period time.Time,
|
|
||||||
farmIDs []uint,
|
|
||||||
) ([]entity.FarmDepreciationSnapshot, error) {
|
|
||||||
if len(farmIDs) == 0 {
|
|
||||||
return []entity.FarmDepreciationSnapshot{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
rows := make([]entity.FarmDepreciationSnapshot, 0)
|
|
||||||
if err := r.db.WithContext(ctx).
|
|
||||||
Where("project_flock_id IN ?", farmIDs).
|
|
||||||
Where("period_date = DATE(?)", period).
|
|
||||||
Find(&rows).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return rows, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *expenseDepreciationRepository) UpsertSnapshots(ctx context.Context, rows []entity.FarmDepreciationSnapshot) error {
|
|
||||||
if len(rows) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return r.db.WithContext(ctx).
|
|
||||||
Clauses(clause.OnConflict{
|
|
||||||
Columns: []clause.Column{
|
|
||||||
{Name: "project_flock_id"},
|
|
||||||
{Name: "period_date"},
|
|
||||||
},
|
|
||||||
DoUpdates: clause.AssignmentColumns([]string{
|
|
||||||
"depreciation_percent_effective",
|
|
||||||
"depreciation_value",
|
|
||||||
"pullet_cost_day_n_total",
|
|
||||||
"components",
|
|
||||||
"updated_at",
|
|
||||||
}),
|
|
||||||
}).
|
|
||||||
Create(&rows).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *expenseDepreciationRepository) DeleteSnapshotsFromDate(
|
|
||||||
ctx context.Context,
|
|
||||||
fromDate time.Time,
|
|
||||||
farmIDs []uint,
|
|
||||||
) error {
|
|
||||||
if fromDate.IsZero() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
query := r.db.WithContext(ctx).
|
|
||||||
Table("farm_depreciation_snapshots").
|
|
||||||
Where("period_date >= DATE(?)", fromDate)
|
|
||||||
if len(farmIDs) > 0 {
|
|
||||||
query = query.Where("project_flock_id IN ?", farmIDs)
|
|
||||||
}
|
|
||||||
return query.Delete(nil).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *expenseDepreciationRepository) GetLatestTransferInputsByFarms(
|
|
||||||
ctx context.Context,
|
|
||||||
period time.Time,
|
|
||||||
farmIDs []uint,
|
|
||||||
) ([]FarmDepreciationLatestTransferRow, error) {
|
|
||||||
if len(farmIDs) == 0 {
|
|
||||||
return []FarmDepreciationLatestTransferRow{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
rows := make([]FarmDepreciationLatestTransferRow, 0)
|
|
||||||
query := `
|
|
||||||
WITH latest_transfer_approval AS (
|
|
||||||
SELECT a.approvable_id, a.action
|
|
||||||
FROM approvals a
|
|
||||||
JOIN (
|
|
||||||
SELECT approvable_id, MAX(action_at) AS latest_action_at
|
|
||||||
FROM approvals
|
|
||||||
WHERE approvable_type = @approval_type
|
|
||||||
GROUP BY approvable_id
|
|
||||||
) la
|
|
||||||
ON la.approvable_id = a.approvable_id
|
|
||||||
AND la.latest_action_at = a.action_at
|
|
||||||
WHERE a.approvable_type = @approval_type
|
|
||||||
),
|
|
||||||
approved_transfers AS (
|
|
||||||
SELECT
|
|
||||||
lt.id,
|
|
||||||
lt.from_project_flock_id,
|
|
||||||
lt.to_project_flock_id,
|
|
||||||
COALESCE(DATE(lt.effective_move_date), DATE(lt.economic_cutoff_date), DATE(lt.transfer_date)) AS effective_date
|
|
||||||
FROM laying_transfers lt
|
|
||||||
JOIN latest_transfer_approval lta ON lta.approvable_id = lt.id
|
|
||||||
WHERE lt.deleted_at IS NULL
|
|
||||||
AND lt.executed_at IS NOT NULL
|
|
||||||
AND lta.action = 'APPROVED'
|
|
||||||
)
|
|
||||||
SELECT DISTINCT ON (ltt.target_project_flock_kandang_id)
|
|
||||||
pf.id AS project_flock_id,
|
|
||||||
pf.flock_name AS farm_name,
|
|
||||||
pfk.id AS project_flock_kandang_id,
|
|
||||||
k.id AS kandang_id,
|
|
||||||
k.name AS kandang_name,
|
|
||||||
k.house_type::text AS house_type,
|
|
||||||
at.from_project_flock_id AS source_project_flock_id,
|
|
||||||
at.effective_date AS transfer_date,
|
|
||||||
ltt.total_qty AS transfer_qty,
|
|
||||||
at.id AS transfer_id
|
|
||||||
FROM laying_transfer_targets ltt
|
|
||||||
JOIN approved_transfers at ON at.id = ltt.laying_transfer_id
|
|
||||||
JOIN project_flock_kandangs pfk ON pfk.id = ltt.target_project_flock_kandang_id
|
|
||||||
JOIN project_flocks pf ON pf.id = pfk.project_flock_id
|
|
||||||
JOIN kandangs k ON k.id = pfk.kandang_id
|
|
||||||
WHERE ltt.deleted_at IS NULL
|
|
||||||
AND pf.id IN @farm_ids
|
|
||||||
AND at.effective_date <= DATE(@period_date)
|
|
||||||
ORDER BY ltt.target_project_flock_kandang_id, at.effective_date DESC, at.id DESC
|
|
||||||
`
|
|
||||||
|
|
||||||
if err := r.db.WithContext(ctx).Raw(query, map[string]any{
|
|
||||||
"approval_type": utils.ApprovalWorkflowTransferToLaying.String(),
|
|
||||||
"farm_ids": farmIDs,
|
|
||||||
"period_date": period,
|
|
||||||
}).Scan(&rows).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return rows, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *expenseDepreciationRepository) GetDepreciationPercents(
|
|
||||||
ctx context.Context,
|
|
||||||
houseTypes []string,
|
|
||||||
maxDay int,
|
|
||||||
) (map[string]map[int]float64, error) {
|
|
||||||
result := make(map[string]map[int]float64)
|
|
||||||
if len(houseTypes) == 0 || maxDay <= 0 {
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
rows := make([]houseDepreciationPercentRow, 0)
|
|
||||||
if err := r.db.WithContext(ctx).
|
|
||||||
Table("house_depreciation_standards").
|
|
||||||
Select("house_type::text AS house_type, day, depreciation_percent").
|
|
||||||
Where("house_type::text IN ?", houseTypes).
|
|
||||||
Where("day <= ?", maxDay).
|
|
||||||
Order("house_type ASC, day ASC").
|
|
||||||
Scan(&rows).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, row := range rows {
|
|
||||||
if _, exists := result[row.HouseType]; !exists {
|
|
||||||
result[row.HouseType] = make(map[int]float64)
|
|
||||||
}
|
|
||||||
result[row.HouseType][row.Day] = row.DepreciationPercent
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *expenseDepreciationRepository) GetLatestManualInputsByFarms(
|
|
||||||
ctx context.Context,
|
|
||||||
areaIDs, locationIDs, projectFlockIDs []int64,
|
|
||||||
) ([]FarmDepreciationManualInputRow, error) {
|
|
||||||
rows := make([]FarmDepreciationManualInputRow, 0)
|
|
||||||
|
|
||||||
query := r.db.WithContext(ctx).
|
|
||||||
Table("farm_depreciation_manual_inputs AS fdmi").
|
|
||||||
Select(`
|
|
||||||
fdmi.id AS id,
|
|
||||||
fdmi.project_flock_id AS project_flock_id,
|
|
||||||
pf.flock_name AS farm_name,
|
|
||||||
fdmi.total_cost AS total_cost,
|
|
||||||
fdmi.cutover_date AS cutover_date,
|
|
||||||
fdmi.note AS note
|
|
||||||
`).
|
|
||||||
Joins("JOIN project_flocks AS pf ON pf.id = fdmi.project_flock_id").
|
|
||||||
Where("pf.deleted_at IS NULL").
|
|
||||||
Where("pf.category = ?", utils.ProjectFlockCategoryLaying)
|
|
||||||
|
|
||||||
if len(areaIDs) > 0 {
|
|
||||||
query = query.Where("pf.area_id IN ?", areaIDs)
|
|
||||||
}
|
|
||||||
if len(locationIDs) > 0 {
|
|
||||||
query = query.Where("pf.location_id IN ?", locationIDs)
|
|
||||||
}
|
|
||||||
if len(projectFlockIDs) > 0 {
|
|
||||||
query = query.Where("pf.id IN ?", projectFlockIDs)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := query.
|
|
||||||
Order("pf.id ASC").
|
|
||||||
Scan(&rows).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return rows, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *expenseDepreciationRepository) UpsertManualInput(ctx context.Context, row *entity.FarmDepreciationManualInput) error {
|
|
||||||
if row == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now().UTC()
|
|
||||||
err := r.db.WithContext(ctx).
|
|
||||||
Clauses(clause.OnConflict{
|
|
||||||
Columns: []clause.Column{
|
|
||||||
{Name: "project_flock_id"},
|
|
||||||
},
|
|
||||||
DoUpdates: clause.Assignments(map[string]any{
|
|
||||||
"total_cost": row.TotalCost,
|
|
||||||
"cutover_date": row.CutoverDate,
|
|
||||||
"note": row.Note,
|
|
||||||
"updated_at": now,
|
|
||||||
}),
|
|
||||||
}).
|
|
||||||
Create(row).Error
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return r.db.WithContext(ctx).
|
|
||||||
Table("farm_depreciation_manual_inputs").
|
|
||||||
Select("id, project_flock_id, total_cost, cutover_date, note").
|
|
||||||
Where("project_flock_id = ?", row.ProjectFlockId).
|
|
||||||
Take(row).Error
|
|
||||||
}
|
|
||||||
@@ -16,14 +16,10 @@ func RepportRoutes(v1 fiber.Router, u user.UserService, s repport.RepportService
|
|||||||
route.Use(m.Auth(u))
|
route.Use(m.Auth(u))
|
||||||
|
|
||||||
route.Get("/expense", m.RequirePermissions(m.P_ReportExpenseGetAll), ctrl.GetExpense)
|
route.Get("/expense", m.RequirePermissions(m.P_ReportExpenseGetAll), ctrl.GetExpense)
|
||||||
route.Get("/expense/depreciation", ctrl.GetExpenseDepreciation)
|
|
||||||
route.Get("/expense/depreciation/manual-inputs", ctrl.GetExpenseDepreciationManualInputs)
|
|
||||||
route.Put("/expense/depreciation/manual-inputs", ctrl.UpsertExpenseDepreciationManualInput)
|
|
||||||
route.Get("/marketing", m.RequirePermissions(m.P_ReportDeliveryGetAll), ctrl.GetMarketing)
|
route.Get("/marketing", m.RequirePermissions(m.P_ReportDeliveryGetAll), ctrl.GetMarketing)
|
||||||
route.Get("/purchase-supplier", m.RequirePermissions(m.P_ReportPurchaseSupplierGetAll), ctrl.GetPurchaseSupplier)
|
route.Get("/purchase-supplier", m.RequirePermissions(m.P_ReportPurchaseSupplierGetAll), ctrl.GetPurchaseSupplier)
|
||||||
route.Get("/debt-supplier", m.RequirePermissions(m.P_ReportDebtSupplierGetAll), ctrl.GetDebtSupplier)
|
route.Get("/debt-supplier", m.RequirePermissions(m.P_ReportDebtSupplierGetAll), ctrl.GetDebtSupplier)
|
||||||
route.Get("/hpp-per-kandang", m.RequirePermissions(m.P_ReportHppPerKandangGetAll), ctrl.GetHppPerKandang)
|
route.Get("/hpp-per-kandang", m.RequirePermissions(m.P_ReportHppPerKandangGetAll), ctrl.GetHppPerKandang)
|
||||||
route.Get("/hpp-v2-breakdown", m.RequirePermissions(m.P_ReportHppPerKandangGetAll), ctrl.GetHppV2Breakdown)
|
|
||||||
route.Get("/production-result/:idProjectFlockKandang", m.RequirePermissions(m.P_ReportProductionResultGetAll), ctrl.GetProductionResult)
|
route.Get("/production-result/:idProjectFlockKandang", m.RequirePermissions(m.P_ReportProductionResultGetAll), ctrl.GetProductionResult)
|
||||||
route.Get("/customer-payment", m.RequirePermissions(m.P_ReportCustomerPaymentGetAll), ctrl.GetCustomerPayment)
|
route.Get("/customer-payment", m.RequirePermissions(m.P_ReportCustomerPaymentGetAll), ctrl.GetCustomerPayment)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,445 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/go-playground/validator/v10"
|
|
||||||
"github.com/gofiber/fiber/v2"
|
|
||||||
commonRepo "gitlab.com/mbugroup/lti-api.git/internal/common/repository"
|
|
||||||
approvalService "gitlab.com/mbugroup/lti-api.git/internal/common/service"
|
|
||||||
entity "gitlab.com/mbugroup/lti-api.git/internal/entities"
|
|
||||||
dto "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/dto"
|
|
||||||
repportRepo "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/repositories"
|
|
||||||
validation "gitlab.com/mbugroup/lti-api.git/internal/modules/repports/validations"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
type expenseDepreciationRepoMock struct {
|
|
||||||
repportRepo.ExpenseDepreciationRepository
|
|
||||||
manualInputs []repportRepo.FarmDepreciationManualInputRow
|
|
||||||
|
|
||||||
upsertedRow *entity.FarmDepreciationManualInput
|
|
||||||
deleteCalled bool
|
|
||||||
deleteDate time.Time
|
|
||||||
deleteFarmIDs []uint
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *expenseDepreciationRepoMock) DB() *gorm.DB {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *expenseDepreciationRepoMock) UpsertManualInput(_ context.Context, row *entity.FarmDepreciationManualInput) error {
|
|
||||||
if row == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
cloned := *row
|
|
||||||
if cloned.Id == 0 {
|
|
||||||
cloned.Id = 123
|
|
||||||
}
|
|
||||||
m.upsertedRow = &cloned
|
|
||||||
row.Id = cloned.Id
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *expenseDepreciationRepoMock) DeleteSnapshotsFromDate(_ context.Context, fromDate time.Time, farmIDs []uint) error {
|
|
||||||
m.deleteCalled = true
|
|
||||||
m.deleteDate = fromDate
|
|
||||||
m.deleteFarmIDs = append([]uint{}, farmIDs...)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *expenseDepreciationRepoMock) GetLatestManualInputsByFarms(_ context.Context, _ []int64, _ []int64, _ []int64) ([]repportRepo.FarmDepreciationManualInputRow, error) {
|
|
||||||
return append([]repportRepo.FarmDepreciationManualInputRow{}, m.manualInputs...), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type hppCostRepoMock struct {
|
|
||||||
commonRepo.HppCostRepository
|
|
||||||
kandangIDsByFarm map[uint][]uint
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *hppCostRepoMock) GetProjectFlockKandangIDs(_ context.Context, projectFlockID uint) ([]uint, error) {
|
|
||||||
return append([]uint{}, m.kandangIDsByFarm[projectFlockID]...), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type hppV2ServiceMock struct {
|
|
||||||
approvalService.HppV2Service
|
|
||||||
breakdownByPFK map[uint]*approvalService.HppV2Breakdown
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *hppV2ServiceMock) CalculateHppBreakdown(projectFlockKandangId uint, _ *time.Time) (*approvalService.HppV2Breakdown, error) {
|
|
||||||
return m.breakdownByPFK[projectFlockKandangId], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestComputeExpenseDepreciationSnapshots_FromHppV2NormalTransfer(t *testing.T) {
|
|
||||||
periodDate := mustJakartaDate(t, "2026-06-05")
|
|
||||||
svc := &repportService{
|
|
||||||
HppCostRepo: &hppCostRepoMock{
|
|
||||||
kandangIDsByFarm: map[uint][]uint{
|
|
||||||
1: {10},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
HppV2Svc: &hppV2ServiceMock{
|
|
||||||
breakdownByPFK: map[uint]*approvalService.HppV2Breakdown{
|
|
||||||
10: {
|
|
||||||
ProjectFlockKandangID: 10,
|
|
||||||
KandangID: 100,
|
|
||||||
KandangName: "Kandang A",
|
|
||||||
HouseType: "close_house",
|
|
||||||
Components: []approvalService.HppV2Component{
|
|
||||||
{
|
|
||||||
Code: "DEPRECIATION",
|
|
||||||
Title: "Depreciation",
|
|
||||||
Total: 100,
|
|
||||||
Parts: []approvalService.HppV2ComponentPart{
|
|
||||||
{
|
|
||||||
Code: "normal_transfer",
|
|
||||||
Total: 100,
|
|
||||||
Details: map[string]any{
|
|
||||||
"schedule_day": 2,
|
|
||||||
"depreciation_percent": 10.0,
|
|
||||||
"pullet_cost_day_n": 1000.0,
|
|
||||||
"source_project_flock_id": 77,
|
|
||||||
"origin_date": "2026-01-01",
|
|
||||||
},
|
|
||||||
References: []approvalService.HppV2Reference{
|
|
||||||
{
|
|
||||||
Type: "laying_transfer",
|
|
||||||
ID: 701,
|
|
||||||
Date: "2026-05-20",
|
|
||||||
Qty: 150,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := svc.computeExpenseDepreciationSnapshots(context.Background(), periodDate, []uint{1}, map[uint]string{1: "Farm A"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
if len(rows) != 1 {
|
|
||||||
t.Fatalf("expected 1 row, got %d", len(rows))
|
|
||||||
}
|
|
||||||
if rows[0].DepreciationValue != 100 {
|
|
||||||
t.Fatalf("expected depreciation value 100, got %v", rows[0].DepreciationValue)
|
|
||||||
}
|
|
||||||
if rows[0].PulletCostDayNTotal != 1000 {
|
|
||||||
t.Fatalf("expected pullet cost day n 1000, got %v", rows[0].PulletCostDayNTotal)
|
|
||||||
}
|
|
||||||
assertFloatEqual(t, rows[0].DepreciationPercentEffective, 10)
|
|
||||||
|
|
||||||
components := decodeDepreciationComponents(t, rows[0].Components)
|
|
||||||
if components.KandangCount != 1 {
|
|
||||||
t.Fatalf("expected kandang_count 1, got %d", components.KandangCount)
|
|
||||||
}
|
|
||||||
entry := components.Kandang[0]
|
|
||||||
if entry.ProjectFlockKandangID != 10 || entry.KandangID != 100 || entry.KandangName != "Kandang A" {
|
|
||||||
t.Fatalf("unexpected kandang identity: %+v", entry)
|
|
||||||
}
|
|
||||||
if entry.TransferID != 701 || entry.TransferDate != "2026-05-20" || entry.TransferQty != 150 {
|
|
||||||
t.Fatalf("unexpected transfer metadata: %+v", entry)
|
|
||||||
}
|
|
||||||
if entry.DepreciationSource != "normal_transfer" {
|
|
||||||
t.Fatalf("expected depreciation_source normal_transfer, got %q", entry.DepreciationSource)
|
|
||||||
}
|
|
||||||
if entry.ManualInputID != nil || entry.CutoverDate != "" || entry.StartScheduleDay != nil {
|
|
||||||
t.Fatalf("expected manual fields empty for normal transfer, got %+v", entry)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestComputeExpenseDepreciationSnapshots_FromHppV2ManualCutover(t *testing.T) {
|
|
||||||
periodDate := mustJakartaDate(t, "2026-06-05")
|
|
||||||
svc := &repportService{
|
|
||||||
HppCostRepo: &hppCostRepoMock{
|
|
||||||
kandangIDsByFarm: map[uint][]uint{
|
|
||||||
2: {20},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
HppV2Svc: &hppV2ServiceMock{
|
|
||||||
breakdownByPFK: map[uint]*approvalService.HppV2Breakdown{
|
|
||||||
20: {
|
|
||||||
ProjectFlockKandangID: 20,
|
|
||||||
KandangID: 200,
|
|
||||||
KandangName: "Kandang B",
|
|
||||||
HouseType: "open_house",
|
|
||||||
Components: []approvalService.HppV2Component{
|
|
||||||
{
|
|
||||||
Code: "DEPRECIATION",
|
|
||||||
Title: "Depreciation",
|
|
||||||
Total: 200,
|
|
||||||
Parts: []approvalService.HppV2ComponentPart{
|
|
||||||
{
|
|
||||||
Code: "manual_cutover",
|
|
||||||
Total: 200,
|
|
||||||
Details: map[string]any{
|
|
||||||
"schedule_day": 2,
|
|
||||||
"start_schedule_day": 2,
|
|
||||||
"depreciation_percent": 25.0,
|
|
||||||
"pullet_cost_day_n": 800.0,
|
|
||||||
"manual_input_id": 901,
|
|
||||||
"cutover_date": "2026-06-01",
|
|
||||||
"origin_date": "2026-01-01",
|
|
||||||
},
|
|
||||||
References: []approvalService.HppV2Reference{
|
|
||||||
{
|
|
||||||
Type: "farm_depreciation_manual_input",
|
|
||||||
ID: 901,
|
|
||||||
Date: "2026-06-01",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := svc.computeExpenseDepreciationSnapshots(context.Background(), periodDate, []uint{2}, map[uint]string{2: "Farm B"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
if len(rows) != 1 {
|
|
||||||
t.Fatalf("expected 1 row, got %d", len(rows))
|
|
||||||
}
|
|
||||||
assertFloatEqual(t, rows[0].DepreciationPercentEffective, 25)
|
|
||||||
|
|
||||||
components := decodeDepreciationComponents(t, rows[0].Components)
|
|
||||||
if components.KandangCount != 1 {
|
|
||||||
t.Fatalf("expected kandang_count 1, got %d", components.KandangCount)
|
|
||||||
}
|
|
||||||
entry := components.Kandang[0]
|
|
||||||
if entry.DepreciationSource != "manual_cutover" {
|
|
||||||
t.Fatalf("expected depreciation_source manual_cutover, got %q", entry.DepreciationSource)
|
|
||||||
}
|
|
||||||
if entry.TransferID != 0 || entry.TransferDate != "" || entry.TransferQty != 0 {
|
|
||||||
t.Fatalf("expected transfer fields empty for manual path, got %+v", entry)
|
|
||||||
}
|
|
||||||
if entry.ManualInputID == nil || *entry.ManualInputID != 901 {
|
|
||||||
t.Fatalf("expected manual_input_id 901, got %+v", entry.ManualInputID)
|
|
||||||
}
|
|
||||||
if entry.CutoverDate != "2026-06-01" || entry.OriginDate != "2026-01-01" {
|
|
||||||
t.Fatalf("unexpected manual date fields: %+v", entry)
|
|
||||||
}
|
|
||||||
if entry.StartScheduleDay == nil || *entry.StartScheduleDay != 2 {
|
|
||||||
t.Fatalf("expected start_schedule_day 2, got %+v", entry.StartScheduleDay)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestComputeExpenseDepreciationSnapshots_AggregatesMultipleKandang(t *testing.T) {
|
|
||||||
periodDate := mustJakartaDate(t, "2026-06-05")
|
|
||||||
svc := &repportService{
|
|
||||||
HppCostRepo: &hppCostRepoMock{
|
|
||||||
kandangIDsByFarm: map[uint][]uint{
|
|
||||||
3: {30, 31},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
HppV2Svc: &hppV2ServiceMock{
|
|
||||||
breakdownByPFK: map[uint]*approvalService.HppV2Breakdown{
|
|
||||||
30: {
|
|
||||||
ProjectFlockKandangID: 30,
|
|
||||||
KandangID: 300,
|
|
||||||
KandangName: "Kandang C1",
|
|
||||||
Components: []approvalService.HppV2Component{
|
|
||||||
{
|
|
||||||
Code: "DEPRECIATION",
|
|
||||||
Parts: []approvalService.HppV2ComponentPart{
|
|
||||||
{
|
|
||||||
Code: "normal_transfer",
|
|
||||||
Total: 50,
|
|
||||||
Details: map[string]any{
|
|
||||||
"schedule_day": 1,
|
|
||||||
"depreciation_percent": 10.0,
|
|
||||||
"pullet_cost_day_n": 500.0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
31: {
|
|
||||||
ProjectFlockKandangID: 31,
|
|
||||||
KandangID: 301,
|
|
||||||
KandangName: "Kandang C2",
|
|
||||||
Components: []approvalService.HppV2Component{
|
|
||||||
{
|
|
||||||
Code: "DEPRECIATION",
|
|
||||||
Parts: []approvalService.HppV2ComponentPart{
|
|
||||||
{
|
|
||||||
Code: "normal_transfer",
|
|
||||||
Total: 100,
|
|
||||||
Details: map[string]any{
|
|
||||||
"schedule_day": 2,
|
|
||||||
"depreciation_percent": 10.0,
|
|
||||||
"pullet_cost_day_n": 1000.0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := svc.computeExpenseDepreciationSnapshots(context.Background(), periodDate, []uint{3}, map[uint]string{3: "Farm C"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
if len(rows) != 1 {
|
|
||||||
t.Fatalf("expected 1 row, got %d", len(rows))
|
|
||||||
}
|
|
||||||
if rows[0].DepreciationValue != 150 {
|
|
||||||
t.Fatalf("expected depreciation value 150, got %v", rows[0].DepreciationValue)
|
|
||||||
}
|
|
||||||
if rows[0].PulletCostDayNTotal != 1500 {
|
|
||||||
t.Fatalf("expected pullet cost day n 1500, got %v", rows[0].PulletCostDayNTotal)
|
|
||||||
}
|
|
||||||
assertFloatEqual(t, rows[0].DepreciationPercentEffective, 10)
|
|
||||||
|
|
||||||
components := decodeDepreciationComponents(t, rows[0].Components)
|
|
||||||
if components.KandangCount != 2 {
|
|
||||||
t.Fatalf("expected kandang_count 2, got %d", components.KandangCount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestComputeExpenseDepreciationSnapshots_ZeroWhenDepreciationMissing(t *testing.T) {
|
|
||||||
periodDate := mustJakartaDate(t, "2026-06-05")
|
|
||||||
svc := &repportService{
|
|
||||||
HppCostRepo: &hppCostRepoMock{
|
|
||||||
kandangIDsByFarm: map[uint][]uint{
|
|
||||||
4: {40},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
HppV2Svc: &hppV2ServiceMock{
|
|
||||||
breakdownByPFK: map[uint]*approvalService.HppV2Breakdown{
|
|
||||||
40: {
|
|
||||||
ProjectFlockKandangID: 40,
|
|
||||||
KandangID: 400,
|
|
||||||
KandangName: "Kandang D",
|
|
||||||
Components: []approvalService.HppV2Component{
|
|
||||||
{Code: "PAKAN", Total: 123},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, err := svc.computeExpenseDepreciationSnapshots(context.Background(), periodDate, []uint{4}, map[uint]string{4: "Farm D"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no error, got %v", err)
|
|
||||||
}
|
|
||||||
if len(rows) != 1 {
|
|
||||||
t.Fatalf("expected 1 row, got %d", len(rows))
|
|
||||||
}
|
|
||||||
if rows[0].DepreciationValue != 0 || rows[0].PulletCostDayNTotal != 0 || rows[0].DepreciationPercentEffective != 0 {
|
|
||||||
t.Fatalf("expected zero snapshot values, got %+v", rows[0])
|
|
||||||
}
|
|
||||||
components := decodeDepreciationComponents(t, rows[0].Components)
|
|
||||||
if components.KandangCount != 0 || len(components.Kandang) != 0 {
|
|
||||||
t.Fatalf("expected empty components, got %+v", components)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpsertExpenseDepreciationManualInput_InvalidatesSnapshotsFromCutoverDate(t *testing.T) {
|
|
||||||
repo := &expenseDepreciationRepoMock{
|
|
||||||
manualInputs: []repportRepo.FarmDepreciationManualInputRow{
|
|
||||||
{
|
|
||||||
Id: 123,
|
|
||||||
ProjectFlockID: 99,
|
|
||||||
FarmName: "Farm Z",
|
|
||||||
TotalCost: 1000,
|
|
||||||
CutoverDate: mustJakartaDate(t, "2026-06-01"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
svc := &repportService{
|
|
||||||
Validate: validator.New(),
|
|
||||||
ExpenseDepreciationRepo: repo,
|
|
||||||
}
|
|
||||||
|
|
||||||
reqPayload := &validation.ExpenseDepreciationManualInputUpsert{
|
|
||||||
ProjectFlockID: 99,
|
|
||||||
TotalCost: 1000,
|
|
||||||
CutoverDate: "2026-06-01",
|
|
||||||
}
|
|
||||||
|
|
||||||
app := fiber.New()
|
|
||||||
var response *dto.ExpenseDepreciationManualInputRowDTO
|
|
||||||
app.Put("/", func(c *fiber.Ctx) error {
|
|
||||||
result, err := svc.UpsertExpenseDepreciationManualInput(c, reqPayload)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
response = result
|
|
||||||
return c.SendStatus(fiber.StatusOK)
|
|
||||||
})
|
|
||||||
|
|
||||||
httpResp, err := app.Test(httptest.NewRequest(http.MethodPut, "/", nil))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("expected no app error, got %v", err)
|
|
||||||
}
|
|
||||||
if httpResp.StatusCode != fiber.StatusOK {
|
|
||||||
t.Fatalf("expected status %d, got %d", fiber.StatusOK, httpResp.StatusCode)
|
|
||||||
}
|
|
||||||
if !repo.deleteCalled {
|
|
||||||
t.Fatal("expected DeleteSnapshotsFromDate to be called")
|
|
||||||
}
|
|
||||||
if len(repo.deleteFarmIDs) != 1 || repo.deleteFarmIDs[0] != 99 {
|
|
||||||
t.Fatalf("expected delete farm ids [99], got %v", repo.deleteFarmIDs)
|
|
||||||
}
|
|
||||||
if repo.deleteDate.Format("2006-01-02") != "2026-06-01" {
|
|
||||||
t.Fatalf("expected delete date 2026-06-01, got %s", repo.deleteDate.Format("2006-01-02"))
|
|
||||||
}
|
|
||||||
if response == nil {
|
|
||||||
t.Fatal("expected response")
|
|
||||||
}
|
|
||||||
if response.FarmName != "Farm Z" {
|
|
||||||
t.Fatalf("expected farm name Farm Z, got %s", response.FarmName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func decodeDepreciationComponents(t *testing.T, raw []byte) depreciationFarmComponents {
|
|
||||||
t.Helper()
|
|
||||||
var out depreciationFarmComponents
|
|
||||||
if len(raw) == 0 {
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal(raw, &out); err != nil {
|
|
||||||
t.Fatalf("failed to decode components: %v", err)
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func mustJakartaDate(t *testing.T, raw string) time.Time {
|
|
||||||
t.Helper()
|
|
||||||
location, err := time.LoadLocation("Asia/Jakarta")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed loading timezone: %v", err)
|
|
||||||
}
|
|
||||||
value, err := time.ParseInLocation("2006-01-02", raw, location)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed parsing date %q: %v", raw, err)
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
func assertFloatEqual(t *testing.T, got float64, want float64) {
|
|
||||||
t.Helper()
|
|
||||||
const epsilon = 0.000001
|
|
||||||
if got > want+epsilon || got < want-epsilon {
|
|
||||||
t.Fatalf("expected %.6f, got %.6f", want, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -42,14 +42,10 @@ import (
|
|||||||
|
|
||||||
type RepportService interface {
|
type RepportService interface {
|
||||||
GetExpense(ctx *fiber.Ctx, params *validation.ExpenseQuery) ([]dto.RepportExpenseListDTO, int64, error)
|
GetExpense(ctx *fiber.Ctx, params *validation.ExpenseQuery) ([]dto.RepportExpenseListDTO, int64, error)
|
||||||
GetExpenseDepreciation(ctx *fiber.Ctx) ([]dto.ExpenseDepreciationRowDTO, *dto.ExpenseDepreciationMetaDTO, error)
|
|
||||||
GetExpenseDepreciationManualInputs(ctx *fiber.Ctx) ([]dto.ExpenseDepreciationManualInputRowDTO, *dto.ExpenseDepreciationMetaDTO, error)
|
|
||||||
UpsertExpenseDepreciationManualInput(ctx *fiber.Ctx, req *validation.ExpenseDepreciationManualInputUpsert) (*dto.ExpenseDepreciationManualInputRowDTO, error)
|
|
||||||
GetMarketing(ctx *fiber.Ctx, params *validation.MarketingQuery) ([]dto.RepportMarketingItemDTO, int64, error)
|
GetMarketing(ctx *fiber.Ctx, params *validation.MarketingQuery) ([]dto.RepportMarketingItemDTO, int64, error)
|
||||||
GetPurchaseSupplier(ctx *fiber.Ctx, params *validation.PurchaseSupplierQuery) ([]dto.PurchaseSupplierDTO, int64, error)
|
GetPurchaseSupplier(ctx *fiber.Ctx, params *validation.PurchaseSupplierQuery) ([]dto.PurchaseSupplierDTO, int64, error)
|
||||||
GetDebtSupplier(ctx *fiber.Ctx, params *validation.DebtSupplierQuery) ([]dto.DebtSupplierDTO, int64, error)
|
GetDebtSupplier(ctx *fiber.Ctx, params *validation.DebtSupplierQuery) ([]dto.DebtSupplierDTO, int64, error)
|
||||||
GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangResponseData, *dto.HppPerKandangMetaDTO, error)
|
GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangResponseData, *dto.HppPerKandangMetaDTO, error)
|
||||||
GetHppV2Breakdown(ctx *fiber.Ctx, params *validation.HppV2BreakdownQuery) (*approvalService.HppV2Breakdown, error)
|
|
||||||
GetProductionResult(ctx *fiber.Ctx, params *validation.ProductionResultQuery) ([]dto.ProductionResultDTO, int64, error)
|
GetProductionResult(ctx *fiber.Ctx, params *validation.ProductionResultQuery) ([]dto.ProductionResultDTO, int64, error)
|
||||||
GetCustomerPayment(ctx *fiber.Ctx, params *validation.CustomerPaymentQuery) ([]dto.CustomerPaymentReportItem, int64, error)
|
GetCustomerPayment(ctx *fiber.Ctx, params *validation.CustomerPaymentQuery) ([]dto.CustomerPaymentReportItem, int64, error)
|
||||||
DB() *gorm.DB
|
DB() *gorm.DB
|
||||||
@@ -60,15 +56,12 @@ type repportService struct {
|
|||||||
Validate *validator.Validate
|
Validate *validator.Validate
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
ExpenseRealizationRepo expenseRepo.ExpenseRealizationRepository
|
ExpenseRealizationRepo expenseRepo.ExpenseRealizationRepository
|
||||||
ExpenseDepreciationRepo repportRepo.ExpenseDepreciationRepository
|
|
||||||
MarketingDeliveryRepo marketingRepo.MarketingDeliveryProductRepository
|
MarketingDeliveryRepo marketingRepo.MarketingDeliveryProductRepository
|
||||||
PurchaseRepo purchaseRepo.PurchaseRepository
|
PurchaseRepo purchaseRepo.PurchaseRepository
|
||||||
ChickinRepo chickinRepo.ProjectChickinRepository
|
ChickinRepo chickinRepo.ProjectChickinRepository
|
||||||
RecordingRepo recordingRepo.RecordingRepository
|
RecordingRepo recordingRepo.RecordingRepository
|
||||||
ApprovalSvc approvalService.ApprovalService
|
ApprovalSvc approvalService.ApprovalService
|
||||||
HppSvc approvalService.HppService
|
HppSvc approvalService.HppService
|
||||||
HppV2Svc approvalService.HppV2Service
|
|
||||||
HppCostRepo commonRepo.HppCostRepository
|
|
||||||
PurchaseSupplierRepo repportRepo.PurchaseSupplierRepository
|
PurchaseSupplierRepo repportRepo.PurchaseSupplierRepository
|
||||||
DebtSupplierRepo repportRepo.DebtSupplierRepository
|
DebtSupplierRepo repportRepo.DebtSupplierRepository
|
||||||
HppPerKandangRepo repportRepo.HppPerKandangRepository
|
HppPerKandangRepo repportRepo.HppPerKandangRepository
|
||||||
@@ -92,15 +85,12 @@ func NewRepportService(
|
|||||||
db *gorm.DB,
|
db *gorm.DB,
|
||||||
validate *validator.Validate,
|
validate *validator.Validate,
|
||||||
expenseRealizationRepo expenseRepo.ExpenseRealizationRepository,
|
expenseRealizationRepo expenseRepo.ExpenseRealizationRepository,
|
||||||
expenseDepreciationRepo repportRepo.ExpenseDepreciationRepository,
|
|
||||||
marketingDeliveryRepo marketingRepo.MarketingDeliveryProductRepository,
|
marketingDeliveryRepo marketingRepo.MarketingDeliveryProductRepository,
|
||||||
purchaseRepo purchaseRepo.PurchaseRepository,
|
purchaseRepo purchaseRepo.PurchaseRepository,
|
||||||
chickinRepo chickinRepo.ProjectChickinRepository,
|
chickinRepo chickinRepo.ProjectChickinRepository,
|
||||||
recordingRepo recordingRepo.RecordingRepository,
|
recordingRepo recordingRepo.RecordingRepository,
|
||||||
approvalSvc approvalService.ApprovalService,
|
approvalSvc approvalService.ApprovalService,
|
||||||
hppSvc approvalService.HppService,
|
hppSvc approvalService.HppService,
|
||||||
hppV2Svc approvalService.HppV2Service,
|
|
||||||
hppCostRepo commonRepo.HppCostRepository,
|
|
||||||
purchaseSupplierRepo repportRepo.PurchaseSupplierRepository,
|
purchaseSupplierRepo repportRepo.PurchaseSupplierRepository,
|
||||||
debtSupplierRepo repportRepo.DebtSupplierRepository,
|
debtSupplierRepo repportRepo.DebtSupplierRepository,
|
||||||
hppPerKandangRepo repportRepo.HppPerKandangRepository,
|
hppPerKandangRepo repportRepo.HppPerKandangRepository,
|
||||||
@@ -115,15 +105,12 @@ func NewRepportService(
|
|||||||
Validate: validate,
|
Validate: validate,
|
||||||
db: db,
|
db: db,
|
||||||
ExpenseRealizationRepo: expenseRealizationRepo,
|
ExpenseRealizationRepo: expenseRealizationRepo,
|
||||||
ExpenseDepreciationRepo: expenseDepreciationRepo,
|
|
||||||
MarketingDeliveryRepo: marketingDeliveryRepo,
|
MarketingDeliveryRepo: marketingDeliveryRepo,
|
||||||
PurchaseRepo: purchaseRepo,
|
PurchaseRepo: purchaseRepo,
|
||||||
ChickinRepo: chickinRepo,
|
ChickinRepo: chickinRepo,
|
||||||
RecordingRepo: recordingRepo,
|
RecordingRepo: recordingRepo,
|
||||||
ApprovalSvc: approvalSvc,
|
ApprovalSvc: approvalSvc,
|
||||||
HppSvc: hppSvc,
|
HppSvc: hppSvc,
|
||||||
HppV2Svc: hppV2Svc,
|
|
||||||
HppCostRepo: hppCostRepo,
|
|
||||||
PurchaseSupplierRepo: purchaseSupplierRepo,
|
PurchaseSupplierRepo: purchaseSupplierRepo,
|
||||||
DebtSupplierRepo: debtSupplierRepo,
|
DebtSupplierRepo: debtSupplierRepo,
|
||||||
HppPerKandangRepo: hppPerKandangRepo,
|
HppPerKandangRepo: hppPerKandangRepo,
|
||||||
@@ -177,540 +164,6 @@ func (s *repportService) GetExpense(c *fiber.Ctx, params *validation.ExpenseQuer
|
|||||||
return result, total, nil
|
return result, total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *repportService) GetExpenseDepreciation(ctx *fiber.Ctx) ([]dto.ExpenseDepreciationRowDTO, *dto.ExpenseDepreciationMetaDTO, error) {
|
|
||||||
params, filters, err := s.parseExpenseDepreciationQuery(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
if err := s.Validate.Struct(params); err != nil {
|
|
||||||
return nil, nil, fiber.NewError(fiber.StatusBadRequest, err.Error())
|
|
||||||
}
|
|
||||||
if s.ExpenseDepreciationRepo == nil {
|
|
||||||
return nil, nil, fiber.NewError(fiber.StatusInternalServerError, "expense depreciation repository is not configured")
|
|
||||||
}
|
|
||||||
|
|
||||||
location, err := time.LoadLocation("Asia/Jakarta")
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, fiber.NewError(fiber.StatusInternalServerError, "failed to load timezone configuration")
|
|
||||||
}
|
|
||||||
periodDate, err := time.ParseInLocation("2006-01-02", params.Period, location)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, fiber.NewError(fiber.StatusBadRequest, "period must follow format YYYY-MM-DD")
|
|
||||||
}
|
|
||||||
|
|
||||||
candidateRows, err := s.ExpenseDepreciationRepo.GetCandidateFarms(
|
|
||||||
ctx.Context(),
|
|
||||||
periodDate,
|
|
||||||
params.AreaIDs,
|
|
||||||
params.LocationIDs,
|
|
||||||
params.ProjectFlockIDs,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
limit := params.Limit
|
|
||||||
if limit <= 0 {
|
|
||||||
limit = 10
|
|
||||||
}
|
|
||||||
if len(candidateRows) == 0 {
|
|
||||||
meta := &dto.ExpenseDepreciationMetaDTO{
|
|
||||||
Page: params.Page,
|
|
||||||
Limit: limit,
|
|
||||||
TotalPages: 1,
|
|
||||||
TotalResults: 0,
|
|
||||||
Filters: filters,
|
|
||||||
}
|
|
||||||
return []dto.ExpenseDepreciationRowDTO{}, meta, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
farmIDs := make([]uint, 0, len(candidateRows))
|
|
||||||
farmNameByID := make(map[uint]string, len(candidateRows))
|
|
||||||
for _, row := range candidateRows {
|
|
||||||
farmIDs = append(farmIDs, row.ProjectFlockID)
|
|
||||||
farmNameByID[row.ProjectFlockID] = row.FarmName
|
|
||||||
}
|
|
||||||
|
|
||||||
snapshots, err := s.ExpenseDepreciationRepo.GetSnapshotsByPeriodAndFarmIDs(ctx.Context(), periodDate, farmIDs)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
snapshotByFarmID := make(map[uint]entity.FarmDepreciationSnapshot, len(snapshots))
|
|
||||||
for _, row := range snapshots {
|
|
||||||
snapshotByFarmID[row.ProjectFlockId] = row
|
|
||||||
}
|
|
||||||
|
|
||||||
missingFarmIDs := make([]uint, 0)
|
|
||||||
for _, farmID := range farmIDs {
|
|
||||||
if _, exists := snapshotByFarmID[farmID]; exists {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
missingFarmIDs = append(missingFarmIDs, farmID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(missingFarmIDs) > 0 {
|
|
||||||
computedSnapshots, computeErr := s.computeExpenseDepreciationSnapshots(ctx.Context(), periodDate, missingFarmIDs, farmNameByID)
|
|
||||||
if computeErr != nil {
|
|
||||||
return nil, nil, computeErr
|
|
||||||
}
|
|
||||||
if len(computedSnapshots) > 0 {
|
|
||||||
if err := s.ExpenseDepreciationRepo.UpsertSnapshots(ctx.Context(), computedSnapshots); err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
for _, row := range computedSnapshots {
|
|
||||||
snapshotByFarmID[row.ProjectFlockId] = row
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rows := make([]dto.ExpenseDepreciationRowDTO, 0, len(candidateRows))
|
|
||||||
for _, candidate := range candidateRows {
|
|
||||||
snapshot, exists := snapshotByFarmID[candidate.ProjectFlockID]
|
|
||||||
if !exists {
|
|
||||||
rows = append(rows, dto.ExpenseDepreciationRowDTO{
|
|
||||||
ProjectFlockID: int64(candidate.ProjectFlockID),
|
|
||||||
FarmName: candidate.FarmName,
|
|
||||||
Period: params.Period,
|
|
||||||
DepreciationPercentEffective: 0,
|
|
||||||
DepreciationValue: 0,
|
|
||||||
PulletCostDayNTotal: 0,
|
|
||||||
Components: map[string]any{},
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
rows = append(rows, dto.ExpenseDepreciationRowDTO{
|
|
||||||
ProjectFlockID: int64(snapshot.ProjectFlockId),
|
|
||||||
FarmName: candidate.FarmName,
|
|
||||||
Period: params.Period,
|
|
||||||
DepreciationPercentEffective: snapshot.DepreciationPercentEffective,
|
|
||||||
DepreciationValue: snapshot.DepreciationValue,
|
|
||||||
PulletCostDayNTotal: snapshot.PulletCostDayNTotal,
|
|
||||||
Components: parseSnapshotComponents(snapshot.Components),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
totalResults := int64(len(rows))
|
|
||||||
totalPages := int64(0)
|
|
||||||
if totalResults > 0 {
|
|
||||||
totalPages = int64(math.Ceil(float64(totalResults) / float64(limit)))
|
|
||||||
}
|
|
||||||
if totalPages == 0 {
|
|
||||||
totalPages = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
offset := (params.Page - 1) * limit
|
|
||||||
if offset < 0 {
|
|
||||||
offset = 0
|
|
||||||
}
|
|
||||||
if offset > len(rows) {
|
|
||||||
offset = len(rows)
|
|
||||||
}
|
|
||||||
end := offset + limit
|
|
||||||
if end > len(rows) {
|
|
||||||
end = len(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
meta := &dto.ExpenseDepreciationMetaDTO{
|
|
||||||
Page: params.Page,
|
|
||||||
Limit: limit,
|
|
||||||
TotalPages: totalPages,
|
|
||||||
TotalResults: totalResults,
|
|
||||||
Filters: filters,
|
|
||||||
}
|
|
||||||
|
|
||||||
return rows[offset:end], meta, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *repportService) GetExpenseDepreciationManualInputs(ctx *fiber.Ctx) ([]dto.ExpenseDepreciationManualInputRowDTO, *dto.ExpenseDepreciationMetaDTO, error) {
|
|
||||||
params, filters, err := s.parseExpenseDepreciationQuery(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
if s.ExpenseDepreciationRepo == nil {
|
|
||||||
return nil, nil, fiber.NewError(fiber.StatusInternalServerError, "expense depreciation repository is not configured")
|
|
||||||
}
|
|
||||||
|
|
||||||
repoRows, err := s.ExpenseDepreciationRepo.GetLatestManualInputsByFarms(
|
|
||||||
ctx.Context(),
|
|
||||||
params.AreaIDs,
|
|
||||||
params.LocationIDs,
|
|
||||||
params.ProjectFlockIDs,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
rows := make([]dto.ExpenseDepreciationManualInputRowDTO, 0, len(repoRows))
|
|
||||||
for _, row := range repoRows {
|
|
||||||
rows = append(rows, dto.ExpenseDepreciationManualInputRowDTO{
|
|
||||||
ID: int64(row.Id),
|
|
||||||
ProjectFlockID: int64(row.ProjectFlockID),
|
|
||||||
FarmName: row.FarmName,
|
|
||||||
TotalCost: row.TotalCost,
|
|
||||||
CutoverDate: row.CutoverDate.Format("2006-01-02"),
|
|
||||||
Note: row.Note,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
limit := params.Limit
|
|
||||||
if limit <= 0 {
|
|
||||||
limit = 10
|
|
||||||
}
|
|
||||||
totalResults := int64(len(rows))
|
|
||||||
totalPages := int64(0)
|
|
||||||
if totalResults > 0 {
|
|
||||||
totalPages = int64(math.Ceil(float64(totalResults) / float64(limit)))
|
|
||||||
}
|
|
||||||
if totalPages == 0 {
|
|
||||||
totalPages = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
offset := (params.Page - 1) * limit
|
|
||||||
if offset < 0 {
|
|
||||||
offset = 0
|
|
||||||
}
|
|
||||||
if offset > len(rows) {
|
|
||||||
offset = len(rows)
|
|
||||||
}
|
|
||||||
end := offset + limit
|
|
||||||
if end > len(rows) {
|
|
||||||
end = len(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
meta := &dto.ExpenseDepreciationMetaDTO{
|
|
||||||
Page: params.Page,
|
|
||||||
Limit: limit,
|
|
||||||
TotalPages: totalPages,
|
|
||||||
TotalResults: totalResults,
|
|
||||||
Filters: filters,
|
|
||||||
}
|
|
||||||
|
|
||||||
return rows[offset:end], meta, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *repportService) UpsertExpenseDepreciationManualInput(ctx *fiber.Ctx, req *validation.ExpenseDepreciationManualInputUpsert) (*dto.ExpenseDepreciationManualInputRowDTO, error) {
|
|
||||||
if req == nil {
|
|
||||||
return nil, fiber.NewError(fiber.StatusBadRequest, "request is required")
|
|
||||||
}
|
|
||||||
if err := s.Validate.Struct(req); err != nil {
|
|
||||||
return nil, fiber.NewError(fiber.StatusBadRequest, err.Error())
|
|
||||||
}
|
|
||||||
if s.ExpenseDepreciationRepo == nil {
|
|
||||||
return nil, fiber.NewError(fiber.StatusInternalServerError, "expense depreciation repository is not configured")
|
|
||||||
}
|
|
||||||
location, err := time.LoadLocation("Asia/Jakarta")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fiber.NewError(fiber.StatusInternalServerError, "failed to load timezone configuration")
|
|
||||||
}
|
|
||||||
cutoverDate, err := time.ParseInLocation("2006-01-02", req.CutoverDate, location)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fiber.NewError(fiber.StatusBadRequest, "cutover_date must follow format YYYY-MM-DD")
|
|
||||||
}
|
|
||||||
|
|
||||||
row := entity.FarmDepreciationManualInput{
|
|
||||||
ProjectFlockId: req.ProjectFlockID,
|
|
||||||
TotalCost: req.TotalCost,
|
|
||||||
CutoverDate: cutoverDate,
|
|
||||||
Note: req.Note,
|
|
||||||
}
|
|
||||||
if err := s.ExpenseDepreciationRepo.UpsertManualInput(ctx.Context(), &row); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if err := s.ExpenseDepreciationRepo.DeleteSnapshotsFromDate(
|
|
||||||
ctx.Context(),
|
|
||||||
cutoverDate,
|
|
||||||
[]uint{row.ProjectFlockId},
|
|
||||||
); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
response := &dto.ExpenseDepreciationManualInputRowDTO{
|
|
||||||
ID: int64(row.Id),
|
|
||||||
ProjectFlockID: int64(row.ProjectFlockId),
|
|
||||||
TotalCost: row.TotalCost,
|
|
||||||
CutoverDate: row.CutoverDate.Format("2006-01-02"),
|
|
||||||
Note: row.Note,
|
|
||||||
}
|
|
||||||
|
|
||||||
listRows, listErr := s.ExpenseDepreciationRepo.GetLatestManualInputsByFarms(
|
|
||||||
ctx.Context(),
|
|
||||||
nil,
|
|
||||||
nil,
|
|
||||||
[]int64{int64(row.ProjectFlockId)},
|
|
||||||
)
|
|
||||||
if listErr == nil {
|
|
||||||
for _, listRow := range listRows {
|
|
||||||
if listRow.ProjectFlockID == row.ProjectFlockId {
|
|
||||||
response.FarmName = listRow.FarmName
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return response, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type depreciationKandangComponent struct {
|
|
||||||
ProjectFlockKandangID uint `json:"project_flock_kandang_id"`
|
|
||||||
KandangID uint `json:"kandang_id"`
|
|
||||||
KandangName string `json:"kandang_name"`
|
|
||||||
TransferID uint `json:"transfer_id"`
|
|
||||||
TransferDate string `json:"transfer_date"`
|
|
||||||
SourceProjectFlockID uint `json:"source_project_flock_id"`
|
|
||||||
HouseType string `json:"house_type"`
|
|
||||||
DayN int `json:"day_n"`
|
|
||||||
DepreciationPercent float64 `json:"depreciation_percent"`
|
|
||||||
TransferQty float64 `json:"transfer_qty"`
|
|
||||||
PulletCostDayN float64 `json:"pullet_cost_day_n"`
|
|
||||||
DepreciationValue float64 `json:"depreciation_value"`
|
|
||||||
DepreciationSource string `json:"depreciation_source,omitempty"`
|
|
||||||
ManualInputID *uint `json:"manual_input_id,omitempty"`
|
|
||||||
CutoverDate string `json:"cutover_date,omitempty"`
|
|
||||||
OriginDate string `json:"origin_date,omitempty"`
|
|
||||||
StartScheduleDay *int `json:"start_schedule_day,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type depreciationFarmComponents struct {
|
|
||||||
KandangCount int `json:"kandang_count"`
|
|
||||||
Kandang []depreciationKandangComponent `json:"kandang"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *repportService) computeExpenseDepreciationSnapshots(
|
|
||||||
ctx context.Context,
|
|
||||||
periodDate time.Time,
|
|
||||||
farmIDs []uint,
|
|
||||||
farmNameByID map[uint]string,
|
|
||||||
) ([]entity.FarmDepreciationSnapshot, error) {
|
|
||||||
_ = farmNameByID
|
|
||||||
|
|
||||||
if len(farmIDs) == 0 {
|
|
||||||
return []entity.FarmDepreciationSnapshot{}, nil
|
|
||||||
}
|
|
||||||
if s.HppCostRepo == nil {
|
|
||||||
return nil, errors.New("hpp cost repository is not configured")
|
|
||||||
}
|
|
||||||
if s.HppV2Svc == nil {
|
|
||||||
return nil, errors.New("hpp v2 service is not configured")
|
|
||||||
}
|
|
||||||
|
|
||||||
result := make([]entity.FarmDepreciationSnapshot, 0, len(farmIDs))
|
|
||||||
for _, farmID := range farmIDs {
|
|
||||||
kandangIDs, err := s.HppCostRepo.GetProjectFlockKandangIDs(ctx, farmID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
components := depreciationFarmComponents{
|
|
||||||
Kandang: make([]depreciationKandangComponent, 0, len(kandangIDs)),
|
|
||||||
}
|
|
||||||
|
|
||||||
totalDepreciationValue := 0.0
|
|
||||||
totalPulletCostDayN := 0.0
|
|
||||||
for _, kandangID := range kandangIDs {
|
|
||||||
breakdown, err := s.HppV2Svc.CalculateHppBreakdown(kandangID, &periodDate)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if breakdown == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
depreciationComponent := hppV2FindDepreciationComponent(breakdown)
|
|
||||||
if depreciationComponent == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, part := range depreciationComponent.Parts {
|
|
||||||
if part.Total <= 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
houseType := approvalService.NormalizeDepreciationHouseType(breakdown.HouseType)
|
|
||||||
component := depreciationKandangComponent{
|
|
||||||
ProjectFlockKandangID: breakdown.ProjectFlockKandangID,
|
|
||||||
KandangID: breakdown.KandangID,
|
|
||||||
KandangName: breakdown.KandangName,
|
|
||||||
SourceProjectFlockID: hppV2DetailUint(part.Details, "source_project_flock_id"),
|
|
||||||
HouseType: houseType,
|
|
||||||
DayN: hppV2DetailInt(part.Details, "schedule_day"),
|
|
||||||
DepreciationPercent: hppV2DetailFloat(part.Details, "depreciation_percent"),
|
|
||||||
PulletCostDayN: hppV2DetailFloat(part.Details, "pullet_cost_day_n"),
|
|
||||||
DepreciationValue: part.Total,
|
|
||||||
DepreciationSource: part.Code,
|
|
||||||
OriginDate: hppV2DetailString(part.Details, "origin_date"),
|
|
||||||
}
|
|
||||||
|
|
||||||
if component.HouseType == "" {
|
|
||||||
component.HouseType = approvalService.NormalizeDepreciationHouseType(hppV2DetailString(part.Details, "house_type"))
|
|
||||||
}
|
|
||||||
|
|
||||||
if ref := hppV2FindReference(part.References, "laying_transfer"); ref != nil {
|
|
||||||
component.TransferID = ref.ID
|
|
||||||
component.TransferDate = ref.Date
|
|
||||||
component.TransferQty = ref.Qty
|
|
||||||
}
|
|
||||||
|
|
||||||
if part.Code == "manual_cutover" {
|
|
||||||
if startDay := hppV2DetailInt(part.Details, "start_schedule_day"); startDay > 0 {
|
|
||||||
component.StartScheduleDay = &startDay
|
|
||||||
}
|
|
||||||
component.CutoverDate = hppV2DetailString(part.Details, "cutover_date")
|
|
||||||
if manualID := hppV2DetailUint(part.Details, "manual_input_id"); manualID > 0 {
|
|
||||||
component.ManualInputID = &manualID
|
|
||||||
}
|
|
||||||
if component.ManualInputID == nil {
|
|
||||||
if ref := hppV2FindReference(part.References, "farm_depreciation_manual_input"); ref != nil && ref.ID > 0 {
|
|
||||||
manualID := ref.ID
|
|
||||||
component.ManualInputID = &manualID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
totalPulletCostDayN += component.PulletCostDayN
|
|
||||||
totalDepreciationValue += component.DepreciationValue
|
|
||||||
components.Kandang = append(components.Kandang, component)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
components.KandangCount = len(components.Kandang)
|
|
||||||
effectivePercent := approvalService.CalculateEffectiveDepreciationPercent(totalDepreciationValue, totalPulletCostDayN)
|
|
||||||
|
|
||||||
componentsJSON, marshalErr := json.Marshal(components)
|
|
||||||
if marshalErr != nil {
|
|
||||||
return nil, marshalErr
|
|
||||||
}
|
|
||||||
|
|
||||||
result = append(result, entity.FarmDepreciationSnapshot{
|
|
||||||
ProjectFlockId: farmID,
|
|
||||||
PeriodDate: periodDate,
|
|
||||||
DepreciationPercentEffective: effectivePercent,
|
|
||||||
DepreciationValue: totalDepreciationValue,
|
|
||||||
PulletCostDayNTotal: totalPulletCostDayN,
|
|
||||||
Components: componentsJSON,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func hppV2FindDepreciationComponent(breakdown *approvalService.HppV2Breakdown) *approvalService.HppV2Component {
|
|
||||||
if breakdown == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
for idx := range breakdown.Components {
|
|
||||||
if breakdown.Components[idx].Code == "DEPRECIATION" {
|
|
||||||
return &breakdown.Components[idx]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func hppV2FindReference(references []approvalService.HppV2Reference, refType string) *approvalService.HppV2Reference {
|
|
||||||
if refType == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
for idx := range references {
|
|
||||||
if references[idx].Type == refType {
|
|
||||||
return &references[idx]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func hppV2DetailFloat(details map[string]any, key string) float64 {
|
|
||||||
if details == nil || key == "" {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
raw, exists := details[key]
|
|
||||||
if !exists || raw == nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
switch value := raw.(type) {
|
|
||||||
case float64:
|
|
||||||
return value
|
|
||||||
case float32:
|
|
||||||
return float64(value)
|
|
||||||
case int:
|
|
||||||
return float64(value)
|
|
||||||
case int8:
|
|
||||||
return float64(value)
|
|
||||||
case int16:
|
|
||||||
return float64(value)
|
|
||||||
case int32:
|
|
||||||
return float64(value)
|
|
||||||
case int64:
|
|
||||||
return float64(value)
|
|
||||||
case uint:
|
|
||||||
return float64(value)
|
|
||||||
case uint8:
|
|
||||||
return float64(value)
|
|
||||||
case uint16:
|
|
||||||
return float64(value)
|
|
||||||
case uint32:
|
|
||||||
return float64(value)
|
|
||||||
case uint64:
|
|
||||||
return float64(value)
|
|
||||||
case string:
|
|
||||||
parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
|
|
||||||
if err != nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return parsed
|
|
||||||
default:
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func hppV2DetailInt(details map[string]any, key string) int {
|
|
||||||
return int(math.Round(hppV2DetailFloat(details, key)))
|
|
||||||
}
|
|
||||||
|
|
||||||
func hppV2DetailUint(details map[string]any, key string) uint {
|
|
||||||
value := hppV2DetailInt(details, key)
|
|
||||||
if value < 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return uint(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
func hppV2DetailString(details map[string]any, key string) string {
|
|
||||||
if details == nil || key == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
raw, exists := details[key]
|
|
||||||
if !exists || raw == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
switch value := raw.(type) {
|
|
||||||
case string:
|
|
||||||
return value
|
|
||||||
case time.Time:
|
|
||||||
return value.Format("2006-01-02")
|
|
||||||
default:
|
|
||||||
return fmt.Sprintf("%v", value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseSnapshotComponents(raw []byte) any {
|
|
||||||
if len(raw) == 0 {
|
|
||||||
return map[string]any{}
|
|
||||||
}
|
|
||||||
var out any
|
|
||||||
if err := json.Unmarshal(raw, &out); err != nil {
|
|
||||||
return map[string]any{}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func valueOrEmptyString(v *string) string {
|
|
||||||
if v == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return *v
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *repportService) GetMarketing(c *fiber.Ctx, params *validation.MarketingQuery) ([]dto.RepportMarketingItemDTO, int64, error) {
|
func (s *repportService) GetMarketing(c *fiber.Ctx, params *validation.MarketingQuery) ([]dto.RepportMarketingItemDTO, int64, error) {
|
||||||
if err := s.Validate.Struct(params); err != nil {
|
if err := s.Validate.Struct(params); err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
@@ -773,7 +226,7 @@ func (s *repportService) GetMarketing(c *fiber.Ctx, params *validation.Marketing
|
|||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
hppByDelivery := buildMarketingHppByDelivery(c.Context(), s.HppV2Svc, attributionRows)
|
hppByDelivery := buildMarketingHppByDelivery(c.Context(), s.HppSvc, attributionRows)
|
||||||
categoryByDelivery := buildMarketingCategoryByDelivery(deliveryProducts, attributionRows)
|
categoryByDelivery := buildMarketingCategoryByDelivery(deliveryProducts, attributionRows)
|
||||||
|
|
||||||
items := dto.ToMarketingReportItems(deliveryProducts, hppByDelivery, categoryByDelivery, agingMap)
|
items := dto.ToMarketingReportItems(deliveryProducts, hppByDelivery, categoryByDelivery, agingMap)
|
||||||
@@ -782,7 +235,7 @@ func (s *repportService) GetMarketing(c *fiber.Ctx, params *validation.Marketing
|
|||||||
|
|
||||||
func buildMarketingHppByDelivery(
|
func buildMarketingHppByDelivery(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
hppSvc approvalService.HppV2Service,
|
hppSvc approvalService.HppService,
|
||||||
attributionRows []commonRepo.MarketingDeliveryAttributionRow,
|
attributionRows []commonRepo.MarketingDeliveryAttributionRow,
|
||||||
) map[uint]float64 {
|
) map[uint]float64 {
|
||||||
if len(attributionRows) == 0 {
|
if len(attributionRows) == 0 {
|
||||||
@@ -1486,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 {
|
||||||
@@ -2192,27 +1630,6 @@ func resolveDebtSupplierReceivedDate(purchase entity.Purchase, loc *time.Locatio
|
|||||||
return time.Date(earliest.Year(), earliest.Month(), earliest.Day(), 0, 0, 0, 0, loc)
|
return time.Date(earliest.Year(), earliest.Month(), earliest.Day(), 0, 0, 0, 0, loc)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *repportService) GetHppV2Breakdown(ctx *fiber.Ctx, params *validation.HppV2BreakdownQuery) (*approvalService.HppV2Breakdown, error) {
|
|
||||||
if err := s.Validate.Struct(params); err != nil {
|
|
||||||
return nil, fiber.NewError(fiber.StatusBadRequest, err.Error())
|
|
||||||
}
|
|
||||||
if s.HppV2Svc == nil {
|
|
||||||
return nil, fiber.NewError(fiber.StatusInternalServerError, "hpp v2 service is not configured")
|
|
||||||
}
|
|
||||||
|
|
||||||
periodDate, err := time.ParseInLocation("2006-01-02", params.Period, time.FixedZone("Asia/Jakarta", 7*60*60))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fiber.NewError(fiber.StatusBadRequest, "period must follow format YYYY-MM-DD")
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := s.HppV2Svc.CalculateHppBreakdown(params.ProjectFlockKandangID, &periodDate)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangResponseData, *dto.HppPerKandangMetaDTO, error) {
|
func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangResponseData, *dto.HppPerKandangMetaDTO, error) {
|
||||||
params, filters, err := s.parseHppPerKandangQuery(ctx)
|
params, filters, err := s.parseHppPerKandangQuery(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -2359,22 +1776,20 @@ func (s *repportService) GetHppPerKandang(ctx *fiber.Ctx) (*dto.HppPerKandangRes
|
|||||||
var eggWeightFloat float64
|
var eggWeightFloat float64
|
||||||
var avgWeight float64
|
var avgWeight float64
|
||||||
eggHpp := 0.0
|
eggHpp := 0.0
|
||||||
if s.HppV2Svc != nil {
|
if s.HppSvc != nil {
|
||||||
hppCost, err := s.HppV2Svc.CalculateHppCost(row.ProjectFlockKandangID, &periodDate)
|
hppCost, err := s.HppSvc.CalculateHppCost(row.ProjectFlockKandangID, &periodDate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
if hppCost != nil {
|
if hppCost != nil {
|
||||||
eggPiecesFloatRemaining = hppCost.Estimation.Butir - hppCost.Real.Butir
|
eggPiecesFloatRemaining = hppCost.Estimation.Butir - hppCost.Real.Butir
|
||||||
// eggHpp = hppCost.Estimation.HargaKg
|
eggHpp = hppCost.Estimation.HargaKg
|
||||||
eggHpp = hppCost.Real.HargaKg
|
|
||||||
eggTotalPiecesFloat = hppCost.Estimation.Butir
|
eggTotalPiecesFloat = hppCost.Estimation.Butir
|
||||||
eggWeightFloat = hppCost.Estimation.Kg
|
eggWeightFloat = hppCost.Estimation.Kg
|
||||||
if eggTotalPiecesFloat > 0 {
|
if eggTotalPiecesFloat > 0 {
|
||||||
avgWeight = eggWeightFloat / eggTotalPiecesFloat
|
avgWeight = eggWeightFloat / eggTotalPiecesFloat
|
||||||
}
|
}
|
||||||
// eggRemainingWeightFloatRemaining = avgWeight * eggPiecesFloatRemaining
|
eggRemainingWeightFloatRemaining = avgWeight * eggPiecesFloatRemaining
|
||||||
eggRemainingWeightFloatRemaining = hppCost.Estimation.Kg - hppCost.Real.Kg
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if math.IsNaN(eggPiecesFloatRemaining) || math.IsInf(eggPiecesFloatRemaining, 0) {
|
if math.IsNaN(eggPiecesFloatRemaining) || math.IsInf(eggPiecesFloatRemaining, 0) {
|
||||||
@@ -2703,84 +2118,6 @@ func (s *repportService) parseHppPerKandangQuery(ctx *fiber.Ctx) (*validation.Hp
|
|||||||
return params, filters, nil
|
return params, filters, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *repportService) parseExpenseDepreciationQuery(ctx *fiber.Ctx) (*validation.ExpenseDepreciationQuery, dto.ExpenseDepreciationFiltersDTO, error) {
|
|
||||||
page := ctx.QueryInt("page", 1)
|
|
||||||
if page < 1 {
|
|
||||||
page = 1
|
|
||||||
}
|
|
||||||
limit := ctx.QueryInt("limit", 10)
|
|
||||||
if limit < 1 {
|
|
||||||
limit = 10
|
|
||||||
}
|
|
||||||
|
|
||||||
rawArea := ctx.Query("area_id", "")
|
|
||||||
rawLocation := ctx.Query("location_id", "")
|
|
||||||
rawProjectFlock := ctx.Query("project_flock_id", "")
|
|
||||||
period := strings.TrimSpace(ctx.Query("period", ""))
|
|
||||||
|
|
||||||
areaIDs, err := parseCommaSeparatedInt64s(rawArea)
|
|
||||||
if err != nil {
|
|
||||||
return nil, dto.ExpenseDepreciationFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, err.Error())
|
|
||||||
}
|
|
||||||
locationIDs, err := parseCommaSeparatedInt64s(rawLocation)
|
|
||||||
if err != nil {
|
|
||||||
return nil, dto.ExpenseDepreciationFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, err.Error())
|
|
||||||
}
|
|
||||||
projectFlockIDs, err := parseCommaSeparatedInt64s(rawProjectFlock)
|
|
||||||
if err != nil {
|
|
||||||
return nil, dto.ExpenseDepreciationFiltersDTO{}, fiber.NewError(fiber.StatusBadRequest, err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
locationScope, err := m.ResolveLocationScope(ctx, s.ExpenseRealizationRepo.DB())
|
|
||||||
if err != nil {
|
|
||||||
return nil, dto.ExpenseDepreciationFiltersDTO{}, err
|
|
||||||
}
|
|
||||||
areaScope, err := m.ResolveAreaScope(ctx, s.ExpenseRealizationRepo.DB())
|
|
||||||
if err != nil {
|
|
||||||
return nil, dto.ExpenseDepreciationFiltersDTO{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if locationScope.Restrict {
|
|
||||||
allowed := toInt64Slice(locationScope.IDs)
|
|
||||||
if len(allowed) == 0 {
|
|
||||||
locationIDs = []int64{-1}
|
|
||||||
} else if len(locationIDs) > 0 {
|
|
||||||
locationIDs = intersectInt64(locationIDs, allowed)
|
|
||||||
} else {
|
|
||||||
locationIDs = allowed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if areaScope.Restrict {
|
|
||||||
allowed := toInt64Slice(areaScope.IDs)
|
|
||||||
if len(allowed) == 0 {
|
|
||||||
areaIDs = []int64{-1}
|
|
||||||
} else if len(areaIDs) > 0 {
|
|
||||||
areaIDs = intersectInt64(areaIDs, allowed)
|
|
||||||
} else {
|
|
||||||
areaIDs = allowed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
params := &validation.ExpenseDepreciationQuery{
|
|
||||||
Page: page,
|
|
||||||
Limit: limit,
|
|
||||||
Period: period,
|
|
||||||
ProjectFlockIDs: projectFlockIDs,
|
|
||||||
AreaIDs: areaIDs,
|
|
||||||
LocationIDs: locationIDs,
|
|
||||||
}
|
|
||||||
|
|
||||||
filters := dto.NewExpenseDepreciationFiltersDTO(
|
|
||||||
rawArea,
|
|
||||||
rawLocation,
|
|
||||||
rawProjectFlock,
|
|
||||||
period,
|
|
||||||
)
|
|
||||||
|
|
||||||
return params, filters, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseCommaSeparatedInt64s(raw string) ([]int64, error) {
|
func parseCommaSeparatedInt64s(raw string) ([]int64, error) {
|
||||||
raw = strings.TrimSpace(raw)
|
raw = strings.TrimSpace(raw)
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
|
|||||||
@@ -75,27 +75,6 @@ type HppPerKandangQuery struct {
|
|||||||
WeightMax *float64 `query:"-"`
|
WeightMax *float64 `query:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type HppV2BreakdownQuery struct {
|
|
||||||
ProjectFlockKandangID uint `query:"project_flock_kandang_id" validate:"required,gt=0"`
|
|
||||||
Period string `query:"period" validate:"required,datetime=2006-01-02"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ExpenseDepreciationQuery struct {
|
|
||||||
Page int `query:"page" validate:"omitempty,min=1,gt=0"`
|
|
||||||
Limit int `query:"limit" validate:"omitempty,min=1,max=1000,gt=0"`
|
|
||||||
Period string `query:"period" validate:"required,datetime=2006-01-02"`
|
|
||||||
ProjectFlockIDs []int64 `query:"-"`
|
|
||||||
AreaIDs []int64 `query:"-"`
|
|
||||||
LocationIDs []int64 `query:"-"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ExpenseDepreciationManualInputUpsert struct {
|
|
||||||
ProjectFlockID uint `json:"project_flock_id" validate:"required,gt=0"`
|
|
||||||
TotalCost float64 `json:"total_cost" validate:"required,gte=0"`
|
|
||||||
CutoverDate string `json:"cutover_date" validate:"required,datetime=2006-01-02"`
|
|
||||||
Note *string `json:"note" validate:"omitempty,max=1000"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ProductionResultQuery struct {
|
type ProductionResultQuery struct {
|
||||||
Page int `query:"page" validate:"omitempty,min=1,gt=0"`
|
Page int `query:"page" validate:"omitempty,min=1,gt=0"`
|
||||||
Limit int `query:"limit" validate:"omitempty,min=1,gt=0"`
|
Limit int `query:"limit" validate:"omitempty,min=1,gt=0"`
|
||||||
|
|||||||
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), "..", ".."))
|
|
||||||
}
|
|
||||||
@@ -581,17 +581,6 @@ const (
|
|||||||
KandangStatusActive KandangStatus = "ACTIVE"
|
KandangStatusActive KandangStatus = "ACTIVE"
|
||||||
)
|
)
|
||||||
|
|
||||||
// -------------------------------------------------------------------
|
|
||||||
// House Type
|
|
||||||
// -------------------------------------------------------------------
|
|
||||||
|
|
||||||
type HouseType string
|
|
||||||
|
|
||||||
const (
|
|
||||||
HouseTypeOpenHouse HouseType = "open_house"
|
|
||||||
HouseTypeCloseHouse HouseType = "close_house"
|
|
||||||
)
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
// Marketing Type
|
// Marketing Type
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user