From 4b2cab4b43c6c384ce687d42bcaa0f44c3c71ff9 Mon Sep 17 00:00:00 2001 From: beatinaniwa Date: Thu, 2 Apr 2026 00:46:40 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=E6=9C=89=E4=BE=A1=E8=A8=BC?= =?UTF-8?q?=E5=88=B8=E5=B1=8A=E5=87=BA=E6=9B=B8=E3=81=AE=E8=B2=A1=E5=8B=99?= =?UTF-8?q?=E3=82=B5=E3=83=9E=E3=83=AA=E3=83=BC=E5=AF=BE=E5=BF=9C=E3=81=A8?= =?UTF-8?q?doc-description=E3=83=95=E3=82=A3=E3=83=AB=E3=82=BF=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit doc financialが有価証券届出書(IPO時)で空のサマリーを返す問題を修正。 populateSummaryを改修し、current期間がない場合にprior1等へフォールバックする。 また、doc listに--doc-descriptionフラグを追加し、書類説明での絞り込みを可能にした。 --- cmd/doc.go | 11 +- cmd/doc_test.go | 10 ++ internal/financial/parser.go | 6 +- internal/financial/parser_test.go | 30 +++++ internal/financial/summary.go | 125 ++++++++++++++---- internal/financial/summary_test.go | 204 ++++++++++++++++++++++++++++- internal/financial/types.go | 2 + internal/financial/types_test.go | 9 +- internal/schema/schema.go | 1 + internal/schema/schema_test.go | 16 +++ internal/service/document.go | 8 +- internal/service/document_test.go | 23 ++++ internal/service/financial.go | 3 +- internal/service/financial_test.go | 61 +++++++++ 14 files changed, 471 insertions(+), 38 deletions(-) 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..939c961 100644 --- a/internal/financial/summary.go +++ b/internal/financial/summary.go @@ -1,48 +1,121 @@ 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 first, then filing_date. + // bestPeriod is processed first so non-additive keys follow first-wins rule. + extractPeriod := func(target string) { + 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] { + 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") + } +} From 784b775b24b08535b2f97cf019810d13c32fdf7a Mon Sep 17 00:00:00 2001 From: beatinaniwa Date: Thu, 2 Apr 2026 00:51:29 +0900 Subject: [PATCH 2/5] =?UTF-8?q?fix(summary):=20filing=5Fdate=E3=81=8B?= =?UTF-8?q?=E3=82=89=E3=81=AEadditive=20keys=E4=BA=8C=E9=87=8D=E3=82=AB?= =?UTF-8?q?=E3=82=A6=E3=83=B3=E3=83=88=E3=82=92=E9=98=B2=E6=AD=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bestPeriodとfiling_dateの両方にinterest_bearing_debt等が存在する場合に 合算されてしまう問題を修正。additive keysはbestPeriod内でのみ累積し、 filing_dateからは非additive keysのみ補足する。 --- internal/financial/summary.go | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/internal/financial/summary.go b/internal/financial/summary.go index 939c961..f31eea1 100644 --- a/internal/financial/summary.go +++ b/internal/financial/summary.go @@ -80,9 +80,11 @@ func populateSummary(summary Summary, statements []FinancialStatement) string { bestPeriod = periods[0] } - // Phase 3: extract summary items from bestPeriod first, then filing_date. + // Phase 3: extract summary items from bestPeriod, then supplement from filing_date. // bestPeriod is processed first so non-additive keys follow first-wins rule. - extractPeriod := func(target string) { + // Additive keys (e.g. interest_bearing_debt) are only accumulated within + // bestPeriod to avoid mixing debt snapshots from different points in time. + extractItems := func(target string, allowAdditive bool) { for _, stmt := range statements { for _, pd := range stmt.Periods { if pd.Period != target { @@ -93,6 +95,9 @@ func populateSummary(summary Summary, statements []FinancialStatement) string { continue } if additiveKeys[item.SummaryKey] { + if !allowAdditive { + continue + } existing := summary[item.SummaryKey] if existing == nil { v := *item.Value @@ -113,9 +118,9 @@ func populateSummary(summary Summary, statements []FinancialStatement) string { } if bestPeriod != "" { - extractPeriod(bestPeriod) + extractItems(bestPeriod, true) } - extractPeriod("filing_date") + extractItems("filing_date", false) // supplemental only, no additive accumulation return bestPeriod } From 3e163c134c344a41a8beefa17531389b261483a0 Mon Sep 17 00:00:00 2001 From: beatinaniwa Date: Thu, 2 Apr 2026 00:58:28 +0900 Subject: [PATCH 3/5] =?UTF-8?q?fix(summary):=20filing=5Fdate=E3=81=8B?= =?UTF-8?q?=E3=82=89=E3=81=AEadditive=20keys=E5=8F=96=E5=BE=97=E3=82=92fir?= =?UTF-8?q?st-wins=E3=81=AB=E5=A4=89=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bestPeriodにdebt等がない場合にfiling_dateからも取得できるよう修正。 ただしbestPeriodで既に設定済みの場合は混在を防ぐためスキップする。 --- internal/financial/summary.go | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/internal/financial/summary.go b/internal/financial/summary.go index f31eea1..5935090 100644 --- a/internal/financial/summary.go +++ b/internal/financial/summary.go @@ -82,9 +82,10 @@ func populateSummary(summary Summary, statements []FinancialStatement) string { // Phase 3: extract summary items from bestPeriod, then supplement from filing_date. // bestPeriod is processed first so non-additive keys follow first-wins rule. - // Additive keys (e.g. interest_bearing_debt) are only accumulated within - // bestPeriod to avoid mixing debt snapshots from different points in time. - extractItems := func(target string, allowAdditive bool) { + // Additive keys (e.g. interest_bearing_debt) are accumulated within a single period + // to avoid mixing debt snapshots from different points in time. Filing_date only + // contributes additive keys if they were not already set by bestPeriod. + extractItems := func(target string, additive bool) { for _, stmt := range statements { for _, pd := range stmt.Periods { if pd.Period != target { @@ -95,16 +96,22 @@ func populateSummary(summary Summary, statements []FinancialStatement) string { continue } if additiveKeys[item.SummaryKey] { - if !allowAdditive { - continue - } - existing := summary[item.SummaryKey] - if existing == nil { - v := *item.Value - summary[item.SummaryKey] = &v + if additive { + // Within the primary period: accumulate (e.g. CL + NCL debt) + existing := summary[item.SummaryKey] + if existing == nil { + v := *item.Value + summary[item.SummaryKey] = &v + } else { + v := *existing + *item.Value + summary[item.SummaryKey] = &v + } } else { - v := *existing + *item.Value - summary[item.SummaryKey] = &v + // Supplemental period: only fill if not already set + if _, exists := summary[item.SummaryKey]; !exists { + v := *item.Value + summary[item.SummaryKey] = &v + } } } else { if _, exists := summary[item.SummaryKey]; !exists { @@ -120,7 +127,7 @@ func populateSummary(summary Summary, statements []FinancialStatement) string { if bestPeriod != "" { extractItems(bestPeriod, true) } - extractItems("filing_date", false) // supplemental only, no additive accumulation + extractItems("filing_date", false) return bestPeriod } From 24de8e0588bea129cc0fc1420ddc2838d176f3d6 Mon Sep 17 00:00:00 2001 From: beatinaniwa Date: Thu, 2 Apr 2026 01:07:21 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix(summary):=20filing=5Fdate-only=E3=81=AE?= =?UTF-8?q?=E5=A0=B4=E5=90=88=E3=81=ABadditive=20keys=E3=82=92=E6=AD=A3?= =?UTF-8?q?=E3=81=97=E3=81=8F=E5=90=88=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bestPeriodが空の場合、filing_dateをprimaryソースとして扱い additive keys(interest_bearing_debt等)の累積を有効にする。 --- internal/financial/summary.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/financial/summary.go b/internal/financial/summary.go index 5935090..7d0b5d0 100644 --- a/internal/financial/summary.go +++ b/internal/financial/summary.go @@ -126,8 +126,10 @@ func populateSummary(summary Summary, statements []FinancialStatement) string { if bestPeriod != "" { extractItems(bestPeriod, true) + extractItems("filing_date", false) // supplement only, no additive accumulation + } else { + extractItems("filing_date", true) // filing_date is the primary source } - extractItems("filing_date", false) return bestPeriod } From 5468da65b4226ee17ad75ddb0bd81f9ae6df5672 Mon Sep 17 00:00:00 2001 From: beatinaniwa Date: Thu, 2 Apr 2026 01:17:55 +0900 Subject: [PATCH 5/5] =?UTF-8?q?fix(summary):=20additive=20keys=E3=82=92?= =?UTF-8?q?=E6=9C=9F=E9=96=93=E5=86=85=E3=81=A7=E5=90=88=E7=AE=97=E3=81=97?= =?UTF-8?q?=E6=9C=9F=E9=96=93=E9=96=93=E3=81=A7=E3=81=AFfirst-wins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit additive keys(interest_bearing_debt等)は同一期間内で合算するが、 bestPeriodで既に設定済みの場合はfiling_dateからは取得しない。 filing_date-onlyの場合は期間内合算が正しく動作する。 --- internal/financial/summary.go | 46 +++++++++++++++++------------------ 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/internal/financial/summary.go b/internal/financial/summary.go index 7d0b5d0..c2ccd26 100644 --- a/internal/financial/summary.go +++ b/internal/financial/summary.go @@ -81,11 +81,17 @@ func populateSummary(summary Summary, statements []FinancialStatement) string { } // Phase 3: extract summary items from bestPeriod, then supplement from filing_date. - // bestPeriod is processed first so non-additive keys follow first-wins rule. - // Additive keys (e.g. interest_bearing_debt) are accumulated within a single period - // to avoid mixing debt snapshots from different points in time. Filing_date only - // contributes additive keys if they were not already set by bestPeriod. - extractItems := func(target string, additive bool) { + // 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 { @@ -96,22 +102,16 @@ func populateSummary(summary Summary, statements []FinancialStatement) string { continue } if additiveKeys[item.SummaryKey] { - if additive { - // Within the primary period: accumulate (e.g. CL + NCL debt) - existing := summary[item.SummaryKey] - if existing == nil { - v := *item.Value - summary[item.SummaryKey] = &v - } else { - v := *existing + *item.Value - summary[item.SummaryKey] = &v - } + 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 { - // Supplemental period: only fill if not already set - if _, exists := summary[item.SummaryKey]; !exists { - v := *item.Value - summary[item.SummaryKey] = &v - } + v := *existing + *item.Value + summary[item.SummaryKey] = &v } } else { if _, exists := summary[item.SummaryKey]; !exists { @@ -125,11 +125,9 @@ func populateSummary(summary Summary, statements []FinancialStatement) string { } if bestPeriod != "" { - extractItems(bestPeriod, true) - extractItems("filing_date", false) // supplement only, no additive accumulation - } else { - extractItems("filing_date", true) // filing_date is the primary source + extractPeriod(bestPeriod) } + extractPeriod("filing_date") return bestPeriod }