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/10 |
+ 木 |
+ 3時限 |
+ 情報科学演習 |
+ 本キャンパス |
+ 山田太郎 |
+ 補講あり:後日案内 |
+
+
+
+
+
+
+ | 04/20 |
+ 日 |
+ 1時限 |
+ 情報科学演習 |
+ 本キャンパス |
+ R781 |
+ 山田太郎 |
+ 4/10 3限の補講 |
+
+
+
+
+
+
+ | 03/02 |
+ 月 |
+ 2時限 |
+ データ構造(旧:データ構造とアルゴリズム) |
+ 本キャンパス |
+ 鈴木花子 |
+ 493 |
+ R781 |
+
+
+
+
+
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/12 |
+ 火 |
+ 3時限 |
+ 情報数学 |
+ 本校 |
+ 教員A |
+ 補講あり 後日連絡 |
+
+
+ | 05/13 |
+ 水 |
+ 1時限 |
+ 未知の科目 |
+ 本校 |
+ 教員B |
+ 補講なし |
+
+
+ | 05/20 |
+ 水 |
+ 2時限 |
+ 情報数学 |
+ 本校 |
+ 教員A |
+ 5/12 の補講 |
+ R791 |
+
+
+ | 05/21 |
+ 木 |
+ 4時限 |
+ 情報数学 |
+ 本校 |
+ 教員A |
+ R791 |
+ R792 |
+
+
+`
+
+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)
+ }
+ })
+ }
+}