diff --git a/cmd/doc.go b/cmd/doc.go index eadd5fd..27e8faa 100644 --- a/cmd/doc.go +++ b/cmd/doc.go @@ -21,8 +21,9 @@ var ( docListDocType string docListSecCode string docListEdinetCode string - docListFilerName string - docListRateLimit int + docListFilerName string + docListDocDescription string + docListRateLimit int docGetType string docGetOut string @@ -78,8 +79,9 @@ var docListCmd = &cobra.Command{ DocType: docListDocType, SecCode: docListSecCode, EdinetCode: docListEdinetCode, - FilerName: docListFilerName, - RateLimit: time.Duration(docListRateLimit) * time.Millisecond, + FilerName: docListFilerName, + DocDescription: docListDocDescription, + RateLimit: time.Duration(docListRateLimit) * time.Millisecond, }) if err != nil { return err @@ -344,6 +346,7 @@ func init() { docListCmd.Flags().StringVar(&docListSecCode, "sec-code", "", "Filter by securities code") docListCmd.Flags().StringVar(&docListEdinetCode, "edinet-code", "", "Filter by EDINET code") docListCmd.Flags().StringVar(&docListFilerName, "filer-name", "", "Filter by filer name (substring match)") + docListCmd.Flags().StringVar(&docListDocDescription, "doc-description", "", "Filter by document description (substring match)") docListCmd.Flags().IntVar(&docListRateLimit, "rate-limit", 100, "Rate limit between requests in ms") docGetCmd.Flags().StringVar(&docGetType, "type", "", "Document type: xbrl, pdf, attach, english, csv (required)") diff --git a/cmd/doc_test.go b/cmd/doc_test.go index 5788a24..eab6449 100644 --- a/cmd/doc_test.go +++ b/cmd/doc_test.go @@ -131,6 +131,16 @@ func TestDocFinancial_InvalidStatement(t *testing.T) { expectErrorCode(t, stderr, "VALIDATION_ERROR") } +func TestDocListCmd_HasDocDescriptionFlag(t *testing.T) { + f := docListCmd.Flags().Lookup("doc-description") + if f == nil { + t.Fatal("doc list command missing --doc-description flag") + } + if f.DefValue != "" { + t.Errorf("--doc-description default = %q, want empty string", f.DefValue) + } +} + func TestDocText_ListSectionsOutput(t *testing.T) { stdout, _, code := executeCommand("doc", "text", "--list-sections") if code != 0 { diff --git a/internal/financial/parser.go b/internal/financial/parser.go index 504c54c..2d8a775 100644 --- a/internal/financial/parser.go +++ b/internal/financial/parser.go @@ -306,9 +306,11 @@ func buildResult(rows []parsedRow, opts ParseOpts, warnings []string) (*ParseRes } } - // Build summary from current period items and derive metrics + // Build summary from best available period items and derive metrics + summary, summaryPeriod := BuildAndDeriveSummary(statements) return &ParseResult{ - Summary: BuildAndDeriveSummary(statements), + Summary: summary, + SummaryPeriod: summaryPeriod, Statements: statements, AccountingStd: acctStd, Consolidated: hasConsolidatedStmt, diff --git a/internal/financial/parser_test.go b/internal/financial/parser_test.go index 72a8368..17c14f6 100644 --- a/internal/financial/parser_test.go +++ b/internal/financial/parser_test.go @@ -1154,3 +1154,33 @@ func TestDetectAccountingStandard_NeutralOnlyRows_StaysUnknown(t *testing.T) { t.Errorf("detectAccountingStandard = %q, want %q", std, "unknown") } } + +// --- Filing-style CSV (IPO prospectus) with Prior1Year contexts --- + +func TestParse_FilingStyleCSV_FallbackPeriod(t *testing.T) { + file := makeCSVFile( + "jpcrp020400-srs-001_E41257-000_2025-04-30_01_2026-01-09.csv", + standardHeaders(), + [][]string{ + makeRow("jpcrp_cor:NetSalesSummaryOfBusinessResults", "売上高、経営指標等", "Prior1YearDuration", "前期", "個別", "期間", "JPY", "円", "9426601000"), + makeRow("jpcrp_cor:OrdinaryIncomeSummaryOfBusinessResults", "経常利益、経営指標等", "Prior1YearDuration", "前期", "個別", "期間", "JPY", "円", "1145214000"), + makeRow("jpcrp_cor:TotalAssetsSummaryOfBusinessResults", "総資産額、経営指標等", "Prior1YearInstant", "前期末", "個別", "時点", "JPY", "円", "6160640000"), + makeRow("jpcrp_cor:NetAssetsSummaryOfBusinessResults", "純資産額、経営指標等", "Prior1YearInstant", "前期末", "個別", "時点", "JPY", "円", "4261992000"), + makeRow("jpcrp_cor:NetSalesSummaryOfBusinessResults", "売上高、経営指標等", "Prior2YearDuration", "前々期", "個別", "期間", "JPY", "円", "8735439000"), + }, + ) + + csvResult := &extract.CSVDataResult{Files: []extract.CSVFile{file}} + result, err := Parse(csvResult, ParseOpts{}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + if result.SummaryPeriod != "prior1" { + t.Errorf("SummaryPeriod = %q, want %q", result.SummaryPeriod, "prior1") + } + assertSummaryValue(t, result.Summary, "revenue", 9426601000) + assertSummaryValue(t, result.Summary, "ordinary_income", 1145214000) + assertSummaryValue(t, result.Summary, "total_assets", 6160640000) + assertSummaryValue(t, result.Summary, "net_assets", 4261992000) +} diff --git a/internal/financial/summary.go b/internal/financial/summary.go index 6e734c4..c2ccd26 100644 --- a/internal/financial/summary.go +++ b/internal/financial/summary.go @@ -1,48 +1,133 @@ package financial +import "sort" + +// supplementalKeys are summary keys that represent per-share or non-core items. +// A period containing only supplemental keys is not considered a meaningful financial snapshot. +var supplementalKeys = map[string]bool{ + "dividend_per_share": true, + "eps": true, + "research_and_development": true, + "shares_outstanding": true, + "treasury_shares": true, +} + // BuildAndDeriveSummary builds the summary from statements and calculates derived metrics. -// This is the single entry point for summary construction, used by both the parser -// and the service layer's statement-filtering path. -func BuildAndDeriveSummary(statements []FinancialStatement) Summary { +// Returns the summary and the period name that was used as the primary data source. +// For annual reports this is typically "current"; for filing documents it may be "prior1" etc. +// An empty string means only filing_date items were available (or no data at all). +func BuildAndDeriveSummary(statements []FinancialStatement) (Summary, string) { summary := make(Summary) - populateSummary(summary, statements) + period := populateSummary(summary, statements) DeriveMetrics(summary) - return summary + return summary, period } -// populateSummary extracts key financial figures from the current period items -// of the given statements and stores them in the summary map. -func populateSummary(summary Summary, statements []FinancialStatement) { - additiveKeys := map[string]bool{ - "interest_bearing_debt": true, - } +// additiveKeys are summary keys whose values are summed across multiple line items +// (e.g., short-term + long-term debt), rather than using first-wins. +var additiveKeys = map[string]bool{ + "interest_bearing_debt": true, +} + +// populateSummary selects the best available period across all statements and extracts +// summary items from it. Filing_date items are always included as supplemental data. +// For annual reports the best period is "current"; for filing documents (IPO prospectuses) +// it falls back to "prior1" etc. Returns the selected period name, or "" if only +// filing_date data was available. +func populateSummary(summary Summary, statements []FinancialStatement) string { + // Phase 1: collect distinct periods and check which ones contain non-supplemental keys. + const estPeriods = 10 + seen := make(map[string]bool, estPeriods) + hasCore := make(map[string]bool, estPeriods) + periods := make([]string, 0, estPeriods) for _, stmt := range statements { for _, pd := range stmt.Periods { - if pd.Period != "current" && pd.Period != "filing_date" { + if pd.Period == "filing_date" { continue } + if !seen[pd.Period] { + seen[pd.Period] = true + periods = append(periods, pd.Period) + } + if hasCore[pd.Period] { + continue // already confirmed core key for this period + } for _, item := range pd.Items { - if item.SummaryKey == "" || item.Value == nil { - continue + if item.SummaryKey != "" && item.Value != nil && !supplementalKeys[item.SummaryKey] { + hasCore[pd.Period] = true + break } + } + } + } - if additiveKeys[item.SummaryKey] { - existing := summary[item.SummaryKey] - if existing == nil { - v := *item.Value - summary[item.SummaryKey] = &v - } else { - v := *existing + *item.Value - summary[item.SummaryKey] = &v + // Phase 2: sort periods by priority and select bestPeriod. + sort.Slice(periods, func(i, j int) bool { + return periodOrder(periods[i]) < periodOrder(periods[j]) + }) + + bestPeriod := "" + for _, p := range periods { + if hasCore[p] { + bestPeriod = p + break + } + } + // If no period has core keys, fall back to the highest-priority period. + // This handles edge cases like EPS-only or dividend-only statements. + if bestPeriod == "" && len(periods) > 0 { + bestPeriod = periods[0] + } + + // Phase 3: extract summary items from bestPeriod, then supplement from filing_date. + // Additive keys (e.g. interest_bearing_debt) are accumulated within each period + // (CL + NCL debt), but once set by one period they are not overwritten by another. + extractPeriod := func(target string) { + // Track which additive keys were already set before this period. + frozenAdditive := make(map[string]bool, len(additiveKeys)) + for k := range additiveKeys { + if _, exists := summary[k]; exists { + frozenAdditive[k] = true + } + } + + for _, stmt := range statements { + for _, pd := range stmt.Periods { + if pd.Period != target { + continue + } + for _, item := range pd.Items { + if item.SummaryKey == "" || item.Value == nil { + continue } - } else { - if _, exists := summary[item.SummaryKey]; !exists { - v := *item.Value - summary[item.SummaryKey] = &v + if additiveKeys[item.SummaryKey] { + if frozenAdditive[item.SummaryKey] { + continue // already set by a prior period + } + existing := summary[item.SummaryKey] + if existing == nil { + v := *item.Value + summary[item.SummaryKey] = &v + } else { + v := *existing + *item.Value + summary[item.SummaryKey] = &v + } + } else { + if _, exists := summary[item.SummaryKey]; !exists { + v := *item.Value + summary[item.SummaryKey] = &v + } } } } } } + + if bestPeriod != "" { + extractPeriod(bestPeriod) + } + extractPeriod("filing_date") + + return bestPeriod } diff --git a/internal/financial/summary_test.go b/internal/financial/summary_test.go index 06ebe4d..68e8022 100644 --- a/internal/financial/summary_test.go +++ b/internal/financial/summary_test.go @@ -25,7 +25,11 @@ func TestBuildAndDeriveSummary_IntegrationWithStatements(t *testing.T) { }, } - s := BuildAndDeriveSummary(stmts) + s, period := BuildAndDeriveSummary(stmts) + + if period != "current" { + t.Errorf("summaryPeriod = %q, want %q", period, "current") + } // Extracted values assertSummaryValue(t, s, "revenue", 10000) @@ -55,7 +59,11 @@ func TestBuildAndDeriveSummary_FilteredStatementsOmitIrrelevantMetrics(t *testin }, } - s := BuildAndDeriveSummary(plOnly) + s, period := BuildAndDeriveSummary(plOnly) + + if period != "current" { + t.Errorf("summaryPeriod = %q, want %q", period, "current") + } // PL-derived metrics should be present assertSummaryValue(t, s, "net_margin", 0.1) @@ -72,3 +80,195 @@ func TestBuildAndDeriveSummary_FilteredStatementsOmitIrrelevantMetrics(t *testin } } +func TestBuildAndDeriveSummary_FallbackToNearest_Prior1(t *testing.T) { + stmts := []FinancialStatement{ + { + Type: "pl", Consolidated: false, AccountingStd: "jpgaap", + Periods: []PeriodData{ + {Period: "prior1", Items: []LineItem{ + {SummaryKey: "revenue", Value: ptrFloat(5000)}, + {SummaryKey: "net_income", Value: ptrFloat(500)}, + }}, + {Period: "prior2", Items: []LineItem{ + {SummaryKey: "revenue", Value: ptrFloat(4000)}, + {SummaryKey: "net_income", Value: ptrFloat(400)}, + }}, + }, + }, + } + + s, period := BuildAndDeriveSummary(stmts) + + if period != "prior1" { + t.Errorf("summaryPeriod = %q, want %q", period, "prior1") + } + assertSummaryValue(t, s, "revenue", 5000) + assertSummaryValue(t, s, "net_income", 500) + assertSummaryValue(t, s, "net_margin", 0.1) +} + +func TestBuildAndDeriveSummary_CurrentPeriodTakesPrecedence(t *testing.T) { + stmts := []FinancialStatement{ + { + Type: "pl", Consolidated: true, AccountingStd: "jpgaap", + Periods: []PeriodData{ + {Period: "current", Items: []LineItem{ + {SummaryKey: "revenue", Value: ptrFloat(10000)}, + }}, + {Period: "prior1", Items: []LineItem{ + {SummaryKey: "revenue", Value: ptrFloat(8000)}, + }}, + }, + }, + } + + s, period := BuildAndDeriveSummary(stmts) + + if period != "current" { + t.Errorf("summaryPeriod = %q, want %q", period, "current") + } + assertSummaryValue(t, s, "revenue", 10000) +} + +func TestBuildAndDeriveSummary_BestPeriodOverridesFilingDate(t *testing.T) { + stmts := []FinancialStatement{ + { + Type: "pl", Consolidated: false, AccountingStd: "jpgaap", + Periods: []PeriodData{ + {Period: "prior1", Items: []LineItem{ + {SummaryKey: "revenue", Value: ptrFloat(5000)}, + }}, + }, + }, + { + Type: "bs", Consolidated: false, AccountingStd: "jpgaap", + Periods: []PeriodData{ + {Period: "filing_date", Items: []LineItem{ + {SummaryKey: "revenue", Value: ptrFloat(9999)}, // should be overridden by prior1 + {SummaryKey: "shares_outstanding", Value: ptrFloat(100)}, // filing_date-only key + }}, + }, + }, + } + + s, period := BuildAndDeriveSummary(stmts) + + if period != "prior1" { + t.Errorf("summaryPeriod = %q, want %q", period, "prior1") + } + assertSummaryValue(t, s, "revenue", 5000) // bestPeriod wins + assertSummaryValue(t, s, "shares_outstanding", 100) // filing_date supplements +} + +func TestBuildAndDeriveSummary_MixedPeriods_PLPrior1_BSPrior2(t *testing.T) { + stmts := []FinancialStatement{ + { + Type: "pl", Consolidated: false, AccountingStd: "jpgaap", + Periods: []PeriodData{ + {Period: "prior1", Items: []LineItem{ + {SummaryKey: "revenue", Value: ptrFloat(5000)}, + {SummaryKey: "net_income", Value: ptrFloat(500)}, + }}, + }, + }, + { + Type: "bs", Consolidated: false, AccountingStd: "jpgaap", + Periods: []PeriodData{ + {Period: "prior2", Items: []LineItem{ + {SummaryKey: "total_assets", Value: ptrFloat(30000)}, + }}, + }, + }, + } + + s, period := BuildAndDeriveSummary(stmts) + + if period != "prior1" { + t.Errorf("summaryPeriod = %q, want %q", period, "prior1") + } + assertSummaryValue(t, s, "revenue", 5000) + // BS values from prior2 should NOT be included (period consistency) + if s["total_assets"] != nil { + t.Errorf("total_assets should be nil (from different period prior2), got %v", *s["total_assets"]) + } +} + +func TestBuildAndDeriveSummary_FilingDateOnly(t *testing.T) { + stmts := []FinancialStatement{ + { + Type: "bs", Consolidated: false, AccountingStd: "jpgaap", + Periods: []PeriodData{ + {Period: "filing_date", Items: []LineItem{ + {SummaryKey: "shares_outstanding", Value: ptrFloat(1000)}, + }}, + }, + }, + } + + s, period := BuildAndDeriveSummary(stmts) + + if period != "" { + t.Errorf("summaryPeriod = %q, want empty string for filing_date-only", period) + } + assertSummaryValue(t, s, "shares_outstanding", 1000) +} + +func TestBuildAndDeriveSummary_SupplementalOnlyPeriodSkipped(t *testing.T) { + stmts := []FinancialStatement{ + { + Type: "pl", Consolidated: false, AccountingStd: "jpgaap", + Periods: []PeriodData{ + {Period: "prior3", Items: []LineItem{ + {SummaryKey: "dividend_per_share", Value: ptrFloat(50)}, // supplemental only + }}, + {Period: "prior1", Items: []LineItem{ + {SummaryKey: "revenue", Value: ptrFloat(5000)}, + {SummaryKey: "net_income", Value: ptrFloat(500)}, + }}, + }, + }, + { + Type: "bs", Consolidated: false, AccountingStd: "jpgaap", + Periods: []PeriodData{ + {Period: "prior1", Items: []LineItem{ + {SummaryKey: "total_assets", Value: ptrFloat(30000)}, + }}, + }, + }, + } + + s, period := BuildAndDeriveSummary(stmts) + + if period != "prior1" { + t.Errorf("summaryPeriod = %q, want %q (prior3 has only supplemental keys)", period, "prior1") + } + assertSummaryValue(t, s, "revenue", 5000) + assertSummaryValue(t, s, "total_assets", 30000) + // prior3's dividend should NOT be included (different period) + if s["dividend_per_share"] != nil { + t.Errorf("dividend_per_share should be nil (from skipped period prior3), got %v", *s["dividend_per_share"]) + } +} + +func TestBuildAndDeriveSummary_AdditiveKeys_FallbackPeriod(t *testing.T) { + stmts := []FinancialStatement{ + { + Type: "bs", Consolidated: true, AccountingStd: "jpgaap", + Periods: []PeriodData{ + {Period: "prior1", Items: []LineItem{ + {SummaryKey: "total_assets", Value: ptrFloat(50000)}, + {SummaryKey: "interest_bearing_debt", Value: ptrFloat(1000)}, // short-term + {SummaryKey: "interest_bearing_debt", Value: ptrFloat(2000)}, // long-term + }}, + }, + }, + } + + s, period := BuildAndDeriveSummary(stmts) + + if period != "prior1" { + t.Errorf("summaryPeriod = %q, want %q", period, "prior1") + } + assertSummaryValue(t, s, "interest_bearing_debt", 3000) // additive: 1000 + 2000 +} + diff --git a/internal/financial/types.go b/internal/financial/types.go index 5316c9d..9a703a8 100644 --- a/internal/financial/types.go +++ b/internal/financial/types.go @@ -39,6 +39,7 @@ type FinancialStatement struct { // ParseResult is the output of the CSV parser before service-layer metadata is added. type ParseResult struct { Summary Summary `json:"summary"` + SummaryPeriod string `json:"summary_period"` Statements []FinancialStatement `json:"statements"` AccountingStd string `json:"accounting_standard"` Consolidated bool `json:"consolidated"` @@ -75,6 +76,7 @@ type FinancialData struct { AccountingStd string `json:"accounting_standard"` Consolidated bool `json:"consolidated"` Summary Summary `json:"summary"` + SummaryPeriod string `json:"summary_period"` Statements []FinancialStatement `json:"statements"` Warnings []string `json:"warnings,omitempty"` } diff --git a/internal/financial/types_test.go b/internal/financial/types_test.go index 2985e7c..e04e814 100644 --- a/internal/financial/types_test.go +++ b/internal/financial/types_test.go @@ -80,6 +80,7 @@ func TestFinancialData_StripStatements(t *testing.T) { FiscalYear: "2025-03-31", AccountingStd: "jpgaap", Consolidated: true, + SummaryPeriod: "prior1", Summary: Summary{ "revenue": &val, }, @@ -108,8 +109,11 @@ func TestFinancialData_StripStatements(t *testing.T) { if fd.Summary["revenue"] == nil || *fd.Summary["revenue"] != val { t.Error("Summary should be preserved after StripStatements") } + if fd.SummaryPeriod != "prior1" { + t.Errorf("SummaryPeriod = %q, want %q after StripStatements", fd.SummaryPeriod, "prior1") + } - // JSON should contain "statements":null + // JSON should contain "statements":null and "summary_period":"prior1" data, err := json.Marshal(fd) if err != nil { t.Fatalf("Marshal error: %v", err) @@ -118,4 +122,7 @@ func TestFinancialData_StripStatements(t *testing.T) { if !strings.Contains(jsonStr, `"statements":null`) { t.Errorf("JSON should contain \"statements\":null, got: %s", jsonStr) } + if !strings.Contains(jsonStr, `"summary_period":"prior1"`) { + t.Errorf("JSON should contain \"summary_period\":\"prior1\", got: %s", jsonStr) + } } diff --git a/internal/schema/schema.go b/internal/schema/schema.go index cd60bf7..60eb236 100644 --- a/internal/schema/schema.go +++ b/internal/schema/schema.go @@ -91,6 +91,7 @@ func ListCommands() []CommandInfo { {Name: "--sec-code", Type: "string", Description: "Filter by securities code"}, {Name: "--edinet-code", Type: "string", Description: "Filter by EDINET code"}, {Name: "--filer-name", Type: "string", Description: "Filter by filer name (substring)"}, + {Name: "--doc-description", Type: "string", Description: "Filter by document description (substring)"}, {Name: "--rate-limit", Type: "int", Default: "100", Description: "Request interval in ms"}, }, Examples: []string{ diff --git a/internal/schema/schema_test.go b/internal/schema/schema_test.go index 2968e24..e774864 100644 --- a/internal/schema/schema_test.go +++ b/internal/schema/schema_test.go @@ -52,6 +52,22 @@ func TestListCommands_HasAllTopLevel(t *testing.T) { } } +func TestListCommands_DocListHasDocDescriptionFlag(t *testing.T) { + cmds := ListCommands() + for _, c := range cmds { + if c.Name == "doc list" { + for _, f := range c.Flags { + if f.Name == "--doc-description" { + return // found + } + } + t.Error("doc list command missing --doc-description flag in schema") + return + } + } + t.Error("doc list command not found in schema") +} + func TestListSections_HasKnownSections(t *testing.T) { sections := ListSections() if len(sections) == 0 { diff --git a/internal/service/document.go b/internal/service/document.go index f531e91..24f7752 100644 --- a/internal/service/document.go +++ b/internal/service/document.go @@ -27,8 +27,9 @@ type ListOptions struct { DocType string SecCode string EdinetCode string - FilerName string - RateLimit time.Duration + FilerName string + DocDescription string + RateLimit time.Duration Limit int Reverse bool } @@ -200,6 +201,9 @@ func filterDocuments(docs []api.Document, opts ListOptions) []DocumentInfo { if opts.FilerName != "" && (doc.FilerName == nil || !strings.Contains(*doc.FilerName, opts.FilerName)) { continue } + if opts.DocDescription != "" && (doc.DocDescription == nil || !strings.Contains(*doc.DocDescription, opts.DocDescription)) { + continue + } result = append(result, ToDocumentInfo(doc)) } return result diff --git a/internal/service/document_test.go b/internal/service/document_test.go index 54bad96..18a1cc0 100644 --- a/internal/service/document_test.go +++ b/internal/service/document_test.go @@ -240,3 +240,26 @@ func TestDocumentService_List_Reverse(t *testing.T) { t.Errorf("requested %d dates, want <=2 (should stop early)", len(requestedDates)) } } + +func TestDocumentService_List_FilterByDocDescription(t *testing.T) { + client, _ := setupMockServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _, _ = w.Write([]byte(`{"metadata":{"status":"200","message":"OK","parameter":{"date":"2025-06-20","type":"2"},"resultset":{"count":3},"processDateTime":"2025-06-20 13:01"},"results":[` + + `{"seqNumber":1,"docID":"S1","edinetCode":null,"secCode":null,"JCN":null,"filerName":null,"fundCode":null,"ordinanceCode":null,"formCode":null,"docTypeCode":"120","periodStart":null,"periodEnd":null,"submitDateTime":null,"docDescription":"有価証券報告書-第121期","issuerEdinetCode":null,"subjectEdinetCode":null,"subsidiaryEdinetCode":null,"currentReportReason":null,"parentDocID":null,"opeDateTime":null,"withdrawalStatus":"0","docInfoEditStatus":"0","disclosureStatus":"0","xbrlFlag":"0","pdfFlag":"0","attachDocFlag":"0","englishDocFlag":"0","csvFlag":"0","legalStatus":"1"},` + + `{"seqNumber":2,"docID":"S2","edinetCode":null,"secCode":null,"JCN":null,"filerName":null,"fundCode":null,"ordinanceCode":null,"formCode":null,"docTypeCode":"030","periodStart":null,"periodEnd":null,"submitDateTime":null,"docDescription":"有価証券届出書(新規公開時)","issuerEdinetCode":null,"subjectEdinetCode":null,"subsidiaryEdinetCode":null,"currentReportReason":null,"parentDocID":null,"opeDateTime":null,"withdrawalStatus":"0","docInfoEditStatus":"0","disclosureStatus":"0","xbrlFlag":"0","pdfFlag":"0","attachDocFlag":"0","englishDocFlag":"0","csvFlag":"0","legalStatus":"1"},` + + `{"seqNumber":3,"docID":"S3","edinetCode":null,"secCode":null,"JCN":null,"filerName":null,"fundCode":null,"ordinanceCode":null,"formCode":null,"docTypeCode":"120","periodStart":null,"periodEnd":null,"submitDateTime":null,"docDescription":null,"issuerEdinetCode":null,"subjectEdinetCode":null,"subsidiaryEdinetCode":null,"currentReportReason":null,"parentDocID":null,"opeDateTime":null,"withdrawalStatus":"0","docInfoEditStatus":"0","disclosureStatus":"0","xbrlFlag":"0","pdfFlag":"0","attachDocFlag":"0","englishDocFlag":"0","csvFlag":"0","legalStatus":"1"}` + + `]}`)) + }) + + svc := NewDocumentService(client, cache.NoopCache{}, nil) + result, err := svc.List(context.Background(), ListOptions{Date: "2025-06-20", DocDescription: "届出書"}) + if err != nil { + t.Fatalf("List() error = %v", err) + } + if len(result.Results) != 1 { + t.Fatalf("len(Results) = %d, want 1 (filtered by doc description substring)", len(result.Results)) + } + if result.Results[0].DocID != "S2" { + t.Errorf("DocID = %q, want %q", result.Results[0].DocID, "S2") + } +} diff --git a/internal/service/financial.go b/internal/service/financial.go index 16e9778..8e0e02c 100644 --- a/internal/service/financial.go +++ b/internal/service/financial.go @@ -234,6 +234,7 @@ func (s *FinancialService) parseAndBuild(csvResult *extract.CSVDataResult, docID AccountingStd: parseResult.AccountingStd, Consolidated: parseResult.Consolidated, Summary: parseResult.Summary, + SummaryPeriod: parseResult.SummaryPeriod, Statements: parseResult.Statements, Warnings: parseResult.Warnings, } @@ -270,7 +271,7 @@ func (s *FinancialService) parseAndBuild(csvResult *extract.CSVDataResult, docID data.Consolidated = hasCons // Rebuild summary from only the filtered statements - data.Summary = financial.BuildAndDeriveSummary(data.Statements) + data.Summary, data.SummaryPeriod = financial.BuildAndDeriveSummary(data.Statements) } // Empty result check diff --git a/internal/service/financial_test.go b/internal/service/financial_test.go index 25ff8df..16f2568 100644 --- a/internal/service/financial_test.go +++ b/internal/service/financial_test.go @@ -441,3 +441,64 @@ func newTestServer(t *testing.T, handler http.Handler) *httptest.Server { t.Cleanup(server.Close) return server } + +// makeFilingStyleCSVZip creates a ZIP with filing-style CSV (Prior1Year contexts only). +func makeFilingStyleCSVZip(t *testing.T) []byte { + t.Helper() + return createCSVZip(t, "XBRL_TO_CSV/jpcrp020400-srs-001_E41257-000_2025-04-30_01_2026-01-09.csv", + []string{"要素ID", "項目名", "コンテキストID", "連結・個別", "期間・時点", "ユニットID", "単位", "値"}, + [][]string{ + {"jpcrp_cor:NetSalesSummaryOfBusinessResults", "売上高、経営指標等", "Prior1YearDuration", "個別", "期間", "JPY", "円", "9426601000"}, + {"jpcrp_cor:OrdinaryIncomeSummaryOfBusinessResults", "経常利益、経営指標等", "Prior1YearDuration", "個別", "期間", "JPY", "円", "1145214000"}, + {"jpcrp_cor:TotalAssetsSummaryOfBusinessResults", "総資産額、経営指標等", "Prior1YearInstant", "個別", "時点", "JPY", "円", "6160640000"}, + {"jpcrp_cor:NetAssetsSummaryOfBusinessResults", "純資産額、経営指標等", "Prior1YearInstant", "個別", "時点", "JPY", "円", "4261992000"}, + }, + ) +} + +func TestFinancialService_GetStatements_FilingStyleCSV(t *testing.T) { + zipData := makeFilingStyleCSVZip(t) + client, _ := setupMockServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/octet-stream") + _, _ = w.Write(zipData) + }) + + svc := NewFinancialService(client, cache.NoopCache{}) + result, err := svc.GetStatements(context.Background(), "S100XF4F", StatementOpts{}) + if err != nil { + t.Fatalf("GetStatements() error = %v", err) + } + if result.DocID != "S100XF4F" { + t.Errorf("DocID = %q, want %q", result.DocID, "S100XF4F") + } + if result.SummaryPeriod != "prior1" { + t.Errorf("SummaryPeriod = %q, want %q", result.SummaryPeriod, "prior1") + } + if result.Summary == nil { + t.Fatal("expected non-nil summary") + } + if rev := result.Summary["revenue"]; rev == nil || *rev != 9426601000 { + t.Errorf("expected revenue=9426601000, got %v", result.Summary["revenue"]) + } + if ta := result.Summary["total_assets"]; ta == nil || *ta != 6160640000 { + t.Errorf("expected total_assets=6160640000, got %v", result.Summary["total_assets"]) + } +} + +func TestFinancialService_GetStatements_FilterRecomputesSummaryPeriod(t *testing.T) { + zipData := makeCSVZip(t) // has current period data + client, _ := setupMockServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/octet-stream") + _, _ = w.Write(zipData) + }) + + svc := NewFinancialService(client, cache.NoopCache{}) + result, err := svc.GetStatements(context.Background(), "S100ABCD", StatementOpts{Statement: "pl"}) + if err != nil { + t.Fatalf("GetStatements() error = %v", err) + } + // After filtering to PL-only, summary should be recomputed with SummaryPeriod + if result.SummaryPeriod != "current" { + t.Errorf("SummaryPeriod = %q, want %q after statement filter recomputation", result.SummaryPeriod, "current") + } +}