diff --git a/.github/actions/set-env/action.yml b/.github/actions/set-env/action.yml index 94ab829..29c17ff 100644 --- a/.github/actions/set-env/action.yml +++ b/.github/actions/set-env/action.yml @@ -72,6 +72,7 @@ runs: user-api build-class-change-notifications-job dispatch-notifications-job + scrape-class-changes-job " include="" for name in $DEPLOY_TARGETS; do diff --git a/Dockerfile b/Dockerfile index f895507..4befa4a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,7 +16,7 @@ RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ set -eux; \ mkdir -p /out/bin; \ - for cmd in academic-api announcement-api apply-table-privileges build-class-change-notifications-job dispatch-notifications-job migrate user-api; do \ + for cmd in academic-api announcement-api apply-table-privileges build-class-change-notifications-job dispatch-notifications-job insert-faculty-rooms migrate scrape-class-changes-job user-api; do \ CGO_ENABLED=0 GOOS=linux \ go build -tags timetzdata -trimpath -ldflags='-s -w' \ -o /out/bin/${cmd} ./cmd/${cmd}; \ diff --git a/cmd/insert-faculty-rooms/main.go b/cmd/insert-faculty-rooms/main.go new file mode 100644 index 0000000..c887a0f --- /dev/null +++ b/cmd/insert-faculty-rooms/main.go @@ -0,0 +1,89 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "os" + "sort" + "strconv" + "strings" + + "github.com/fun-dotto/server/internal/modules/batch-jobs/domain" + "github.com/fun-dotto/server/internal/modules/batch-jobs/repository" + "github.com/fun-dotto/server/internal/modules/batch-jobs/service" + "github.com/fun-dotto/server/internal/shared/db" + "github.com/joho/godotenv" +) + +func main() { + if err := godotenv.Load(); err != nil { + log.Printf("Warning: .env file not found: %v", err) + } + + csvPathByYear := map[int]string{} + flag.Func("faculties", "年度と CSV パスのペア(例: --faculties 2026=faculties_2026.csv)。複数年度は本オプションを複数回指定する。CSV 必須カラム: email, room_name", func(value string) error { + yearStr, path, ok := strings.Cut(value, "=") + if !ok || path == "" { + return fmt.Errorf("YEAR=PATH 形式で指定してください: %q", value) + } + year, err := strconv.Atoi(yearStr) + if err != nil { + return fmt.Errorf("年度は整数で指定してください: %q", yearStr) + } + if _, exists := csvPathByYear[year]; exists { + return fmt.Errorf("年度 %d が複数回指定されています", year) + } + csvPathByYear[year] = path + return nil + }) + flag.Parse() + if len(csvPathByYear) == 0 { + flag.Usage() + os.Exit(2) + } + + years := make([]int, 0, len(csvPathByYear)) + for year := range csvPathByYear { + years = append(years, year) + } + sort.Ints(years) + + var rows []domain.FacultyRoomImportRow + for _, year := range years { + path := csvPathByYear[year] + f, err := os.Open(path) + if err != nil { + log.Fatalf("Failed to open CSV %s: %v", path, err) + } + parsed, err := service.ParseFacultyRoomCSV(year, f) + _ = f.Close() + if err != nil { + log.Fatalf("Failed to parse CSV %s: %v", path, err) + } + rows = append(rows, parsed...) + } + + conn, err := db.ConnectWithConnectorIAMAuthN() + if err != nil { + log.Fatalf("Failed to connect to database: %v", err) + } + defer func() { + if err := db.Close(conn); err != nil { + log.Printf("Failed to close database: %v", err) + } + }() + + svc := service.NewFacultyRoomImportService( + repository.NewFacultyRepository(conn), + repository.NewRoomRepository(conn), + repository.NewFacultyRoomRepository(conn), + ) + + summary, err := svc.Import(context.Background(), rows) + if err != nil { + log.Fatalf("Failed to import faculty rooms: %v (unmatched_emails=%v unmatched_rooms=%v)", err, summary.UnmatchedEmails, summary.UnmatchedRooms) + } + log.Printf("import summary: inserted=%d skipped_blank=%d", summary.Inserted, summary.SkippedBlank) +} diff --git a/cmd/scrape-class-changes-job/main.go b/cmd/scrape-class-changes-job/main.go new file mode 100644 index 0000000..b145113 --- /dev/null +++ b/cmd/scrape-class-changes-job/main.go @@ -0,0 +1,73 @@ +package main + +import ( + "context" + "log" + "os" + "strconv" + "time" + + "github.com/fun-dotto/server/internal/modules/batch-jobs/repository" + "github.com/fun-dotto/server/internal/modules/batch-jobs/scraper" + "github.com/fun-dotto/server/internal/modules/batch-jobs/service" + "github.com/fun-dotto/server/internal/shared/db" + "github.com/joho/godotenv" +) + +func main() { + if err := godotenv.Load(); err != nil { + log.Printf("Warning: .env file not found: %v", err) + } + + userID := os.Getenv("SCRAPE_USER_ID") + password := os.Getenv("SCRAPE_USER_PASSWORD") + if userID == "" || password == "" { + log.Fatal("SCRAPE_USER_ID and SCRAPE_USER_PASSWORD must be set") + } + + year := scraper.CurrentNendo(time.Now()) + if v := os.Getenv("SCRAPE_YEAR"); v != "" { + parsed, err := strconv.Atoi(v) + if err != nil { + log.Fatalf("Invalid SCRAPE_YEAR %q: %v", v, err) + } + year = parsed + } + + conn, err := db.ConnectWithConnectorIAMAuthN() + if err != nil { + log.Fatalf("Failed to connect to database: %v", err) + } + defer func() { + if err := db.Close(conn); err != nil { + log.Printf("Failed to close database: %v", err) + } + }() + + client, err := scraper.NewClient(year) + if err != nil { + log.Fatalf("Failed to create scraper client: %v", err) + } + + svc := service.NewClassChangeScrapeService( + client, + repository.NewSubjectRepository(conn), + repository.NewRoomRepository(conn), + repository.NewCancelledClassRepository(conn), + repository.NewMakeupClassRepository(conn), + repository.NewRoomChangeRepository(conn), + ) + + summary, err := svc.Run(context.Background(), userID, password, year) + if err != nil { + log.Fatalf("Failed to scrape class changes: %v", err) + } + log.Printf( + "scrape summary: year=%d cancelled(inserted=%d duplicated=%d skipped=%d) makeup(inserted=%d duplicated=%d skipped=%d) room_change(inserted=%d duplicated=%d skipped=%d) unmatched(subjects=%d rooms=%d)", + year, + summary.CancelledInserted, summary.CancelledDuplicated, summary.CancelledSkipped, + summary.MakeupInserted, summary.MakeupDuplicated, summary.MakeupSkipped, + summary.RoomChangeInserted, summary.RoomChangeDuplicated, summary.RoomChangeSkipped, + len(summary.UnmatchedSubjectNames), len(summary.UnmatchedRoomNames), + ) +} diff --git a/go.mod b/go.mod index 21a1148..b76f9cc 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( ariga.io/atlas-provider-gorm v0.6.0 cloud.google.com/go/cloudsqlconn v1.21.0 firebase.google.com/go/v4 v4.19.0 + github.com/PuerkitoBio/goquery v1.10.3 github.com/getkin/kin-openapi v0.138.0 github.com/gin-contrib/timeout v1.2.1 github.com/gin-gonic/gin v1.12.0 @@ -16,6 +17,7 @@ require ( github.com/oapi-codegen/gin-middleware v1.0.2 github.com/oapi-codegen/runtime v1.4.0 github.com/stretchr/testify v1.11.1 + golang.org/x/text v0.36.0 gorm.io/driver/postgres v1.6.0 gorm.io/gorm v1.31.1 ) @@ -39,6 +41,7 @@ require ( github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 // indirect github.com/MicahParks/keyfunc v1.9.0 // indirect github.com/agext/levenshtein v1.2.1 // indirect + github.com/andybalholm/cascadia v1.3.3 // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect @@ -138,7 +141,6 @@ require ( golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.43.0 // indirect - golang.org/x/text v0.36.0 // indirect golang.org/x/time v0.15.0 // indirect golang.org/x/tools v0.43.0 // indirect google.golang.org/api v0.276.0 // indirect diff --git a/go.sum b/go.sum index eebba63..f6f971e 100644 --- a/go.sum +++ b/go.sum @@ -73,9 +73,13 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapp github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0/go.mod h1:cSgYe11MCNYunTnRXrKiR/tHc0eoKjICUuWpNZoVCOo= github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o= github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw= +github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo= +github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y= github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= github.com/agext/levenshtein v1.2.1 h1:QmvMAjj2aEICytGiWzmxoE0x2KZvE0fvmqMOfy2tjT8= github.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= +github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= +github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/apparentlymart/go-textseg/v13 v13.0.0 h1:Y+KvPE1NYz0xl601PVImeQfFyEy6iT90AvPUL1NNfNw= @@ -213,6 +217,7 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -427,10 +432,14 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -440,6 +449,9 @@ golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHl golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -463,9 +475,13 @@ golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.13.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA= golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -477,6 +493,10 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -503,11 +523,16 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -515,9 +540,13 @@ golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o= golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -530,6 +559,8 @@ golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= @@ -543,6 +574,8 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/modules/batch-jobs/domain/faculty.go b/internal/modules/batch-jobs/domain/faculty.go new file mode 100644 index 0000000..39633db --- /dev/null +++ b/internal/modules/batch-jobs/domain/faculty.go @@ -0,0 +1,7 @@ +package domain + +// Faculty は faculty_rooms インポート時に email で突合するための最小表現。 +type Faculty struct { + ID string + Email string +} diff --git a/internal/modules/batch-jobs/domain/faculty_room.go b/internal/modules/batch-jobs/domain/faculty_room.go new file mode 100644 index 0000000..0695e7e --- /dev/null +++ b/internal/modules/batch-jobs/domain/faculty_room.go @@ -0,0 +1,24 @@ +package domain + +// FacultyRoomImportRow は faculty_rooms 取り込み CSV の 1 行(year は --faculties オプションで指定)。 +type FacultyRoomImportRow struct { + Year int + Email string + RoomName string +} + +// FacultyRoomImportSummary は faculty_rooms 取り込みの結果集計。 +type FacultyRoomImportSummary struct { + Inserted int + SkippedBlank int + UnmatchedEmails []string + UnmatchedRooms []string +} + +// FacultyRoomInsert は email / room_name を faculty_id / room_id に解決済みの +// faculty_rooms 挿入対象 1 件を表す。 +type FacultyRoomInsert struct { + FacultyID string + RoomID string + Year int +} diff --git a/internal/modules/batch-jobs/domain/room.go b/internal/modules/batch-jobs/domain/room.go new file mode 100644 index 0000000..7012597 --- /dev/null +++ b/internal/modules/batch-jobs/domain/room.go @@ -0,0 +1,7 @@ +package domain + +// RoomRef は教室名照合に使う rooms テーブルの最小表現。 +type RoomRef struct { + ID string + Name string +} diff --git a/internal/modules/batch-jobs/domain/scraped_class_change.go b/internal/modules/batch-jobs/domain/scraped_class_change.go new file mode 100644 index 0000000..e2245a9 --- /dev/null +++ b/internal/modules/batch-jobs/domain/scraped_class_change.go @@ -0,0 +1,46 @@ +package domain + +import "time" + +// CancelledClassKind は休講コメントの接頭辞から判定した補講予定の種別を表す。 +type CancelledClassKind string + +const ( + CancelledClassKindMakeupScheduled CancelledClassKind = "補講あり" + CancelledClassKindMakeupNotPlanned CancelledClassKind = "補講なし" + CancelledClassKindMakeupUndecided CancelledClassKind = "補講未定" + CancelledClassKindOther CancelledClassKind = "その他" +) + +// ScrapedCancelledClass は休講テーブルの 1 行をパースした中間表現。 +type ScrapedCancelledClass struct { + Date time.Time + Period string + LessonName string + Campus string + Staff string + Comment string + Kind CancelledClassKind +} + +// ScrapedMakeupClass は補講テーブルの 1 行をパースした中間表現。 +type ScrapedMakeupClass struct { + Date time.Time + Period string + LessonName string + Campus string + Staff string + Comment string + RoomName string +} + +// ScrapedRoomChange は部屋変更テーブルの 1 行をパースした中間表現。 +type ScrapedRoomChange struct { + Date time.Time + Period string + LessonName string + Campus string + Staff string + RoomFromName string + RoomToName string +} diff --git a/internal/modules/batch-jobs/domain/subject.go b/internal/modules/batch-jobs/domain/subject.go new file mode 100644 index 0000000..a850047 --- /dev/null +++ b/internal/modules/batch-jobs/domain/subject.go @@ -0,0 +1,8 @@ +package domain + +// SubjectRef は科目名照合に使う subjects テーブルの最小表現。 +type SubjectRef struct { + ID string + SyllabusID string + Name string +} diff --git a/internal/modules/batch-jobs/repository/cancelled_class.go b/internal/modules/batch-jobs/repository/cancelled_class.go new file mode 100644 index 0000000..0f432e6 --- /dev/null +++ b/internal/modules/batch-jobs/repository/cancelled_class.go @@ -0,0 +1,11 @@ +package repository + +import "gorm.io/gorm" + +type CancelledClassRepository struct { + db *gorm.DB +} + +func NewCancelledClassRepository(db *gorm.DB) *CancelledClassRepository { + return &CancelledClassRepository{db: db} +} diff --git a/internal/modules/batch-jobs/repository/cancelled_class_exists.go b/internal/modules/batch-jobs/repository/cancelled_class_exists.go new file mode 100644 index 0000000..1c9c312 --- /dev/null +++ b/internal/modules/batch-jobs/repository/cancelled_class_exists.go @@ -0,0 +1,17 @@ +package repository + +import ( + "context" + "time" + + "github.com/fun-dotto/server/internal/shared/model" +) + +// Exists は (subject_id, date, period) の自然キーで既存レコードの有無を返す。 +func (r *CancelledClassRepository) Exists(ctx context.Context, subjectID string, date time.Time, period string) (bool, error) { + var count int64 + err := r.db.WithContext(ctx).Model(&model.CancelledClass{}). + Where("subject_id = ? AND date = ? AND period = ?", parseUUIDOrNil(subjectID), date.Format(dateLayout), period). + Count(&count).Error + return count > 0, err +} diff --git a/internal/modules/batch-jobs/repository/cancelled_class_insert.go b/internal/modules/batch-jobs/repository/cancelled_class_insert.go new file mode 100644 index 0000000..aa5d697 --- /dev/null +++ b/internal/modules/batch-jobs/repository/cancelled_class_insert.go @@ -0,0 +1,16 @@ +package repository + +import ( + "context" + + academicdomain "github.com/fun-dotto/server/internal/modules/academic/domain" +) + +// Insert は休講レコードを 1 件挿入する。自然キーの重複チェックは呼び出し側(Exists)で行う。 +func (r *CancelledClassRepository) Insert(ctx context.Context, cc academicdomain.CancelledClass) (academicdomain.CancelledClass, error) { + record := cancelledClassFromDomain(cc) + if err := r.db.WithContext(ctx).Create(&record).Error; err != nil { + return academicdomain.CancelledClass{}, err + } + return cancelledClassToDomain(record), nil +} diff --git a/internal/modules/batch-jobs/repository/convert.go b/internal/modules/batch-jobs/repository/convert.go new file mode 100644 index 0000000..138c566 --- /dev/null +++ b/internal/modules/batch-jobs/repository/convert.go @@ -0,0 +1,108 @@ +package repository + +import ( + "time" + + academicdomain "github.com/fun-dotto/server/internal/modules/academic/domain" + "github.com/fun-dotto/server/internal/shared/model" + "github.com/google/uuid" +) + +const dateLayout = "2006-01-02" + +// parseUUIDOrNil は文字列を uuid.UUID に変換する。パース不能な場合は uuid.Nil を返す。 +func parseUUIDOrNil(s string) uuid.UUID { + id, err := uuid.Parse(s) + if err != nil { + return uuid.Nil + } + return id +} + +func cancelledClassFromDomain(d academicdomain.CancelledClass) model.CancelledClass { + var comment *string + if d.Comment != "" { + comment = &d.Comment + } + return model.CancelledClass{ + SubjectID: parseUUIDOrNil(d.Subject.ID), + Date: parseDomainDate(d.Date), + Period: string(d.Period), + Comment: comment, + } +} + +func makeupClassFromDomain(d academicdomain.MakeupClass) model.MakeupClass { + var comment *string + if d.Comment != "" { + comment = &d.Comment + } + return model.MakeupClass{ + SubjectID: parseUUIDOrNil(d.Subject.ID), + Date: parseDomainDate(d.Date), + Period: string(d.Period), + Comment: comment, + } +} + +func roomChangeFromDomain(d academicdomain.RoomChange) model.RoomChange { + return model.RoomChange{ + SubjectID: parseUUIDOrNil(d.Subject.ID), + Date: parseDomainDate(d.Date), + Period: string(d.Period), + OriginalRoomID: parseUUIDOrNil(d.OriginalRoom.ID), + NewRoomID: parseUUIDOrNil(d.NewRoom.ID), + } +} + +func cancelledClassToDomain(m model.CancelledClass) academicdomain.CancelledClass { + d := academicdomain.CancelledClass{ + ID: m.ID.String(), + Date: m.Date.Format(dateLayout), + Period: academicdomain.Period(m.Period), + } + if m.Comment != nil { + d.Comment = *m.Comment + } + d.Subject = academicdomain.Subject{ID: m.SubjectID.String()} + return d +} + +func makeupClassToDomain(m model.MakeupClass) academicdomain.MakeupClass { + d := academicdomain.MakeupClass{ + ID: m.ID.String(), + Date: m.Date.Format(dateLayout), + Period: academicdomain.Period(m.Period), + } + if m.Comment != nil { + d.Comment = *m.Comment + } + d.Subject = academicdomain.Subject{ID: m.SubjectID.String()} + return d +} + +func roomChangeToDomain(m model.RoomChange) academicdomain.RoomChange { + return academicdomain.RoomChange{ + ID: m.ID.String(), + Date: m.Date.Format(dateLayout), + Period: academicdomain.Period(m.Period), + Subject: academicdomain.Subject{ID: m.SubjectID.String()}, + OriginalRoom: academicdomain.Room{ID: m.OriginalRoomID.String()}, + NewRoom: academicdomain.Room{ID: m.NewRoomID.String()}, + } +} + +func facultyRoomFromDomain(facultyID, roomID string, year int) model.FacultyRoom { + return model.FacultyRoom{ + FacultyID: parseUUIDOrNil(facultyID), + RoomID: parseUUIDOrNil(roomID), + Year: year, + } +} + +// parseDomainDate は domain 層の YYYY-MM-DD 文字列を time.Time に変換する。 +// パース不能な場合は zero value を返す。 +func parseDomainDate(s string) time.Time { + t, _ := time.Parse(dateLayout, s) + return t +} diff --git a/internal/modules/batch-jobs/repository/faculty.go b/internal/modules/batch-jobs/repository/faculty.go new file mode 100644 index 0000000..a17c699 --- /dev/null +++ b/internal/modules/batch-jobs/repository/faculty.go @@ -0,0 +1,11 @@ +package repository + +import "gorm.io/gorm" + +type FacultyRepository struct { + db *gorm.DB +} + +func NewFacultyRepository(db *gorm.DB) *FacultyRepository { + return &FacultyRepository{db: db} +} diff --git a/internal/modules/batch-jobs/repository/faculty_list.go b/internal/modules/batch-jobs/repository/faculty_list.go new file mode 100644 index 0000000..8c625f2 --- /dev/null +++ b/internal/modules/batch-jobs/repository/faculty_list.go @@ -0,0 +1,22 @@ +package repository + +import ( + "context" + + "github.com/fun-dotto/server/internal/modules/batch-jobs/domain" + "github.com/fun-dotto/server/internal/shared/model" +) + +// ListFaculties は email 照合に使う (id, email) を全件返す。 +func (r *FacultyRepository) ListFaculties(ctx context.Context) ([]domain.Faculty, error) { + var records []model.Faculty + if err := r.db.WithContext(ctx).Select("id", "email").Find(&records).Error; err != nil { + return nil, err + } + + faculties := make([]domain.Faculty, len(records)) + for i, rec := range records { + faculties[i] = domain.Faculty{ID: rec.ID.String(), Email: rec.Email} + } + return faculties, nil +} diff --git a/internal/modules/batch-jobs/repository/faculty_room.go b/internal/modules/batch-jobs/repository/faculty_room.go new file mode 100644 index 0000000..9d77b28 --- /dev/null +++ b/internal/modules/batch-jobs/repository/faculty_room.go @@ -0,0 +1,11 @@ +package repository + +import "gorm.io/gorm" + +type FacultyRoomRepository struct { + db *gorm.DB +} + +func NewFacultyRoomRepository(db *gorm.DB) *FacultyRoomRepository { + return &FacultyRoomRepository{db: db} +} diff --git a/internal/modules/batch-jobs/repository/faculty_room_insert_batch.go b/internal/modules/batch-jobs/repository/faculty_room_insert_batch.go new file mode 100644 index 0000000..ee566e3 --- /dev/null +++ b/internal/modules/batch-jobs/repository/faculty_room_insert_batch.go @@ -0,0 +1,26 @@ +package repository + +import ( + "context" + + "github.com/fun-dotto/server/internal/modules/batch-jobs/domain" + "github.com/fun-dotto/server/internal/shared/model" + "gorm.io/gorm" +) + +// InsertBatch は faculty_rooms への一括 INSERT を 1 トランザクションで行う。 +// (faculty_id, room_id, year) の UNIQUE 制約に違反した場合はロールバックしエラーを返す。 +func (r *FacultyRoomRepository) InsertBatch(ctx context.Context, rows []domain.FacultyRoomInsert) error { + if len(rows) == 0 { + return nil + } + + records := make([]model.FacultyRoom, len(rows)) + for i, row := range rows { + records[i] = facultyRoomFromDomain(row.FacultyID, row.RoomID, row.Year) + } + + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return tx.Create(&records).Error + }) +} diff --git a/internal/modules/batch-jobs/repository/makeup_class.go b/internal/modules/batch-jobs/repository/makeup_class.go new file mode 100644 index 0000000..db7447d --- /dev/null +++ b/internal/modules/batch-jobs/repository/makeup_class.go @@ -0,0 +1,11 @@ +package repository + +import "gorm.io/gorm" + +type MakeupClassRepository struct { + db *gorm.DB +} + +func NewMakeupClassRepository(db *gorm.DB) *MakeupClassRepository { + return &MakeupClassRepository{db: db} +} diff --git a/internal/modules/batch-jobs/repository/makeup_class_exists.go b/internal/modules/batch-jobs/repository/makeup_class_exists.go new file mode 100644 index 0000000..17df3fe --- /dev/null +++ b/internal/modules/batch-jobs/repository/makeup_class_exists.go @@ -0,0 +1,17 @@ +package repository + +import ( + "context" + "time" + + "github.com/fun-dotto/server/internal/shared/model" +) + +// Exists は (subject_id, date, period) の自然キーで既存レコードの有無を返す。 +func (r *MakeupClassRepository) Exists(ctx context.Context, subjectID string, date time.Time, period string) (bool, error) { + var count int64 + err := r.db.WithContext(ctx).Model(&model.MakeupClass{}). + Where("subject_id = ? AND date = ? AND period = ?", parseUUIDOrNil(subjectID), date.Format(dateLayout), period). + Count(&count).Error + return count > 0, err +} diff --git a/internal/modules/batch-jobs/repository/makeup_class_insert.go b/internal/modules/batch-jobs/repository/makeup_class_insert.go new file mode 100644 index 0000000..9f42c67 --- /dev/null +++ b/internal/modules/batch-jobs/repository/makeup_class_insert.go @@ -0,0 +1,16 @@ +package repository + +import ( + "context" + + academicdomain "github.com/fun-dotto/server/internal/modules/academic/domain" +) + +// Insert は補講レコードを 1 件挿入する。自然キーの重複チェックは呼び出し側(Exists)で行う。 +func (r *MakeupClassRepository) Insert(ctx context.Context, mc academicdomain.MakeupClass) (academicdomain.MakeupClass, error) { + record := makeupClassFromDomain(mc) + if err := r.db.WithContext(ctx).Create(&record).Error; err != nil { + return academicdomain.MakeupClass{}, err + } + return makeupClassToDomain(record), nil +} diff --git a/internal/modules/batch-jobs/repository/room.go b/internal/modules/batch-jobs/repository/room.go new file mode 100644 index 0000000..a76d9c4 --- /dev/null +++ b/internal/modules/batch-jobs/repository/room.go @@ -0,0 +1,11 @@ +package repository + +import "gorm.io/gorm" + +type RoomRepository struct { + db *gorm.DB +} + +func NewRoomRepository(db *gorm.DB) *RoomRepository { + return &RoomRepository{db: db} +} diff --git a/internal/modules/batch-jobs/repository/room_change.go b/internal/modules/batch-jobs/repository/room_change.go new file mode 100644 index 0000000..cc12ba1 --- /dev/null +++ b/internal/modules/batch-jobs/repository/room_change.go @@ -0,0 +1,11 @@ +package repository + +import "gorm.io/gorm" + +type RoomChangeRepository struct { + db *gorm.DB +} + +func NewRoomChangeRepository(db *gorm.DB) *RoomChangeRepository { + return &RoomChangeRepository{db: db} +} diff --git a/internal/modules/batch-jobs/repository/room_change_exists.go b/internal/modules/batch-jobs/repository/room_change_exists.go new file mode 100644 index 0000000..2581f77 --- /dev/null +++ b/internal/modules/batch-jobs/repository/room_change_exists.go @@ -0,0 +1,21 @@ +package repository + +import ( + "context" + "time" + + "github.com/fun-dotto/server/internal/shared/model" +) + +// Exists は (subject_id, date, period, original_room_id, new_room_id) の自然キーで +// 既存レコードの有無を返す。 +func (r *RoomChangeRepository) Exists(ctx context.Context, subjectID string, date time.Time, period, originalRoomID, newRoomID string) (bool, error) { + var count int64 + err := r.db.WithContext(ctx).Model(&model.RoomChange{}). + Where( + "subject_id = ? AND date = ? AND period = ? AND original_room_id = ? AND new_room_id = ?", + parseUUIDOrNil(subjectID), date.Format(dateLayout), period, parseUUIDOrNil(originalRoomID), parseUUIDOrNil(newRoomID), + ). + Count(&count).Error + return count > 0, err +} diff --git a/internal/modules/batch-jobs/repository/room_change_insert.go b/internal/modules/batch-jobs/repository/room_change_insert.go new file mode 100644 index 0000000..011a109 --- /dev/null +++ b/internal/modules/batch-jobs/repository/room_change_insert.go @@ -0,0 +1,16 @@ +package repository + +import ( + "context" + + academicdomain "github.com/fun-dotto/server/internal/modules/academic/domain" +) + +// Insert は部屋変更レコードを 1 件挿入する。自然キーの重複チェックは呼び出し側(Exists)で行う。 +func (r *RoomChangeRepository) Insert(ctx context.Context, rc academicdomain.RoomChange) (academicdomain.RoomChange, error) { + record := roomChangeFromDomain(rc) + if err := r.db.WithContext(ctx).Create(&record).Error; err != nil { + return academicdomain.RoomChange{}, err + } + return roomChangeToDomain(record), nil +} diff --git a/internal/modules/batch-jobs/repository/room_list.go b/internal/modules/batch-jobs/repository/room_list.go new file mode 100644 index 0000000..9350945 --- /dev/null +++ b/internal/modules/batch-jobs/repository/room_list.go @@ -0,0 +1,22 @@ +package repository + +import ( + "context" + + "github.com/fun-dotto/server/internal/modules/batch-jobs/domain" + "github.com/fun-dotto/server/internal/shared/model" +) + +// ListRooms は教室名照合に使う (id, name) を全件返す。 +func (r *RoomRepository) ListRooms(ctx context.Context) ([]domain.RoomRef, error) { + var records []model.Room + if err := r.db.WithContext(ctx).Select("id", "name").Find(&records).Error; err != nil { + return nil, err + } + + refs := make([]domain.RoomRef, len(records)) + for i, rec := range records { + refs[i] = domain.RoomRef{ID: rec.ID.String(), Name: rec.Name} + } + return refs, nil +} diff --git a/internal/modules/batch-jobs/repository/subject.go b/internal/modules/batch-jobs/repository/subject.go new file mode 100644 index 0000000..95e4176 --- /dev/null +++ b/internal/modules/batch-jobs/repository/subject.go @@ -0,0 +1,11 @@ +package repository + +import "gorm.io/gorm" + +type SubjectRepository struct { + db *gorm.DB +} + +func NewSubjectRepository(db *gorm.DB) *SubjectRepository { + return &SubjectRepository{db: db} +} diff --git a/internal/modules/batch-jobs/repository/subject_list_syllabus_names.go b/internal/modules/batch-jobs/repository/subject_list_syllabus_names.go new file mode 100644 index 0000000..0595b71 --- /dev/null +++ b/internal/modules/batch-jobs/repository/subject_list_syllabus_names.go @@ -0,0 +1,26 @@ +package repository + +import ( + "context" + + "github.com/fun-dotto/server/internal/modules/batch-jobs/domain" + "github.com/fun-dotto/server/internal/shared/model" +) + +// ListSyllabusNames は科目名照合・subject_id 解決に使う (id, syllabus_id, name) を全件返す。 +func (r *SubjectRepository) ListSyllabusNames(ctx context.Context) ([]domain.SubjectRef, error) { + var records []model.Subject + if err := r.db.WithContext(ctx).Select("id", "syllabus_id", "name").Find(&records).Error; err != nil { + return nil, err + } + + refs := make([]domain.SubjectRef, len(records)) + for i, rec := range records { + refs[i] = domain.SubjectRef{ + ID: rec.ID.String(), + SyllabusID: rec.SyllabusID, + Name: rec.Name, + } + } + return refs, nil +} diff --git a/internal/modules/batch-jobs/scraper/client.go b/internal/modules/batch-jobs/scraper/client.go new file mode 100644 index 0000000..e007876 --- /dev/null +++ b/internal/modules/batch-jobs/scraper/client.go @@ -0,0 +1,131 @@ +package scraper + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/cookiejar" + "net/url" + "strconv" + "strings" + "time" + + "github.com/PuerkitoBio/goquery" +) + +const ( + loginURL = "https://students.fun.ac.jp/Login" + classChangeListURL = "https://students.fun.ac.jp/Pt/CSLecture" + + // loginPostDelay はログイン直後の遷移待ち。Python 版に合わせて 2 秒固定する。 + loginPostDelay = 2 * time.Second +) + +// hiddenFieldNames は ASP.NET WebForms のログインフォームに含まれる隠しフィールド。 +var hiddenFieldNames = []string{"__VIEWSTATE", "__VIEWSTATEGENERATOR", "__EVENTVALIDATION"} + +// Client は学生ポータル(students.fun.ac.jp)へログインし、休講・補講・部屋変更の +// 一覧ページを取得する。 +type Client struct { + httpClient *http.Client + year int +} + +// NewClient は対象年度 year(ログインフォームの TargetYearList に使う)を指定して Client を作る。 +func NewClient(year int) (*Client, error) { + jar, err := cookiejar.New(nil) + if err != nil { + return nil, fmt.Errorf("create cookie jar: %w", err) + } + return &Client{ + httpClient: &http.Client{Jar: jar, Timeout: 30 * time.Second}, + year: year, + }, nil +} + +// FetchClassChangeHTML はログインしたのち /Pt/CSLecture の HTML を取得する。 +func (c *Client) FetchClassChangeHTML(ctx context.Context, userID, password string) (string, error) { + if err := c.login(ctx, userID, password); err != nil { + return "", fmt.Errorf("login: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, classChangeListURL, nil) + if err != nil { + return "", err + } + resp, err := c.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("fetch class change list: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("fetch class change list: unexpected status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("read class change list body: %w", err) + } + return string(body), nil +} + +// login はログインページから隠しフィールドを取得し、認証情報とともに POST する。 +func (c *Client) login(ctx context.Context, userID, password string) error { + getReq, err := http.NewRequestWithContext(ctx, http.MethodGet, loginURL, nil) + if err != nil { + return err + } + getResp, err := c.httpClient.Do(getReq) + if err != nil { + return fmt.Errorf("fetch login page: %w", err) + } + defer func() { _ = getResp.Body.Close() }() + if getResp.StatusCode != http.StatusOK { + return fmt.Errorf("fetch login page: unexpected status %d", getResp.StatusCode) + } + + doc, err := goquery.NewDocumentFromReader(getResp.Body) + if err != nil { + return fmt.Errorf("parse login page: %w", err) + } + + form := url.Values{} + form.Set("__LASTFOCUS", "") + form.Set("__EVENTTARGET", "") + form.Set("__EVENTARGUMENT", "") + form.Set("__SCROLLPOSITIONX", "0") + form.Set("__SCROLLPOSITIONY", "0") + form.Set("ctl00$MainContent$TargetYearList", strconv.Itoa(c.year)) + form.Set("ctl00$MainContent$TargetTermList", "11") + form.Set("ctl00$MainContent$LoginId", userID) + form.Set("ctl00$MainContent$LoginPassword", password) + form.Set("ctl00$MainContent$LoginButton", "ログイン") + for _, name := range hiddenFieldNames { + if v, ok := doc.Find(fmt.Sprintf(`[name="%s"]`, name)).Attr("value"); ok { + form.Set(name, v) + } + } + + postReq, err := http.NewRequestWithContext(ctx, http.MethodPost, loginURL, strings.NewReader(form.Encode())) + if err != nil { + return err + } + postReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + postResp, err := c.httpClient.Do(postReq) + if err != nil { + return fmt.Errorf("post login form: %w", err) + } + defer func() { _ = postResp.Body.Close() }() + if _, err := io.Copy(io.Discard, postResp.Body); err != nil { + return fmt.Errorf("drain login response: %w", err) + } + + // ポータル側のセッション確立を待つ。Python 版と同じ固定 2 秒。 + select { + case <-time.After(loginPostDelay): + case <-ctx.Done(): + return ctx.Err() + } + return nil +} diff --git a/internal/modules/batch-jobs/scraper/parser.go b/internal/modules/batch-jobs/scraper/parser.go new file mode 100644 index 0000000..d5db3b6 --- /dev/null +++ b/internal/modules/batch-jobs/scraper/parser.go @@ -0,0 +1,161 @@ +package scraper + +import ( + "fmt" + "strconv" + "strings" + "time" + + "github.com/PuerkitoBio/goquery" + "github.com/fun-dotto/server/internal/modules/batch-jobs/domain" +) + +const monthDayLayout = "01/02" + +// columnTitle は各テーブルの td[data-col-responsive-title] に対応する列名。 +const ( + colDate = "日付" + colDayOfWeek = "曜日" + colPeriod = "時限" + colLessonName = "授業名" + colCampus = "キャンパス" + colStaff = "代表教職員" + colCancelledComment = "休講コメント" + colMakeupComment = "補講コメント" + colRoomName = "教室名" + colRoomFrom = "移動元" + colRoomTo = "移動先" +) + +// ParseClassChanges は /Pt/CSLecture の HTML から休講・補講・部屋変更を抽出する。 +// year は対象年度(4/1 起点)で、"MM/DD" の日付を年度内に収まるよう補正するために使う。 +func ParseClassChanges(html string, year int) ([]domain.ScrapedCancelledClass, []domain.ScrapedMakeupClass, []domain.ScrapedRoomChange, error) { + doc, err := goquery.NewDocumentFromReader(strings.NewReader(html)) + if err != nil { + return nil, nil, nil, fmt.Errorf("parse html: %w", err) + } + + var cancelled []domain.ScrapedCancelledClass + var makeup []domain.ScrapedMakeupClass + var roomChanges []domain.ScrapedRoomChange + + doc.Find("tr").Each(func(_ int, row *goquery.Selection) { + date, period, lessonName, campus, staff, ok := parseCommonColumns(row, year) + if !ok { + return + } + + if comment, ok := cellText(row, colCancelledComment); ok { + cancelled = append(cancelled, domain.ScrapedCancelledClass{ + Date: date, + Period: period, + LessonName: lessonName, + Campus: campus, + Staff: staff, + Comment: comment, + Kind: classifyCancelledComment(comment), + }) + return + } + + if comment, ok := cellText(row, colMakeupComment); ok { + roomName, _ := cellText(row, colRoomName) + makeup = append(makeup, domain.ScrapedMakeupClass{ + Date: date, + Period: period, + LessonName: lessonName, + Campus: campus, + Staff: staff, + Comment: comment, + RoomName: roomName, + }) + return + } + + roomFrom, hasFrom := cellText(row, colRoomFrom) + roomTo, hasTo := cellText(row, colRoomTo) + if hasFrom && hasTo { + roomChanges = append(roomChanges, domain.ScrapedRoomChange{ + Date: date, + Period: period, + LessonName: lessonName, + Campus: campus, + Staff: staff, + RoomFromName: roomFrom, + RoomToName: roomTo, + }) + } + }) + + return cancelled, makeup, roomChanges, nil +} + +// parseCommonColumns は 3 種のテーブルに共通する日付・時限・授業名・キャンパス・代表教職員を読む。 +// 曜日列の有無を「対象行」かどうかの判定に使う(見出し行やその他の tr を除外する)。 +func parseCommonColumns(row *goquery.Selection, year int) (date time.Time, period, lessonName, campus, staff string, ok bool) { + if _, hasDay := cellText(row, colDayOfWeek); !hasDay { + return time.Time{}, "", "", "", "", false + } + + dateText, hasDate := cellText(row, colDate) + periodText, hasPeriod := cellText(row, colPeriod) + lessonName, hasLesson := cellText(row, colLessonName) + campus, hasCampus := cellText(row, colCampus) + staff, hasStaff := cellText(row, colStaff) + if !hasDate || !hasPeriod || !hasLesson || !hasCampus || !hasStaff { + return time.Time{}, "", "", "", "", false + } + + monthDay, err := time.Parse(monthDayLayout, dateText) + if err != nil { + return time.Time{}, "", "", "", "", false + } + date = resolveNendoDate(monthDay, year) + + period, ok = formatPeriod(periodText) + if !ok { + return time.Time{}, "", "", "", "", false + } + + return date, period, lessonName, campus, staff, true +} + +// cellText は td[data-col-responsive-title=name] のテキストを取得する。列が存在しなければ ok=false。 +func cellText(row *goquery.Selection, name string) (string, bool) { + cell := row.Find(fmt.Sprintf(`td[data-col-responsive-title="%s"]`, name)) + if cell.Length() == 0 { + return "", false + } + return strings.TrimSpace(cell.First().Text()), true +} + +// formatPeriod は "1時限" 等の時限表示の先頭の数字から "Period{n}" を作る。 +func formatPeriod(s string) (string, bool) { + trimmed := strings.TrimSpace(s) + if trimmed == "" { + return "", false + } + first := []rune(trimmed)[0] + if first < '0' || first > '9' { + return "", false + } + n, err := strconv.Atoi(string(first)) + if err != nil { + return "", false + } + return fmt.Sprintf("Period%d", n), true +} + +// classifyCancelledComment は休講コメントの接頭辞から補講予定の種別を判定する。 +func classifyCancelledComment(comment string) domain.CancelledClassKind { + switch { + case strings.HasPrefix(comment, "補講あり"): + return domain.CancelledClassKindMakeupScheduled + case strings.HasPrefix(comment, "補講なし"): + return domain.CancelledClassKindMakeupNotPlanned + case strings.HasPrefix(comment, "補講未定"): + return domain.CancelledClassKindMakeupUndecided + default: + return domain.CancelledClassKindOther + } +} diff --git a/internal/modules/batch-jobs/scraper/parser_test.go b/internal/modules/batch-jobs/scraper/parser_test.go new file mode 100644 index 0000000..c95f737 --- /dev/null +++ b/internal/modules/batch-jobs/scraper/parser_test.go @@ -0,0 +1,85 @@ +package scraper + +import ( + "os" + "testing" + "time" + + "github.com/fun-dotto/server/internal/modules/batch-jobs/domain" +) + +func TestParseClassChanges(t *testing.T) { + html, err := os.ReadFile("testdata/class_changes.html") + if err != nil { + t.Fatalf("read testdata: %v", err) + } + + cancelled, makeup, roomChanges, err := ParseClassChanges(string(html), 2026) + if err != nil { + t.Fatalf("ParseClassChanges: %v", err) + } + + if len(cancelled) != 1 { + t.Fatalf("cancelled: got %d rows, want 1", len(cancelled)) + } + c := cancelled[0] + wantDate := time.Date(2026, time.April, 10, 0, 0, 0, 0, time.UTC) + if !c.Date.Equal(wantDate) { + t.Errorf("cancelled date = %v, want %v", c.Date, wantDate) + } + if c.Period != "Period3" { + t.Errorf("cancelled period = %q, want Period3", c.Period) + } + if c.LessonName != "情報科学演習" { + t.Errorf("cancelled lesson name = %q", c.LessonName) + } + if c.Kind != domain.CancelledClassKindMakeupScheduled { + t.Errorf("cancelled kind = %q, want %q", c.Kind, domain.CancelledClassKindMakeupScheduled) + } + + if len(makeup) != 1 { + t.Fatalf("makeup: got %d rows, want 1", len(makeup)) + } + m := makeup[0] + if m.RoomName != "R781" { + t.Errorf("makeup room name = %q, want R781", m.RoomName) + } + if m.Period != "Period1" { + t.Errorf("makeup period = %q, want Period1", m.Period) + } + + if len(roomChanges) != 1 { + t.Fatalf("room changes: got %d rows, want 1", len(roomChanges)) + } + rc := roomChanges[0] + // 03/02 は年度 2026(4/1始まり)の範囲外なので +1 年に補正される。 + wantRCDate := time.Date(2027, time.March, 2, 0, 0, 0, 0, time.UTC) + if !rc.Date.Equal(wantRCDate) { + t.Errorf("room change date = %v, want %v", rc.Date, wantRCDate) + } + if rc.RoomFromName != "493" || rc.RoomToName != "R781" { + t.Errorf("room change from/to = %q/%q", rc.RoomFromName, rc.RoomToName) + } + if rc.LessonName != "データ構造(旧:データ構造とアルゴリズム)" { + t.Errorf("room change lesson name = %q", rc.LessonName) + } +} + +func TestFormatPeriod(t *testing.T) { + tests := []struct { + in string + want string + wantOK bool + }{ + {in: "1時限", want: "Period1", wantOK: true}, + {in: "6", want: "Period6", wantOK: true}, + {in: "", want: "", wantOK: false}, + {in: "時限不明", want: "", wantOK: false}, + } + for _, tt := range tests { + got, ok := formatPeriod(tt.in) + if ok != tt.wantOK || got != tt.want { + t.Errorf("formatPeriod(%q) = (%q, %v), want (%q, %v)", tt.in, got, ok, tt.want, tt.wantOK) + } + } +} diff --git a/internal/modules/batch-jobs/scraper/testdata/class_changes.html b/internal/modules/batch-jobs/scraper/testdata/class_changes.html new file mode 100644 index 0000000..586ee2f --- /dev/null +++ b/internal/modules/batch-jobs/scraper/testdata/class_changes.html @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + +
04/103時限情報科学演習本キャンパス山田太郎補講あり:後日案内
+ + + + + + + + + + + + + +
04/201時限情報科学演習本キャンパスR781山田太郎4/10 3限の補講
+ + + + + + + + + + + + + +
03/022時限データ構造(旧:データ構造とアルゴリズム)本キャンパス鈴木花子493R781
+ + diff --git a/internal/modules/batch-jobs/scraper/year.go b/internal/modules/batch-jobs/scraper/year.go new file mode 100644 index 0000000..eedef23 --- /dev/null +++ b/internal/modules/batch-jobs/scraper/year.go @@ -0,0 +1,34 @@ +package scraper + +import "time" + +// nendoStart は年度(4 月始まり)の開始日を返す。 +func nendoStart(year int) time.Time { + return time.Date(year, time.April, 1, 0, 0, 0, 0, time.UTC) +} + +// nendoEnd は年度(4 月始まり)の終了日を返す。 +func nendoEnd(year int) time.Time { + return time.Date(year+1, time.March, 31, 0, 0, 0, 0, time.UTC) +} + +// CurrentNendo は現在日時を基準とした年度(4/1 起点)を返す。 +func CurrentNendo(now time.Time) int { + if now.Month() < time.April { + return now.Year() - 1 + } + return now.Year() +} + +// resolveNendoDate は "MM/DD" 形式の日付をポータルの表示年度 year の範囲 +// (nendoStart(year) から nendoEnd(year) まで)に収まるよう年を補正する。 +func resolveNendoDate(monthDay time.Time, year int) time.Time { + candidate := time.Date(year, monthDay.Month(), monthDay.Day(), 0, 0, 0, 0, time.UTC) + if candidate.Before(nendoStart(year)) { + candidate = time.Date(year+1, monthDay.Month(), monthDay.Day(), 0, 0, 0, 0, time.UTC) + } + if candidate.After(nendoEnd(year)) { + candidate = time.Date(year-1, monthDay.Month(), monthDay.Day(), 0, 0, 0, 0, time.UTC) + } + return candidate +} diff --git a/internal/modules/batch-jobs/service/class_change_scrape.go b/internal/modules/batch-jobs/service/class_change_scrape.go new file mode 100644 index 0000000..dadcdff --- /dev/null +++ b/internal/modules/batch-jobs/service/class_change_scrape.go @@ -0,0 +1,67 @@ +package service + +import ( + "context" + "time" + + academicdomain "github.com/fun-dotto/server/internal/modules/academic/domain" + "github.com/fun-dotto/server/internal/modules/batch-jobs/domain" +) + +// ScraperClient は学生ポータルへログインし休講/補講/部屋変更一覧の HTML を取得する。 +// 実装は internal/modules/batch-jobs/scraper.Client。 +type ScraperClient interface { + FetchClassChangeHTML(ctx context.Context, userID, password string) (string, error) +} + +type ScrapeSubjectRepository interface { + ListSyllabusNames(ctx context.Context) ([]domain.SubjectRef, error) +} + +type ScrapeRoomRepository interface { + ListRooms(ctx context.Context) ([]domain.RoomRef, error) +} + +type ScrapeCancelledClassRepository interface { + Exists(ctx context.Context, subjectID string, date time.Time, period string) (bool, error) + Insert(ctx context.Context, cc academicdomain.CancelledClass) (academicdomain.CancelledClass, error) +} + +type ScrapeMakeupClassRepository interface { + Exists(ctx context.Context, subjectID string, date time.Time, period string) (bool, error) + Insert(ctx context.Context, mc academicdomain.MakeupClass) (academicdomain.MakeupClass, error) +} + +type ScrapeRoomChangeRepository interface { + Exists(ctx context.Context, subjectID string, date time.Time, period, originalRoomID, newRoomID string) (bool, error) + Insert(ctx context.Context, rc academicdomain.RoomChange) (academicdomain.RoomChange, error) +} + +// ClassChangeScrapeService は学生ポータルから休講/補講/部屋変更をスクレイプし、 +// subjects/rooms と突合したうえで冪等に DB へ反映する。 +type ClassChangeScrapeService struct { + client ScraperClient + subjects ScrapeSubjectRepository + rooms ScrapeRoomRepository + cancelled ScrapeCancelledClassRepository + makeup ScrapeMakeupClassRepository + roomChange ScrapeRoomChangeRepository +} + +func NewClassChangeScrapeService( + client ScraperClient, + subjects ScrapeSubjectRepository, + rooms ScrapeRoomRepository, + cancelled ScrapeCancelledClassRepository, + makeup ScrapeMakeupClassRepository, + roomChange ScrapeRoomChangeRepository, +) *ClassChangeScrapeService { + return &ClassChangeScrapeService{ + client: client, + subjects: subjects, + rooms: rooms, + cancelled: cancelled, + makeup: makeup, + roomChange: roomChange, + } +} diff --git a/internal/modules/batch-jobs/service/class_change_scrape_matcher.go b/internal/modules/batch-jobs/service/class_change_scrape_matcher.go new file mode 100644 index 0000000..b81610c --- /dev/null +++ b/internal/modules/batch-jobs/service/class_change_scrape_matcher.go @@ -0,0 +1,65 @@ +package service + +import ( + "github.com/fun-dotto/server/internal/modules/batch-jobs/domain" +) + +// subjectMatcher は科目名から subjects.id を引く。 +// キーは NFKC 正規化した科目名と、さらに「(旧:…)」注釈を除去した科目名の 2 種類。 +// 同一キーが衝突した場合は先勝ちする。 +type subjectMatcher struct { + byName map[string]string +} + +func newSubjectMatcher(subjects []domain.SubjectRef) *subjectMatcher { + byName := make(map[string]string, len(subjects)*2) + register := func(key, id string) { + if key == "" { + return + } + if _, ok := byName[key]; !ok { + byName[key] = id + } + } + for _, s := range subjects { + normalized := NormalizeSubjectName(s.Name) + register(normalized, s.ID) + register(NormalizeSubjectName(StripLegacyAnnotation(normalized)), s.ID) + } + return &subjectMatcher{byName: byName} +} + +func (m *subjectMatcher) resolve(lessonName string) (string, bool) { + normalized := NormalizeSubjectName(lessonName) + if id, ok := m.byName[normalized]; ok { + return id, true + } + if id, ok := m.byName[NormalizeSubjectName(StripLegacyAnnotation(normalized))]; ok { + return id, true + } + return "", false +} + +// roomMatcher は教室名から rooms.id を引く。キーは NormalizeRoomName で正規化した教室名。 +type roomMatcher struct { + byName map[string]string +} + +func newRoomMatcher(rooms []domain.RoomRef) *roomMatcher { + byName := make(map[string]string, len(rooms)) + for _, r := range rooms { + key := NormalizeRoomName(r.Name) + if key == "" { + continue + } + if _, ok := byName[key]; !ok { + byName[key] = r.ID + } + } + return &roomMatcher{byName: byName} +} + +func (m *roomMatcher) resolve(roomName string) (string, bool) { + id, ok := m.byName[NormalizeRoomName(roomName)] + return id, ok +} diff --git a/internal/modules/batch-jobs/service/class_change_scrape_run.go b/internal/modules/batch-jobs/service/class_change_scrape_run.go new file mode 100644 index 0000000..eb89cd9 --- /dev/null +++ b/internal/modules/batch-jobs/service/class_change_scrape_run.go @@ -0,0 +1,172 @@ +package service + +import ( + "context" + "fmt" + "log/slog" + + academicdomain "github.com/fun-dotto/server/internal/modules/academic/domain" + "github.com/fun-dotto/server/internal/modules/batch-jobs/scraper" +) + +// ScrapeSummary はスクレイプ・突合・DB 反映の結果集計。 +type ScrapeSummary struct { + CancelledInserted int + CancelledDuplicated int + CancelledSkipped int + MakeupInserted int + MakeupDuplicated int + MakeupSkipped int + RoomChangeInserted int + RoomChangeDuplicated int + RoomChangeSkipped int + UnmatchedSubjectNames []string + UnmatchedRoomNames []string +} + +// Run はポータルへログインして休講/補講/部屋変更を取得し、subjects/rooms と突合したうえで +// 冪等に DB へ反映する。year はログイン対象年度(4/1 起点)で、日付の年度補正にも使う。 +func (s *ClassChangeScrapeService) Run(ctx context.Context, userID, password string, year int) (ScrapeSummary, error) { + html, err := s.client.FetchClassChangeHTML(ctx, userID, password) + if err != nil { + return ScrapeSummary{}, fmt.Errorf("fetch class change html: %w", err) + } + + cancelledRows, makeupRows, roomChangeRows, err := scraper.ParseClassChanges(html, year) + if err != nil { + return ScrapeSummary{}, fmt.Errorf("parse class change html: %w", err) + } + + subjectRefs, err := s.subjects.ListSyllabusNames(ctx) + if err != nil { + return ScrapeSummary{}, fmt.Errorf("list subjects: %w", err) + } + roomRefs, err := s.rooms.ListRooms(ctx) + if err != nil { + return ScrapeSummary{}, fmt.Errorf("list rooms: %w", err) + } + + matcher := newSubjectMatcher(subjectRefs) + roomMatcher := newRoomMatcher(roomRefs) + + summary := ScrapeSummary{} + unmatchedSubjects := map[string]struct{}{} + unmatchedRooms := map[string]struct{}{} + + for _, row := range cancelledRows { + subjectID, ok := matcher.resolve(row.LessonName) + if !ok { + unmatchedSubjects[row.LessonName] = struct{}{} + summary.CancelledSkipped++ + continue + } + if row.Period == "" { + summary.CancelledSkipped++ + continue + } + + exists, err := s.cancelled.Exists(ctx, subjectID, row.Date, row.Period) + if err != nil { + return ScrapeSummary{}, fmt.Errorf("check cancelled class exists: %w", err) + } + if exists { + summary.CancelledDuplicated++ + continue + } + if _, err := s.cancelled.Insert(ctx, academicdomain.CancelledClass{ + Subject: academicdomain.Subject{ID: subjectID}, + Date: row.Date.Format(dateOutputLayout), + Period: academicdomain.Period(row.Period), + Comment: row.Comment, + }); err != nil { + return ScrapeSummary{}, fmt.Errorf("insert cancelled class: %w", err) + } + summary.CancelledInserted++ + } + + for _, row := range makeupRows { + subjectID, ok := matcher.resolve(row.LessonName) + if !ok { + unmatchedSubjects[row.LessonName] = struct{}{} + summary.MakeupSkipped++ + continue + } + if row.Period == "" { + summary.MakeupSkipped++ + continue + } + + exists, err := s.makeup.Exists(ctx, subjectID, row.Date, row.Period) + if err != nil { + return ScrapeSummary{}, fmt.Errorf("check makeup class exists: %w", err) + } + if exists { + summary.MakeupDuplicated++ + continue + } + if _, err := s.makeup.Insert(ctx, academicdomain.MakeupClass{ + Subject: academicdomain.Subject{ID: subjectID}, + Date: row.Date.Format(dateOutputLayout), + Period: academicdomain.Period(row.Period), + Comment: row.Comment, + }); err != nil { + return ScrapeSummary{}, fmt.Errorf("insert makeup class: %w", err) + } + summary.MakeupInserted++ + } + + for _, row := range roomChangeRows { + subjectID, subjectOK := matcher.resolve(row.LessonName) + if !subjectOK { + unmatchedSubjects[row.LessonName] = struct{}{} + } + originalRoomID, fromOK := roomMatcher.resolve(row.RoomFromName) + if !fromOK { + unmatchedRooms[row.RoomFromName] = struct{}{} + } + newRoomID, toOK := roomMatcher.resolve(row.RoomToName) + if !toOK { + unmatchedRooms[row.RoomToName] = struct{}{} + } + if !subjectOK || !fromOK || !toOK || row.Period == "" { + summary.RoomChangeSkipped++ + continue + } + + exists, err := s.roomChange.Exists(ctx, subjectID, row.Date, row.Period, originalRoomID, newRoomID) + if err != nil { + return ScrapeSummary{}, fmt.Errorf("check room change exists: %w", err) + } + if exists { + summary.RoomChangeDuplicated++ + continue + } + if _, err := s.roomChange.Insert(ctx, academicdomain.RoomChange{ + Subject: academicdomain.Subject{ID: subjectID}, + Date: row.Date.Format(dateOutputLayout), + Period: academicdomain.Period(row.Period), + OriginalRoom: academicdomain.Room{ID: originalRoomID}, + NewRoom: academicdomain.Room{ID: newRoomID}, + }); err != nil { + return ScrapeSummary{}, fmt.Errorf("insert room change: %w", err) + } + summary.RoomChangeInserted++ + } + + for name := range unmatchedSubjects { + summary.UnmatchedSubjectNames = append(summary.UnmatchedSubjectNames, name) + } + for name := range unmatchedRooms { + summary.UnmatchedRoomNames = append(summary.UnmatchedRoomNames, name) + } + if len(summary.UnmatchedSubjectNames) > 0 { + slog.Warn("subjects に一致しない授業名があります", "names", summary.UnmatchedSubjectNames) + } + if len(summary.UnmatchedRoomNames) > 0 { + slog.Warn("rooms に一致しない教室名があります", "names", summary.UnmatchedRoomNames) + } + + return summary, nil +} + +const dateOutputLayout = "2006-01-02" diff --git a/internal/modules/batch-jobs/service/class_change_scrape_run_test.go b/internal/modules/batch-jobs/service/class_change_scrape_run_test.go new file mode 100644 index 0000000..c474b7a --- /dev/null +++ b/internal/modules/batch-jobs/service/class_change_scrape_run_test.go @@ -0,0 +1,215 @@ +package service + +import ( + "context" + "testing" + "time" + + academicdomain "github.com/fun-dotto/server/internal/modules/academic/domain" + "github.com/fun-dotto/server/internal/modules/batch-jobs/domain" +) + +const scrapeTestHTML = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
05/123時限情報数学本校教員A補講あり 後日連絡
05/131時限未知の科目本校教員B補講なし
05/202時限情報数学本校教員A5/12 の補講R791
05/214時限情報数学本校教員AR791R792
+` + +type stubScraperClient struct { + html string +} + +func (c *stubScraperClient) FetchClassChangeHTML(_ context.Context, _, _ string) (string, error) { + return c.html, nil +} + +type stubSubjectRepository struct { + subjects []domain.SubjectRef +} + +func (r *stubSubjectRepository) ListSyllabusNames(_ context.Context) ([]domain.SubjectRef, error) { + return r.subjects, nil +} + +type stubRoomRepository struct { + rooms []domain.RoomRef +} + +func (r *stubRoomRepository) ListRooms(_ context.Context) ([]domain.RoomRef, error) { + return r.rooms, nil +} + +type stubCancelledClassRepository struct { + existing map[string]struct{} + inserted []academicdomain.CancelledClass +} + +func cancelledKey(subjectID string, date time.Time, period string) string { + return subjectID + "|" + date.Format("2006-01-02") + "|" + period +} + +func (r *stubCancelledClassRepository) Exists(_ context.Context, subjectID string, date time.Time, period string) (bool, error) { + _, ok := r.existing[cancelledKey(subjectID, date, period)] + return ok, nil +} + +func (r *stubCancelledClassRepository) Insert(_ context.Context, cc academicdomain.CancelledClass) (academicdomain.CancelledClass, error) { + r.inserted = append(r.inserted, cc) + return cc, nil +} + +type stubMakeupClassRepository struct { + existing map[string]struct{} + inserted []academicdomain.MakeupClass +} + +func (r *stubMakeupClassRepository) Exists(_ context.Context, subjectID string, date time.Time, period string) (bool, error) { + _, ok := r.existing[cancelledKey(subjectID, date, period)] + return ok, nil +} + +func (r *stubMakeupClassRepository) Insert(_ context.Context, mc academicdomain.MakeupClass) (academicdomain.MakeupClass, error) { + r.inserted = append(r.inserted, mc) + return mc, nil +} + +type stubRoomChangeRepository struct { + existing map[string]struct{} + inserted []academicdomain.RoomChange +} + +func (r *stubRoomChangeRepository) Exists(_ context.Context, subjectID string, date time.Time, period, originalRoomID, newRoomID string) (bool, error) { + _, ok := r.existing[cancelledKey(subjectID, date, period)+"|"+originalRoomID+"|"+newRoomID] + return ok, nil +} + +func (r *stubRoomChangeRepository) Insert(_ context.Context, rc academicdomain.RoomChange) (academicdomain.RoomChange, error) { + r.inserted = append(r.inserted, rc) + return rc, nil +} + +func newScrapeServiceForTest(html string) (*ClassChangeScrapeService, *stubCancelledClassRepository, *stubMakeupClassRepository, *stubRoomChangeRepository) { + cancelled := &stubCancelledClassRepository{existing: map[string]struct{}{}} + makeup := &stubMakeupClassRepository{existing: map[string]struct{}{}} + roomChange := &stubRoomChangeRepository{existing: map[string]struct{}{}} + svc := NewClassChangeScrapeService( + &stubScraperClient{html: html}, + &stubSubjectRepository{subjects: []domain.SubjectRef{ + {ID: "subject-1", SyllabusID: "101", Name: "情報数学"}, + }}, + &stubRoomRepository{rooms: []domain.RoomRef{ + {ID: "room-1", Name: "R791"}, + {ID: "room-2", Name: "R792"}, + }}, + cancelled, + makeup, + roomChange, + ) + return svc, cancelled, makeup, roomChange +} + +func TestClassChangeScrapeServiceRun(t *testing.T) { + svc, cancelled, makeup, roomChange := newScrapeServiceForTest(scrapeTestHTML) + + summary, err := svc.Run(context.Background(), "user", "password", 2026) + if err != nil { + t.Fatalf("Run: %v", err) + } + + if summary.CancelledInserted != 1 || summary.CancelledSkipped != 1 { + t.Errorf("cancelled inserted=%d skipped=%d, want 1/1", summary.CancelledInserted, summary.CancelledSkipped) + } + if summary.MakeupInserted != 1 { + t.Errorf("makeup inserted=%d, want 1", summary.MakeupInserted) + } + if summary.RoomChangeInserted != 1 { + t.Errorf("room change inserted=%d, want 1", summary.RoomChangeInserted) + } + if len(summary.UnmatchedSubjectNames) != 1 || summary.UnmatchedSubjectNames[0] != "未知の科目" { + t.Errorf("unmatched subjects = %v, want [未知の科目]", summary.UnmatchedSubjectNames) + } + + if len(cancelled.inserted) != 1 { + t.Fatalf("cancelled inserted rows = %d, want 1", len(cancelled.inserted)) + } + cc := cancelled.inserted[0] + if cc.Subject.ID != "subject-1" || cc.Date != "2026-05-12" || cc.Period != academicdomain.PeriodPeriod3 { + t.Errorf("unexpected cancelled class: %+v", cc) + } + + if len(makeup.inserted) != 1 { + t.Fatalf("makeup inserted rows = %d, want 1", len(makeup.inserted)) + } + if len(roomChange.inserted) != 1 { + t.Fatalf("room change inserted rows = %d, want 1", len(roomChange.inserted)) + } + rc := roomChange.inserted[0] + if rc.OriginalRoom.ID != "room-1" || rc.NewRoom.ID != "room-2" { + t.Errorf("unexpected room change: %+v", rc) + } +} + +func TestClassChangeScrapeServiceRunIsIdempotent(t *testing.T) { + svc, cancelled, makeup, roomChange := newScrapeServiceForTest(scrapeTestHTML) + date := time.Date(2026, 5, 12, 0, 0, 0, 0, time.UTC) + cancelled.existing[cancelledKey("subject-1", date, "Period3")] = struct{}{} + makeupDate := time.Date(2026, 5, 20, 0, 0, 0, 0, time.UTC) + makeup.existing[cancelledKey("subject-1", makeupDate, "Period2")] = struct{}{} + roomChangeDate := time.Date(2026, 5, 21, 0, 0, 0, 0, time.UTC) + roomChange.existing[cancelledKey("subject-1", roomChangeDate, "Period4")+"|room-1|room-2"] = struct{}{} + + summary, err := svc.Run(context.Background(), "user", "password", 2026) + if err != nil { + t.Fatalf("Run: %v", err) + } + + if summary.CancelledDuplicated != 1 || summary.CancelledInserted != 0 { + t.Errorf("cancelled duplicated=%d inserted=%d, want 1/0", summary.CancelledDuplicated, summary.CancelledInserted) + } + if summary.MakeupDuplicated != 1 || summary.MakeupInserted != 0 { + t.Errorf("makeup duplicated=%d inserted=%d, want 1/0", summary.MakeupDuplicated, summary.MakeupInserted) + } + if summary.RoomChangeDuplicated != 1 || summary.RoomChangeInserted != 0 { + t.Errorf("room change duplicated=%d inserted=%d, want 1/0", summary.RoomChangeDuplicated, summary.RoomChangeInserted) + } + if len(cancelled.inserted)+len(makeup.inserted)+len(roomChange.inserted) != 0 { + t.Errorf("expected no inserts on duplicated run") + } +} diff --git a/internal/modules/batch-jobs/service/faculty_room_import.go b/internal/modules/batch-jobs/service/faculty_room_import.go new file mode 100644 index 0000000..d8f8658 --- /dev/null +++ b/internal/modules/batch-jobs/service/faculty_room_import.go @@ -0,0 +1,168 @@ +package service + +import ( + "context" + "encoding/csv" + "errors" + "fmt" + "io" + "log/slog" + "sort" + "strings" + + "github.com/fun-dotto/server/internal/modules/batch-jobs/domain" +) + +// ErrFacultyRoomUnmatched は CSV 内に faculties / rooms と照合できない行があったことを表す。 +// この場合 INSERT は 1 件も実行しない。 +var ErrFacultyRoomUnmatched = errors.New("faculties / rooms に一致しない行があります") + +type ImportFacultyRepository interface { + ListFaculties(ctx context.Context) ([]domain.Faculty, error) +} + +type ImportFacultyRoomRepository interface { + InsertBatch(ctx context.Context, rows []domain.FacultyRoomInsert) error +} + +// FacultyRoomImportService は faculties CSV を読み faculty_rooms へ一括 INSERT する。 +// email は faculties.email(小文字化)、room_name は rooms.name(NormalizeRoomName)で照合し、 +// 未一致が 1 件でもあれば INSERT せずエラーを返す。 +type FacultyRoomImportService struct { + faculties ImportFacultyRepository + rooms ScrapeRoomRepository + facultyRooms ImportFacultyRoomRepository +} + +func NewFacultyRoomImportService( + faculties ImportFacultyRepository, + rooms ScrapeRoomRepository, + facultyRooms ImportFacultyRoomRepository, +) *FacultyRoomImportService { + return &FacultyRoomImportService{ + faculties: faculties, + rooms: rooms, + facultyRooms: facultyRooms, + } +} + +// ParseFacultyRoomCSV は faculties CSV(UTF-8、BOM 許容、ヘッダ行必須、必須カラム email/room_name) +// を読み、year を付与した行の一覧を返す。 +func ParseFacultyRoomCSV(year int, r io.Reader) ([]domain.FacultyRoomImportRow, error) { + reader := csv.NewReader(r) + reader.FieldsPerRecord = -1 + + header, err := reader.Read() + if err != nil { + return nil, fmt.Errorf("read csv header: %w", err) + } + emailIdx, roomNameIdx := -1, -1 + for i, name := range header { + switch strings.TrimSpace(strings.TrimPrefix(name, "\uFEFF")) { + case "email": + emailIdx = i + case "room_name": + roomNameIdx = i + } + } + if emailIdx < 0 || roomNameIdx < 0 { + return nil, fmt.Errorf("csv に必須カラムがありません(email, room_name が必要): %v", header) + } + + var rows []domain.FacultyRoomImportRow + for { + record, err := reader.Read() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return nil, fmt.Errorf("read csv row: %w", err) + } + row := domain.FacultyRoomImportRow{Year: year} + if emailIdx < len(record) { + row.Email = strings.TrimSpace(record[emailIdx]) + } + if roomNameIdx < len(record) { + row.RoomName = strings.TrimSpace(record[roomNameIdx]) + } + rows = append(rows, row) + } + return rows, nil +} + +// Import は CSV 行を faculties / rooms と照合し、全行一致した場合のみ faculty_rooms へ +// 1 トランザクションで一括 INSERT する。room_name 空欄の行はスキップする。 +func (s *FacultyRoomImportService) Import(ctx context.Context, rows []domain.FacultyRoomImportRow) (domain.FacultyRoomImportSummary, error) { + faculties, err := s.faculties.ListFaculties(ctx) + if err != nil { + return domain.FacultyRoomImportSummary{}, fmt.Errorf("list faculties: %w", err) + } + rooms, err := s.rooms.ListRooms(ctx) + if err != nil { + return domain.FacultyRoomImportSummary{}, fmt.Errorf("list rooms: %w", err) + } + + facultyIDByEmail := make(map[string]string, len(faculties)) + for _, f := range faculties { + key := strings.ToLower(strings.TrimSpace(f.Email)) + if key == "" { + continue + } + if _, ok := facultyIDByEmail[key]; !ok { + facultyIDByEmail[key] = f.ID + } + } + roomIDByName := newRoomMatcher(rooms) + + summary := domain.FacultyRoomImportSummary{} + unmatchedEmails := map[string]struct{}{} + unmatchedRooms := map[string]struct{}{} + var inserts []domain.FacultyRoomInsert + + for _, row := range rows { + if row.RoomName == "" { + summary.SkippedBlank++ + continue + } + facultyID, facultyOK := facultyIDByEmail[strings.ToLower(row.Email)] + roomID, roomOK := roomIDByName.resolve(row.RoomName) + if !facultyOK { + unmatchedEmails[row.Email] = struct{}{} + } + if !roomOK { + unmatchedRooms[row.RoomName] = struct{}{} + } + if !facultyOK || !roomOK { + continue + } + inserts = append(inserts, domain.FacultyRoomInsert{ + FacultyID: facultyID, + RoomID: roomID, + Year: row.Year, + }) + } + + for email := range unmatchedEmails { + summary.UnmatchedEmails = append(summary.UnmatchedEmails, email) + } + for name := range unmatchedRooms { + summary.UnmatchedRooms = append(summary.UnmatchedRooms, name) + } + sort.Strings(summary.UnmatchedEmails) + sort.Strings(summary.UnmatchedRooms) + + if len(summary.UnmatchedEmails) > 0 || len(summary.UnmatchedRooms) > 0 { + slog.Error( + "faculties / rooms に一致しない行があるため INSERT を実行しません", + "unmatched_emails", summary.UnmatchedEmails, + "unmatched_rooms", summary.UnmatchedRooms, + ) + return summary, ErrFacultyRoomUnmatched + } + + if err := s.facultyRooms.InsertBatch(ctx, inserts); err != nil { + return summary, fmt.Errorf("insert faculty rooms: %w", err) + } + summary.Inserted = len(inserts) + return summary, nil +} diff --git a/internal/modules/batch-jobs/service/faculty_room_import_test.go b/internal/modules/batch-jobs/service/faculty_room_import_test.go new file mode 100644 index 0000000..2d1f067 --- /dev/null +++ b/internal/modules/batch-jobs/service/faculty_room_import_test.go @@ -0,0 +1,112 @@ +package service + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/fun-dotto/server/internal/modules/batch-jobs/domain" +) + +type stubFacultyRepository struct { + faculties []domain.Faculty +} + +func (r *stubFacultyRepository) ListFaculties(_ context.Context) ([]domain.Faculty, error) { + return r.faculties, nil +} + +type stubFacultyRoomRepository struct { + inserted []domain.FacultyRoomInsert +} + +func (r *stubFacultyRoomRepository) InsertBatch(_ context.Context, rows []domain.FacultyRoomInsert) error { + r.inserted = append(r.inserted, rows...) + return nil +} + +func newImportServiceForTest() (*FacultyRoomImportService, *stubFacultyRoomRepository) { + facultyRooms := &stubFacultyRoomRepository{} + svc := NewFacultyRoomImportService( + &stubFacultyRepository{faculties: []domain.Faculty{ + {ID: "faculty-1", Email: "Alice@fun.ac.jp"}, + {ID: "faculty-2", Email: "bob@fun.ac.jp"}, + }}, + &stubRoomRepository{rooms: []domain.RoomRef{ + {ID: "room-1", Name: "R791"}, + {ID: "room-2", Name: "R792"}, + }}, + facultyRooms, + ) + return svc, facultyRooms +} + +func TestParseFacultyRoomCSV(t *testing.T) { + csvData := "\uFEFFemail,room_name,note\nalice@fun.ac.jp,R791,x\nbob@fun.ac.jp,,y\n" + rows, err := ParseFacultyRoomCSV(2026, strings.NewReader(csvData)) + if err != nil { + t.Fatalf("ParseFacultyRoomCSV: %v", err) + } + want := []domain.FacultyRoomImportRow{ + {Year: 2026, Email: "alice@fun.ac.jp", RoomName: "R791"}, + {Year: 2026, Email: "bob@fun.ac.jp", RoomName: ""}, + } + if len(rows) != len(want) { + t.Fatalf("rows = %d, want %d", len(rows), len(want)) + } + for i := range want { + if rows[i] != want[i] { + t.Errorf("rows[%d] = %+v, want %+v", i, rows[i], want[i]) + } + } +} + +func TestParseFacultyRoomCSVMissingColumn(t *testing.T) { + if _, err := ParseFacultyRoomCSV(2026, strings.NewReader("email,name\na@fun.ac.jp,R791\n")); err == nil { + t.Fatal("expected error for missing room_name column") + } +} + +func TestFacultyRoomImport(t *testing.T) { + svc, facultyRooms := newImportServiceForTest() + + summary, err := svc.Import(context.Background(), []domain.FacultyRoomImportRow{ + {Year: 2026, Email: "alice@fun.ac.jp", RoomName: "R791"}, + {Year: 2026, Email: "BOB@fun.ac.jp", RoomName: "R792"}, + {Year: 2026, Email: "carol@fun.ac.jp", RoomName: ""}, + }) + if err != nil { + t.Fatalf("Import: %v", err) + } + if summary.Inserted != 2 || summary.SkippedBlank != 1 { + t.Errorf("summary = %+v, want inserted=2 skipped_blank=1", summary) + } + if len(facultyRooms.inserted) != 2 { + t.Fatalf("inserted rows = %d, want 2", len(facultyRooms.inserted)) + } + if facultyRooms.inserted[0] != (domain.FacultyRoomInsert{FacultyID: "faculty-1", RoomID: "room-1", Year: 2026}) { + t.Errorf("unexpected first insert: %+v", facultyRooms.inserted[0]) + } +} + +func TestFacultyRoomImportAbortsOnUnmatched(t *testing.T) { + svc, facultyRooms := newImportServiceForTest() + + summary, err := svc.Import(context.Background(), []domain.FacultyRoomImportRow{ + {Year: 2026, Email: "alice@fun.ac.jp", RoomName: "R791"}, + {Year: 2026, Email: "unknown@fun.ac.jp", RoomName: "R999"}, + }) + if !errors.Is(err, ErrFacultyRoomUnmatched) { + t.Fatalf("err = %v, want ErrFacultyRoomUnmatched", err) + } + if len(facultyRooms.inserted) != 0 { + t.Errorf("inserted rows = %d, want 0 (no insert on unmatched)", len(facultyRooms.inserted)) + } + if len(summary.UnmatchedEmails) != 1 || summary.UnmatchedEmails[0] != "unknown@fun.ac.jp" { + t.Errorf("unmatched emails = %v", summary.UnmatchedEmails) + } + if len(summary.UnmatchedRooms) != 1 || summary.UnmatchedRooms[0] != "R999" { + t.Errorf("unmatched rooms = %v", summary.UnmatchedRooms) + } +} diff --git a/internal/modules/batch-jobs/service/normalize.go b/internal/modules/batch-jobs/service/normalize.go new file mode 100644 index 0000000..4291738 --- /dev/null +++ b/internal/modules/batch-jobs/service/normalize.go @@ -0,0 +1,30 @@ +package service + +import ( + "regexp" + "strings" + + "golang.org/x/text/unicode/norm" +) + +// legacyAnnotationPattern はポータル側の科目名に付く「旧科目名」注釈を検出する。 +var legacyAnnotationPattern = regexp.MustCompile(`(旧[::][^)]*)|\(旧[::][^)]*\)`) + +// NormalizeSubjectName は科目名照合用のキー: NFKC 正規化 + 連続空白の圧縮 + 前後空白除去。 +// 大文字小文字は区別する(Python 版 normalize_for_match と同等)。 +func NormalizeSubjectName(s string) string { + t := norm.NFKC.String(s) + return strings.TrimSpace(strings.Join(strings.Fields(t), " ")) +} + +// StripLegacyAnnotation は科目名に付く「(旧:…)」注釈をすべて除去し前後空白を整える。 +func StripLegacyAnnotation(s string) string { + return strings.TrimSpace(legacyAnnotationPattern.ReplaceAllString(s, "")) +} + +// NormalizeRoomName は教室名照合用のキー: NFKC 正規化 + 連続空白の圧縮 + 前後空白除去 + 大文字小文字無視。 +func NormalizeRoomName(s string) string { + t := norm.NFKC.String(s) + t = strings.Join(strings.Fields(t), " ") + return strings.ToLower(strings.TrimSpace(t)) +} diff --git a/internal/modules/batch-jobs/service/normalize_test.go b/internal/modules/batch-jobs/service/normalize_test.go new file mode 100644 index 0000000..24168cc --- /dev/null +++ b/internal/modules/batch-jobs/service/normalize_test.go @@ -0,0 +1,63 @@ +package service + +import "testing" + +func TestNormalizeSubjectName(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {name: "全角英数はNFKCで半角化される", in: "AI基礎", want: "AI基礎"}, + {name: "連続する空白は1つに圧縮される", in: "情報 工学 概論", want: "情報 工学 概論"}, + {name: "前後の空白は除去される", in: " データ構造 ", want: "データ構造"}, + {name: "大文字小文字は区別する", in: "Python入門", want: "Python入門"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := NormalizeSubjectName(tt.in); got != tt.want { + t.Errorf("NormalizeSubjectName(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} + +func TestStripLegacyAnnotation(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {name: "全角括弧の旧科目名注釈を除去する", in: "情報科学演習(旧:情報処理演習)", want: "情報科学演習"}, + {name: "全角コロンの注釈を除去する", in: "情報科学演習(旧:情報処理演習)", want: "情報科学演習"}, + {name: "半角括弧の注釈を除去する", in: "情報科学演習(旧:情報処理演習)", want: "情報科学演習"}, + {name: "注釈が無ければそのまま", in: "情報科学演習", want: "情報科学演習"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := StripLegacyAnnotation(tt.in); got != tt.want { + t.Errorf("StripLegacyAnnotation(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} + +func TestNormalizeRoomName(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {name: "全角英数字はNFKCで半角化される", in: "R781", want: "r781"}, + {name: "全角スペースは通常の空白として圧縮される", in: "大 講義室", want: "大 講義室"}, + {name: "大文字小文字を無視する", in: "R781", want: "r781"}, + {name: "前後の空白は除去される", in: " 講堂 ", want: "講堂"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := NormalizeRoomName(tt.in); got != tt.want { + t.Errorf("NormalizeRoomName(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +}