From ab9f570e858755a097aa881f6c824866d8f3cc73 Mon Sep 17 00:00:00 2001 From: beatinaniwa Date: Wed, 1 Apr 2026 14:24:31 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat(financial):=20EDINET=20XBRL=20CSV?= =?UTF-8?q?=E3=81=8B=E3=82=89=E6=A7=8B=E9=80=A0=E5=8C=96=E8=B2=A1=E5=8B=99?= =?UTF-8?q?=E8=AB=B8=E8=A1=A8=E3=82=92=E6=8A=BD=E5=87=BA=E3=81=99=E3=82=8B?= =?UTF-8?q?doc=20financial/company=20financials=E3=82=B3=E3=83=9E=E3=83=B3?= =?UTF-8?q?=E3=83=89=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AIエージェント(Claude Code等)がバフェットコード的な財務分析を行うためのデータ抽出基盤。 summaryフィールドで主要財務項目(売上高、営業利益、純利益、総資産、自己資本、営業CF等)に 即アクセス可能。IFRS/JP-GAAP両対応、連結・個別の自動選択を実装。 --- cmd/company.go | 60 ++ cmd/company_test.go | 41 ++ cmd/doc.go | 41 ++ cmd/doc_test.go | 30 + cmd/helpers.go | 16 + cmd/schema.go | 10 + cmd/schema_test.go | 54 ++ internal/financial/classifier.go | 393 +++++++++++ internal/financial/classifier_test.go | 593 +++++++++++++++++ internal/financial/context.go | 123 ++++ internal/financial/context_test.go | 150 +++++ internal/financial/parser.go | 555 ++++++++++++++++ internal/financial/parser_test.go | 914 ++++++++++++++++++++++++++ internal/financial/types.go | 72 ++ internal/financial/types_test.go | 73 ++ internal/schema/schema.go | 31 + internal/schema/schema_test.go | 2 +- internal/service/financial.go | 317 +++++++++ internal/service/financial_test.go | 443 +++++++++++++ 19 files changed, 3917 insertions(+), 1 deletion(-) create mode 100644 internal/financial/classifier.go create mode 100644 internal/financial/classifier_test.go create mode 100644 internal/financial/context.go create mode 100644 internal/financial/context_test.go create mode 100644 internal/financial/parser.go create mode 100644 internal/financial/parser_test.go create mode 100644 internal/financial/types.go create mode 100644 internal/financial/types_test.go create mode 100644 internal/service/financial.go create mode 100644 internal/service/financial_test.go diff --git a/cmd/company.go b/cmd/company.go index 9e04fc1..4410fbb 100644 --- a/cmd/company.go +++ b/cmd/company.go @@ -21,6 +21,10 @@ var ( companyFilingsTo string companyFilingsLimit int companySearchIndustry string + + companyFinancialsPeriods int + companyFinancialsStatement string + companyFinancialsNonConsolidated bool ) const ( @@ -121,6 +125,57 @@ var companyFilingsCmd = &cobra.Command{ }, } +var companyFinancialsCmd = &cobra.Command{ + Use: "financials ", + Short: "Extract financial statements for multiple fiscal periods", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + code := args[0] + + if companyFinancialsPeriods < 1 || companyFinancialsPeriods > 10 { + return &api.EDINETError{Code: api.ErrValidation, Message: fmt.Sprintf("--periods must be between 1 and 10, got %d", companyFinancialsPeriods)} + } + if err := validateStatement(companyFinancialsStatement); err != nil { + return err + } + if app.Config.SubscriptionKey == "" { + return &api.EDINETError{Code: api.ErrAuth, Message: "EDINET_API_KEY environment variable is required"} + } + + client := api.NewClient(app.Config.SubscriptionKey, "https://api.edinet-fsa.go.jp", app.Config.Debug) + docSvc := service.NewDocumentService(client, app.Cache, cmd.ErrOrStderr()) + finSvc := service.NewFinancialService(client, app.Cache) + + var reg *company.Registry + if !isEdinetCode(code) { + var err error + reg, err = loadRegistry() + if err != nil { + return err + } + } + + companySvc := service.NewCompanyService(reg, docSvc) + + opts := service.CompanyFinancialsOpts{ + StatementOpts: service.StatementOpts{ + Statement: companyFinancialsStatement, + }, + Periods: companyFinancialsPeriods, + RateLimit: 100 * time.Millisecond, + } + if companyFinancialsNonConsolidated { + opts.Consolidated = ptrBool(false) + } + + result, err := finSvc.GetCompanyFinancials(cmd.Context(), companySvc, code, opts) + if err != nil { + return err + } + return outputResult(cmd.OutOrStdout(), result) + }, +} + var companyUpdateCmd = &cobra.Command{ Use: "update", Short: "Download and update the EDINET code list", @@ -228,8 +283,13 @@ func init() { companyFilingsCmd.Flags().StringVar(&companyFilingsTo, "to", "", "Range end date (default: today)") companyFilingsCmd.Flags().IntVar(&companyFilingsLimit, "limit", 0, "Maximum number of results (0=unlimited)") + companyFinancialsCmd.Flags().IntVar(&companyFinancialsPeriods, "periods", 3, "Number of fiscal periods (1-10)") + companyFinancialsCmd.Flags().StringVar(&companyFinancialsStatement, "statement", "all", "Statement type: bs, pl, cf, all") + companyFinancialsCmd.Flags().BoolVar(&companyFinancialsNonConsolidated, "non-consolidated", false, "Prefer non-consolidated statements") + companyCmd.AddCommand(companySearchCmd) companyCmd.AddCommand(companyFilingsCmd) + companyCmd.AddCommand(companyFinancialsCmd) companyCmd.AddCommand(companyUpdateCmd) rootCmd.AddCommand(companyCmd) } diff --git a/cmd/company_test.go b/cmd/company_test.go index 29dc42c..a3d7d53 100644 --- a/cmd/company_test.go +++ b/cmd/company_test.go @@ -54,3 +54,44 @@ func TestCompanyFilings_HelpOutput(t *testing.T) { t.Error("expected help output") } } + +func TestCompanyFinancials_NoArgs(t *testing.T) { + _, _, code := executeCommand("company", "financials") + if code == 0 { + t.Error("expected non-zero exit code when no code provided") + } +} + +func TestCompanyFinancials_PeriodsZero(t *testing.T) { + _, stderr, code := executeCommand("company", "financials", "E02144", "--periods", "0") + if code == 0 { + t.Error("expected non-zero exit code for --periods 0") + } + expectErrorCode(t, stderr, "VALIDATION_ERROR") +} + +func TestCompanyFinancials_PeriodsTooHigh(t *testing.T) { + _, stderr, code := executeCommand("company", "financials", "E02144", "--periods", "11") + if code == 0 { + t.Error("expected non-zero exit code for --periods 11") + } + expectErrorCode(t, stderr, "VALIDATION_ERROR") +} + +func TestCompanyFinancials_InvalidStatement(t *testing.T) { + _, stderr, code := executeCommand("company", "financials", "E02144", "--statement", "invalid") + if code == 0 { + t.Error("expected non-zero exit code for invalid --statement") + } + expectErrorCode(t, stderr, "VALIDATION_ERROR") +} + +func TestCompanyFinancials_HelpOutput(t *testing.T) { + stdout, _, code := executeCommand("company", "financials", "--help") + if code != 0 { + t.Error("company financials --help should succeed") + } + if stdout == "" { + t.Error("expected help output") + } +} diff --git a/cmd/doc.go b/cmd/doc.go index 56e5a16..bd8c0ab 100644 --- a/cmd/doc.go +++ b/cmd/doc.go @@ -29,6 +29,9 @@ var ( docTextSection string docTextListSections bool + + docFinancialStatement string + docFinancialNonConsolidated bool ) var downloadTypeMap = map[string]int{ @@ -176,6 +179,40 @@ var docDataCmd = &cobra.Command{ }, } +var docFinancialCmd = &cobra.Command{ + Use: "financial ", + Short: "Extract structured financial statements from CSV", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + docID := args[0] + if !isValidDocID(docID) { + return &api.EDINETError{Code: api.ErrValidation, Message: fmt.Sprintf("invalid docID %q: must match S followed by digits (e.g. S100ABCD)", docID)} + } + if err := validateStatement(docFinancialStatement); err != nil { + return err + } + if app.Config.SubscriptionKey == "" { + return &api.EDINETError{Code: api.ErrAuth, Message: "EDINET_API_KEY environment variable is required"} + } + + client := api.NewClient(app.Config.SubscriptionKey, "https://api.edinet-fsa.go.jp", app.Config.Debug) + svc := service.NewFinancialService(client, app.Cache) + + opts := service.StatementOpts{ + Statement: docFinancialStatement, + } + if docFinancialNonConsolidated { + opts.Consolidated = ptrBool(false) + } + + result, err := svc.GetStatements(cmd.Context(), docID, opts) + if err != nil { + return err + } + return outputResult(cmd.OutOrStdout(), result) + }, +} + var docTextCmd = &cobra.Command{ Use: "text [docID]", Short: "Extract text from document HTML (best-effort)", @@ -311,9 +348,13 @@ func init() { docTextCmd.Flags().StringVar(&docTextSection, "section", "", "Section ID or heading pattern") docTextCmd.Flags().BoolVar(&docTextListSections, "list-sections", false, "List available sections") + docFinancialCmd.Flags().StringVar(&docFinancialStatement, "statement", "all", "Statement type: bs, pl, cf, all") + docFinancialCmd.Flags().BoolVar(&docFinancialNonConsolidated, "non-consolidated", false, "Prefer non-consolidated statements") + docCmd.AddCommand(docListCmd) docCmd.AddCommand(docGetCmd) docCmd.AddCommand(docDataCmd) docCmd.AddCommand(docTextCmd) + docCmd.AddCommand(docFinancialCmd) rootCmd.AddCommand(docCmd) } diff --git a/cmd/doc_test.go b/cmd/doc_test.go index e8ac14d..5788a24 100644 --- a/cmd/doc_test.go +++ b/cmd/doc_test.go @@ -21,6 +21,21 @@ func executeCommand(args ...string) (stdout, stderr string, exitCode int) { return outBuf.String(), errBuf.String(), exitCode } +// expectErrorCode asserts the stderr JSON contains the expected error code. +func expectErrorCode(t *testing.T, stderr, wantCode string) { + t.Helper() + var errResp struct { + Error struct { + Code string `json:"code"` + } `json:"error"` + } + if err := json.Unmarshal([]byte(stderr), &errResp); err == nil { + if errResp.Error.Code != wantCode { + t.Errorf("error code = %q, want %q", errResp.Error.Code, wantCode) + } + } +} + func TestDocList_NoDateFlag(t *testing.T) { _, stderr, code := executeCommand("doc", "list") if code == 0 { @@ -101,6 +116,21 @@ func TestDocText_NoDocID(t *testing.T) { } } +func TestDocFinancial_NoDocID(t *testing.T) { + _, _, code := executeCommand("doc", "financial") + if code == 0 { + t.Error("expected non-zero exit code when no docID provided") + } +} + +func TestDocFinancial_InvalidStatement(t *testing.T) { + _, stderr, code := executeCommand("doc", "financial", "S100ABCD", "--statement", "invalid") + if code == 0 { + t.Error("expected non-zero exit code for invalid --statement") + } + expectErrorCode(t, stderr, "VALIDATION_ERROR") +} + func TestDocText_ListSectionsOutput(t *testing.T) { stdout, _, code := executeCommand("doc", "text", "--list-sections") if code != 0 { diff --git a/cmd/helpers.go b/cmd/helpers.go index a179d76..d8d0f43 100644 --- a/cmd/helpers.go +++ b/cmd/helpers.go @@ -71,6 +71,22 @@ func renderMapTable(w io.Writer, rows []map[string]any) error { return nil } +// validStatements defines accepted --statement flag values. +var validStatements = map[string]bool{ + "bs": true, "pl": true, "cf": true, "all": true, +} + +// validateStatement checks that stmt is a valid statement type. +func validateStatement(stmt string) error { + if !validStatements[stmt] { + return &api.EDINETError{Code: api.ErrValidation, Message: fmt.Sprintf("invalid --statement %q, must be one of: bs, pl, cf, all", stmt)} + } + return nil +} + +// ptrBool returns a pointer to b. Used for optional *bool flags. +func ptrBool(b bool) *bool { return &b } + // exitError writes a structured error to w and returns the appropriate exit code. func exitError(w io.Writer, err error) int { if edinetErr, ok := err.(*api.EDINETError); ok { diff --git a/cmd/schema.go b/cmd/schema.go index dc2d243..d7663f3 100644 --- a/cmd/schema.go +++ b/cmd/schema.go @@ -1,6 +1,7 @@ package cmd import ( + "github.com/beatinaniwa/edinet-cli/internal/financial" "github.com/beatinaniwa/edinet-cli/internal/schema" "github.com/spf13/cobra" ) @@ -34,9 +35,18 @@ var schemaSectionsCmd = &cobra.Command{ }, } +var schemaFinancialElementsCmd = &cobra.Command{ + Use: "financial-elements", + Short: "List all known financial XBRL element mappings", + RunE: func(cmd *cobra.Command, args []string) error { + return outputResult(cmd.OutOrStdout(), financial.Elements()) + }, +} + func init() { schemaCmd.AddCommand(schemaCommandsCmd) schemaCmd.AddCommand(schemaDocTypesCmd) schemaCmd.AddCommand(schemaSectionsCmd) + schemaCmd.AddCommand(schemaFinancialElementsCmd) rootCmd.AddCommand(schemaCmd) } diff --git a/cmd/schema_test.go b/cmd/schema_test.go index 4d00d4a..0faf322 100644 --- a/cmd/schema_test.go +++ b/cmd/schema_test.go @@ -35,6 +35,60 @@ func TestSchemaSections_OutputIsJSON(t *testing.T) { } } +func TestSchemaFinancialElements_OutputIsJSON(t *testing.T) { + stdout, _, code := executeCommand("schema", "financial-elements") + if code != 0 { + t.Fatalf("schema financial-elements exit code = %d, want 0", code) + } + if !json.Valid([]byte(stdout)) { + t.Errorf("output is not valid JSON: %q", stdout[:min(100, len(stdout))]) + } + // Verify it's an array + var elems []map[string]any + if err := json.Unmarshal([]byte(stdout), &elems); err != nil { + t.Fatalf("failed to parse output as array: %v", err) + } + if len(elems) == 0 { + t.Error("expected non-empty elements array") + } +} + +func TestSchemaCommands_ContainsDocFinancial(t *testing.T) { + stdout, _, _ := executeCommand("schema", "commands") + var cmds []map[string]any + if err := json.Unmarshal([]byte(stdout), &cmds); err != nil { + t.Fatalf("failed to parse output: %v", err) + } + found := false + for _, c := range cmds { + if c["name"] == "doc financial" { + found = true + break + } + } + if !found { + t.Error("schema commands output missing 'doc financial'") + } +} + +func TestSchemaCommands_ContainsSchemaFinancialElements(t *testing.T) { + stdout, _, _ := executeCommand("schema", "commands") + var cmds []map[string]any + if err := json.Unmarshal([]byte(stdout), &cmds); err != nil { + t.Fatalf("failed to parse output: %v", err) + } + found := false + for _, c := range cmds { + if c["name"] == "schema financial-elements" { + found = true + break + } + } + if !found { + t.Error("schema commands output missing 'schema financial-elements'") + } +} + func TestSchemaCommands_ContainsDocList(t *testing.T) { stdout, _, _ := executeCommand("schema", "commands") var cmds []map[string]any diff --git a/internal/financial/classifier.go b/internal/financial/classifier.go new file mode 100644 index 0000000..177e664 --- /dev/null +++ b/internal/financial/classifier.go @@ -0,0 +1,393 @@ +package financial + +import "strings" + +// StatementType represents a financial statement type. +type StatementType string + +const ( + StmtBS StatementType = "bs" + StmtPL StatementType = "pl" + StmtCF StatementType = "cf" + StmtUnknown StatementType = "unknown" +) + +// ElementClassification holds the classification result for an XBRL element. +type ElementClassification struct { + Statement StatementType + Category string + SortOrder int + IsTotal bool + SummaryKey string // maps to summary field, "" if not a summary item +} + +// ElementInfo describes a known element mapping for schema output. +type ElementInfo struct { + ID string `json:"id"` + Statement StatementType `json:"statement"` + Category string `json:"category"` + SummaryKey string `json:"summary_key,omitempty"` + LabelEN string `json:"label_en,omitempty"` +} + +// elementDef is the internal definition for a known element. +type elementDef struct { + statement StatementType + category string + sortOrder int + isTotal bool + summaryKey string + labelEN string +} + +// knownElements maps full element IDs to their classification. +// This is the single source of truth for element classification and summary key mapping. +var knownElements map[string]elementDef + +// companySuffixes maps element suffixes (after the colon in company-specific IDs) to their classification. +// Used for jpcrp030000-asr_* elements. +var companySuffixes map[string]elementDef + +func init() { + knownElements = map[string]elementDef{ + // ============================================================ + // IFRS Balance Sheet (jpigp_cor:) + // ============================================================ + + // Current Assets + "jpigp_cor:CashAndCashEquivalentsIFRS": {StmtBS, "current_assets", 100, false, "cash_and_equivalents", "Cash and cash equivalents"}, + "jpigp_cor:TradeAndOtherCurrentReceivablesIFRS": {StmtBS, "current_assets", 110, false, "", "Trade and other receivables (current)"}, + "jpigp_cor:OtherCurrentFinancialAssetsIFRS": {StmtBS, "current_assets", 120, false, "", "Other current financial assets"}, + "jpigp_cor:InventoriesIFRS": {StmtBS, "current_assets", 130, false, "", "Inventories"}, + "jpigp_cor:OtherCurrentAssetsIFRS": {StmtBS, "current_assets", 140, false, "", "Other current assets"}, + "jpigp_cor:CurrentAssetsIFRS": {StmtBS, "current_assets", 199, true, "current_assets", "Total current assets"}, + + // Non-current Assets + "jpigp_cor:PropertyPlantAndEquipmentIFRS": {StmtBS, "noncurrent_assets", 200, false, "", "Property, plant and equipment"}, + "jpigp_cor:RightOfUseAssetsIFRS": {StmtBS, "noncurrent_assets", 205, false, "", "Right-of-use assets"}, + "jpigp_cor:GoodwillIFRS": {StmtBS, "noncurrent_assets", 210, false, "", "Goodwill"}, + "jpigp_cor:IntangibleAssetsIFRS": {StmtBS, "noncurrent_assets", 220, false, "", "Intangible assets"}, + "jpigp_cor:InvestmentAccountedForUsingEquityMethodIFRS": {StmtBS, "noncurrent_assets", 230, false, "", "Investments using equity method"}, + "jpigp_cor:OtherNonCurrentFinancialAssetsIFRS": {StmtBS, "noncurrent_assets", 240, false, "", "Other non-current financial assets"}, + "jpigp_cor:DeferredTaxAssetsIFRS": {StmtBS, "noncurrent_assets", 250, false, "", "Deferred tax assets"}, + "jpigp_cor:OtherNonCurrentAssetsIFRS": {StmtBS, "noncurrent_assets", 260, false, "", "Other non-current assets"}, + "jpigp_cor:NonCurrentAssetsIFRS": {StmtBS, "noncurrent_assets", 299, true, "", "Total non-current assets"}, + "jpigp_cor:AssetsIFRS": {StmtBS, "total", 300, true, "total_assets", "Total assets"}, + + // Current Liabilities + "jpigp_cor:TradeAndOtherCurrentPayablesIFRS": {StmtBS, "current_liabilities", 400, false, "", "Trade and other payables (current)"}, + "jpigp_cor:InterestBearingLiabilitiesCLIFRS": {StmtBS, "current_liabilities", 410, false, "interest_bearing_debt", "Interest-bearing liabilities (current)"}, + "jpigp_cor:OtherCurrentFinancialLiabilitiesIFRS": {StmtBS, "current_liabilities", 420, false, "", "Other current financial liabilities"}, + "jpigp_cor:IncomeTaxPayablesIFRS": {StmtBS, "current_liabilities", 430, false, "", "Income tax payables"}, + "jpigp_cor:ProvisionsCurrentIFRS": {StmtBS, "current_liabilities", 440, false, "", "Provisions (current)"}, + "jpigp_cor:OtherCurrentLiabilitiesIFRS": {StmtBS, "current_liabilities", 450, false, "", "Other current liabilities"}, + "jpigp_cor:TotalCurrentLiabilitiesIFRS": {StmtBS, "current_liabilities", 499, true, "current_liabilities", "Total current liabilities"}, + + // Non-current Liabilities + "jpigp_cor:InterestBearingLiabilitiesNCLIFRS": {StmtBS, "noncurrent_liabilities", 500, false, "interest_bearing_debt", "Interest-bearing liabilities (non-current)"}, + "jpigp_cor:OtherNonCurrentFinancialLiabilitiesIFRS": {StmtBS, "noncurrent_liabilities", 510, false, "", "Other non-current financial liabilities"}, + "jpigp_cor:DeferredTaxLiabilitiesIFRS": {StmtBS, "noncurrent_liabilities", 520, false, "", "Deferred tax liabilities"}, + "jpigp_cor:ProvisionsNonCurrentIFRS": {StmtBS, "noncurrent_liabilities", 530, false, "", "Provisions (non-current)"}, + "jpigp_cor:RetirementBenefitLiabilityIFRS": {StmtBS, "noncurrent_liabilities", 540, false, "", "Retirement benefit liability"}, + "jpigp_cor:OtherNonCurrentLiabilitiesIFRS": {StmtBS, "noncurrent_liabilities", 550, false, "", "Other non-current liabilities"}, + "jpigp_cor:NonCurrentLiabilitiesIFRS": {StmtBS, "noncurrent_liabilities", 599, true, "", "Total non-current liabilities"}, + "jpigp_cor:LiabilitiesIFRS": {StmtBS, "total", 600, true, "total_liabilities", "Total liabilities"}, + + // Equity + "jpigp_cor:ShareCapitalIFRS": {StmtBS, "equity", 700, false, "", "Share capital"}, + "jpigp_cor:CapitalSurplusIFRS": {StmtBS, "equity", 710, false, "", "Capital surplus"}, + "jpigp_cor:RetainedEarningsIFRS": {StmtBS, "equity", 720, false, "", "Retained earnings"}, + "jpigp_cor:TreasurySharesIFRS": {StmtBS, "equity", 730, false, "", "Treasury shares"}, + "jpigp_cor:OtherComponentsOfEquityIFRS": {StmtBS, "equity", 740, false, "", "Other components of equity"}, + "jpigp_cor:EquityAttributableToOwnersOfParentIFRS": {StmtBS, "equity", 790, true, "equity", "Equity attributable to owners of parent"}, + "jpigp_cor:NonControllingInterestsIFRS": {StmtBS, "equity", 795, false, "", "Non-controlling interests"}, + "jpigp_cor:EquityIFRS": {StmtBS, "equity", 799, true, "net_assets", "Total equity"}, + + // ============================================================ + // IFRS Income Statement (jpigp_cor:) + // ============================================================ + "jpigp_cor:RevenueIFRS": {StmtPL, "revenue", 1000, true, "revenue", "Revenue"}, + "jpigp_cor:CostOfSalesIFRS": {StmtPL, "cost_of_sales", 1010, false, "cost_of_sales", "Cost of sales"}, + "jpigp_cor:GrossProfitIFRS": {StmtPL, "gross_profit", 1020, true, "gross_profit", "Gross profit"}, + "jpigp_cor:SellingGeneralAndAdministrativeExpensesIFRS": {StmtPL, "operating", 1030, false, "sga_expenses", "SGA expenses"}, + "jpigp_cor:OtherIncomeIFRS": {StmtPL, "operating", 1040, false, "", "Other income"}, + "jpigp_cor:OtherExpensesIFRS": {StmtPL, "operating", 1050, false, "", "Other expenses"}, + "jpigp_cor:OperatingProfitLossIFRS": {StmtPL, "operating_income", 1060, true, "operating_income", "Operating profit/loss"}, + "jpigp_cor:FinanceIncomeIFRS": {StmtPL, "finance", 1070, false, "", "Finance income"}, + "jpigp_cor:FinanceCostsIFRS": {StmtPL, "finance", 1080, false, "", "Finance costs"}, + "jpigp_cor:ShareOfProfitLossOfInvestmentsAccountedForUsingEquityMethodIFRS": {StmtPL, "finance", 1090, false, "", "Share of profit of equity method investments"}, + "jpigp_cor:ProfitLossBeforeTaxIFRS": {StmtPL, "pretax", 1100, true, "", "Profit before tax"}, + "jpigp_cor:IncomeTaxExpenseIFRS": {StmtPL, "tax", 1110, false, "", "Income tax expense"}, + "jpigp_cor:ProfitLossIFRS": {StmtPL, "net_income", 1120, true, "", "Profit/loss"}, + "jpigp_cor:ProfitLossAttributableToOwnersOfParentIFRS": {StmtPL, "net_income", 1130, true, "net_income", "Profit attributable to owners of parent"}, + "jpigp_cor:ProfitLossAttributableToNonControllingInterestsIFRS": {StmtPL, "net_income", 1140, false, "", "Profit attributable to non-controlling interests"}, + + // EPS + "jpigp_cor:BasicEarningsLossPerShareIFRS": {StmtPL, "eps", 1200, false, "eps", "Basic EPS"}, + "jpigp_cor:BasicAndDilutedEarningsLossPerShareIFRS": {StmtPL, "eps", 1201, false, "eps", "Basic and diluted EPS"}, + "jpigp_cor:DilutedEarningsLossPerShareIFRS": {StmtPL, "eps", 1210, false, "", "Diluted EPS"}, + + // ============================================================ + // IFRS Cash Flow Statement (jpigp_cor:) + // ============================================================ + "jpigp_cor:DepreciationAndAmortisationIFRS": {StmtCF, "operating_activities", 2000, false, "depreciation", "Depreciation and amortisation"}, + "jpigp_cor:ImpairmentLossReversalOfImpairmentLossRecognisedInProfitOrLossIFRS": {StmtCF, "operating_activities", 2010, false, "", "Impairment loss"}, + "jpigp_cor:CashFlowsFromUsedInOperatingActivitiesIFRS": {StmtCF, "operating_activities", 2099, true, "operating_cf", "Cash flows from operating activities"}, + "jpigp_cor:CashFlowsFromUsedInInvestingActivitiesIFRS": {StmtCF, "investing_activities", 2199, true, "investing_cf", "Cash flows from investing activities"}, + "jpigp_cor:CashFlowsFromUsedInFinancingActivitiesIFRS": {StmtCF, "financing_activities", 2299, true, "financing_cf", "Cash flows from financing activities"}, + "jpigp_cor:NetCashProvidedByUsedInOperatingActivitiesIFRS": {StmtCF, "operating_activities", 2099, true, "operating_cf", "Net cash from operating activities"}, + "jpigp_cor:NetCashProvidedByUsedInInvestingActivitiesIFRS": {StmtCF, "investing_activities", 2199, true, "investing_cf", "Net cash from investing activities"}, + "jpigp_cor:NetCashProvidedByUsedInFinancingActivitiesIFRS": {StmtCF, "financing_activities", 2299, true, "financing_cf", "Net cash from financing activities"}, + "jpigp_cor:CapitalExpendituresIFRS": {StmtCF, "investing_activities", 2110, false, "capital_expenditure", "Capital expenditures"}, + + // jpcrp_cor CF summary elements (key financial data / summary of business results) + "jpcrp_cor:CashFlowsFromUsedInOperatingActivitiesIFRSSummaryOfBusinessResults": {StmtCF, "operating_activities", 2098, true, "operating_cf", "Operating CF (summary)"}, + "jpcrp_cor:CashFlowsFromUsedInInvestingActivitiesIFRSSummaryOfBusinessResults": {StmtCF, "investing_activities", 2198, true, "investing_cf", "Investing CF (summary)"}, + "jpcrp_cor:CashFlowsFromUsedInFinancingActivitiesIFRSSummaryOfBusinessResults": {StmtCF, "financing_activities", 2298, true, "financing_cf", "Financing CF (summary)"}, + + // ============================================================ + // JP-GAAP Balance Sheet (jppfs_cor:) + // ============================================================ + + // Current Assets + "jppfs_cor:CashAndDeposits": {StmtBS, "current_assets", 100, false, "cash_and_equivalents", "Cash and deposits"}, + "jppfs_cor:NotesAndAccountsReceivableTrade": {StmtBS, "current_assets", 110, false, "", "Notes and accounts receivable - trade"}, + "jppfs_cor:NotesAndAccountsReceivableTradeAndContractAssets": {StmtBS, "current_assets", 111, false, "", "Notes and accounts receivable - trade, and contract assets"}, + "jppfs_cor:SecuritiesCurrent": {StmtBS, "current_assets", 115, false, "", "Securities (current)"}, + "jppfs_cor:MerchandiseAndFinishedGoods": {StmtBS, "current_assets", 120, false, "", "Merchandise and finished goods"}, + "jppfs_cor:WorkInProcess": {StmtBS, "current_assets", 121, false, "", "Work in process"}, + "jppfs_cor:RawMaterialsAndSupplies": {StmtBS, "current_assets", 122, false, "", "Raw materials and supplies"}, + "jppfs_cor:OtherCurrentAssets": {StmtBS, "current_assets", 140, false, "", "Other current assets"}, + "jppfs_cor:AllowanceForDoubtfulAccountsCurrentAssets": {StmtBS, "current_assets", 145, false, "", "Allowance for doubtful accounts (current)"}, + "jppfs_cor:CurrentAssets": {StmtBS, "current_assets", 199, true, "current_assets", "Total current assets"}, + + // Non-current Assets + "jppfs_cor:BuildingsAndStructuresNet": {StmtBS, "noncurrent_assets", 200, false, "", "Buildings and structures (net)"}, + "jppfs_cor:MachineryEquipmentAndVehiclesNet": {StmtBS, "noncurrent_assets", 201, false, "", "Machinery, equipment and vehicles (net)"}, + "jppfs_cor:LandNet": {StmtBS, "noncurrent_assets", 202, false, "", "Land"}, + "jppfs_cor:ConstructionInProgress": {StmtBS, "noncurrent_assets", 203, false, "", "Construction in progress"}, + "jppfs_cor:PropertyPlantAndEquipment": {StmtBS, "noncurrent_assets", 210, true, "", "Total property, plant and equipment"}, + "jppfs_cor:GoodwillNet": {StmtBS, "noncurrent_assets", 220, false, "", "Goodwill"}, + "jppfs_cor:IntangibleAssetsNet": {StmtBS, "noncurrent_assets", 229, true, "", "Total intangible assets"}, + "jppfs_cor:InvestmentSecurities": {StmtBS, "noncurrent_assets", 230, false, "", "Investment securities"}, + "jppfs_cor:InvestmentsAndOtherAssets": {StmtBS, "noncurrent_assets", 249, true, "", "Total investments and other assets"}, + "jppfs_cor:NoncurrentAssets": {StmtBS, "noncurrent_assets", 299, true, "", "Total non-current assets"}, + "jppfs_cor:TotalAssets": {StmtBS, "total", 300, true, "total_assets", "Total assets"}, + + // Current Liabilities + "jppfs_cor:NotesAndAccountsPayableTrade": {StmtBS, "current_liabilities", 400, false, "", "Notes and accounts payable - trade"}, + "jppfs_cor:ShortTermLoansPayable": {StmtBS, "current_liabilities", 410, false, "interest_bearing_debt", "Short-term loans payable"}, + "jppfs_cor:CurrentPortionOfLongTermLoansPayable": {StmtBS, "current_liabilities", 411, false, "interest_bearing_debt", "Current portion of long-term loans payable"}, + "jppfs_cor:CurrentPortionOfBonds": {StmtBS, "current_liabilities", 412, false, "interest_bearing_debt", "Current portion of bonds"}, + "jppfs_cor:CommercialPapersLiabilities": {StmtBS, "current_liabilities", 413, false, "interest_bearing_debt", "Commercial papers"}, + "jppfs_cor:AccruedExpenses": {StmtBS, "current_liabilities", 420, false, "", "Accrued expenses"}, + "jppfs_cor:IncomeTaxesPayable": {StmtBS, "current_liabilities", 430, false, "", "Income taxes payable"}, + "jppfs_cor:ProvisionForBonuses": {StmtBS, "current_liabilities", 435, false, "", "Provision for bonuses"}, + "jppfs_cor:OtherCurrentLiabilities": {StmtBS, "current_liabilities", 450, false, "", "Other current liabilities"}, + "jppfs_cor:CurrentLiabilities": {StmtBS, "current_liabilities", 499, true, "current_liabilities", "Total current liabilities"}, + + // Non-current Liabilities + "jppfs_cor:BondsPayable": {StmtBS, "noncurrent_liabilities", 500, false, "interest_bearing_debt", "Bonds payable"}, + "jppfs_cor:LongTermLoansPayable": {StmtBS, "noncurrent_liabilities", 510, false, "interest_bearing_debt", "Long-term loans payable"}, + "jppfs_cor:DeferredTaxLiabilities": {StmtBS, "noncurrent_liabilities", 520, false, "", "Deferred tax liabilities"}, + "jppfs_cor:RetirementBenefitLiability": {StmtBS, "noncurrent_liabilities", 530, false, "", "Retirement benefit liability"}, + "jppfs_cor:OtherNoncurrentLiabilities": {StmtBS, "noncurrent_liabilities", 550, false, "", "Other non-current liabilities"}, + "jppfs_cor:NoncurrentLiabilities": {StmtBS, "noncurrent_liabilities", 599, true, "", "Total non-current liabilities"}, + "jppfs_cor:TotalLiabilities": {StmtBS, "total", 600, true, "total_liabilities", "Total liabilities"}, + + // Equity / Net Assets + "jppfs_cor:CapitalStock": {StmtBS, "equity", 700, false, "", "Capital stock"}, + "jppfs_cor:CapitalSurplus": {StmtBS, "equity", 710, false, "", "Capital surplus"}, + "jppfs_cor:RetainedEarnings": {StmtBS, "equity", 720, false, "", "Retained earnings"}, + "jppfs_cor:TreasuryShares": {StmtBS, "equity", 730, false, "", "Treasury shares"}, + "jppfs_cor:ShareholdersEquity": {StmtBS, "equity", 790, true, "equity", "Total shareholders' equity"}, + "jppfs_cor:ValuationAndTranslationAdjustments": {StmtBS, "equity", 792, false, "", "Valuation and translation adjustments"}, + "jppfs_cor:NonControllingInterests": {StmtBS, "equity", 795, false, "", "Non-controlling interests"}, + "jppfs_cor:NetAssets": {StmtBS, "equity", 799, true, "net_assets", "Total net assets"}, + + // ============================================================ + // JP-GAAP Income Statement (jppfs_cor:) + // ============================================================ + "jppfs_cor:NetSales": {StmtPL, "revenue", 1000, true, "revenue", "Net sales"}, + "jppfs_cor:CostOfSales": {StmtPL, "cost_of_sales", 1010, false, "cost_of_sales", "Cost of sales"}, + "jppfs_cor:GrossProfit": {StmtPL, "gross_profit", 1020, true, "gross_profit", "Gross profit"}, + "jppfs_cor:SellingGeneralAndAdministrativeExpenses": {StmtPL, "operating", 1030, false, "sga_expenses", "SGA expenses"}, + "jppfs_cor:OperatingIncome": {StmtPL, "operating_income", 1060, true, "operating_income", "Operating income"}, + "jppfs_cor:NonOperatingIncome": {StmtPL, "non_operating", 1070, false, "", "Non-operating income"}, + "jppfs_cor:NonOperatingExpenses": {StmtPL, "non_operating", 1080, false, "", "Non-operating expenses"}, + "jppfs_cor:OrdinaryIncome": {StmtPL, "ordinary_income", 1090, true, "ordinary_income", "Ordinary income"}, + "jppfs_cor:ExtraordinaryIncome": {StmtPL, "extraordinary", 1100, false, "", "Extraordinary income"}, + "jppfs_cor:ExtraordinaryLoss": {StmtPL, "extraordinary", 1110, false, "", "Extraordinary loss"}, + "jppfs_cor:IncomeBeforeIncomeTaxes": {StmtPL, "pretax", 1120, true, "", "Income before income taxes"}, + "jppfs_cor:IncomeTaxes": {StmtPL, "tax", 1130, false, "", "Income taxes - current"}, + "jppfs_cor:IncomeTaxesDeferred": {StmtPL, "tax", 1131, false, "", "Income taxes - deferred"}, + "jppfs_cor:NetIncome": {StmtPL, "net_income", 1150, true, "net_income", "Net income"}, + "jppfs_cor:NetIncomeAttributableToOwnersOfParent": {StmtPL, "net_income", 1151, true, "net_income", "Net income attributable to owners of parent"}, + "jppfs_cor:NetIncomeAttributableToNonControllingInterests": {StmtPL, "net_income", 1152, false, "", "Net income attributable to non-controlling interests"}, + + // ============================================================ + // JP-GAAP Cash Flow Statement (jppfs_cor:) + // ============================================================ + "jppfs_cor:DepreciationAndAmortization": {StmtCF, "operating_activities", 2000, false, "depreciation", "Depreciation and amortization"}, + "jppfs_cor:NetCashProvidedByUsedInOperatingActivities": {StmtCF, "operating_activities", 2099, true, "operating_cf", "Cash flows from operating activities"}, + "jppfs_cor:PurchaseOfPropertyPlantAndEquipmentAndIntangibleAssets": {StmtCF, "investing_activities", 2110, false, "capital_expenditure", "Purchase of property, plant and equipment"}, + "jppfs_cor:PurchaseOfPropertyPlantAndEquipment": {StmtCF, "investing_activities", 2111, false, "capital_expenditure", "Purchase of property, plant and equipment"}, + "jppfs_cor:NetCashProvidedByUsedInInvestingActivities": {StmtCF, "investing_activities", 2199, true, "investing_cf", "Cash flows from investing activities"}, + "jppfs_cor:NetCashProvidedByUsedInFinancingActivities": {StmtCF, "financing_activities", 2299, true, "financing_cf", "Cash flows from financing activities"}, + "jppfs_cor:CashAndCashEquivalents": {StmtCF, "cash_position", 2400, false, "", "Cash and cash equivalents at end of period"}, + + // ============================================================ + // Cross-standard elements (jpcrp_cor:) + // ============================================================ + "jpcrp_cor:NumberOfIssuedSharesAsOfFilingDateTotal": {StmtBS, "shares", 3000, false, "shares_outstanding", "Shares outstanding"}, + "jpcrp_cor:NumberOfTreasurySharesAsOfFilingDateTotal": {StmtBS, "shares", 3010, false, "treasury_shares", "Treasury shares"}, + "jpcrp_cor:ResearchAndDevelopmentExpensesTotal": {StmtPL, "other_pl", 3100, false, "research_and_development", "R&D expenses"}, + "jpcrp_cor:DividendPaidPerShareSummaryOfBusinessResults": {StmtPL, "dividends", 3200, false, "dividend_per_share", "Dividend per share"}, + "jpcrp_cor:BasicEarningsLossPerShare": {StmtPL, "eps", 3210, false, "eps", "Basic EPS"}, + "jpcrp_cor:DilutedEarningsLossPerShare": {StmtPL, "eps", 3220, false, "", "Diluted EPS"}, + + // ============================================================ + // Additional IFRS elements commonly seen + // ============================================================ + "jpigp_cor:ProfitLossBeforeTaxFromContinuingOperationsIFRS": {StmtPL, "pretax", 1095, true, "", "Profit before tax from continuing operations"}, + "jpigp_cor:ComprehensiveIncomeIFRS": {StmtPL, "comprehensive_income", 1300, true, "", "Comprehensive income"}, + "jpigp_cor:ComprehensiveIncomeAttributableToOwnersOfParentIFRS": {StmtPL, "comprehensive_income", 1310, true, "", "Comprehensive income attributable to owners of parent"}, + + // ============================================================ + // Additional JP-GAAP elements + // ============================================================ + "jppfs_cor:DeferredTaxAssets": {StmtBS, "current_assets", 141, false, "", "Deferred tax assets (current)"}, + "jppfs_cor:DeferredTaxAssetsNCA": {StmtBS, "noncurrent_assets", 251, false, "", "Deferred tax assets (non-current)"}, + "jppfs_cor:ResearchAndDevelopmentExpenses": {StmtPL, "other_pl", 3101, false, "research_and_development", "R&D expenses"}, + } + + // Company-specific suffix mappings for jpcrp030000-asr_* elements. + companySuffixes = map[string]elementDef{ + "SalesRevenuesIFRS": {StmtPL, "revenue", 1001, true, "revenue", "Sales revenues (IFRS)"}, + "OperatingRevenuesIFRS": {StmtPL, "revenue", 1002, true, "revenue", "Operating revenues (IFRS)"}, + "OperatingRevenuesIFRSKeyFinancialData": {StmtPL, "revenue", 1003, true, "revenue", "Operating revenues (IFRS, key financial data)"}, + "SalesRevenuesIFRSKeyFinancialData": {StmtPL, "revenue", 1004, true, "revenue", "Sales revenues (IFRS, key financial data)"}, + "OperatingProfitLossIFRS": {StmtPL, "operating_income", 1061, true, "operating_income", "Operating profit/loss (company-specific IFRS)"}, + "RevenueIFRS": {StmtPL, "revenue", 1005, true, "revenue", "Revenue (company-specific IFRS)"}, + "NetSales": {StmtPL, "revenue", 1006, true, "revenue", "Net sales (company-specific)"}, + } +} + +// Classify determines the financial statement type and metadata for an XBRL element. +// The pointType parameter ("instant" or "duration") is used for keyword-based heuristic +// matching of unknown elements. +func Classify(elementID string, pointType string) ElementClassification { + if elementID == "" { + return ElementClassification{Statement: StmtUnknown} + } + + // 1. Check exact match in known elements + if def, ok := knownElements[elementID]; ok { + return ElementClassification{ + Statement: def.statement, + Category: def.category, + SortOrder: def.sortOrder, + IsTotal: def.isTotal, + SummaryKey: def.summaryKey, + } + } + + // 2. Check company-specific elements (jpcrp030000-asr_*) + if strings.HasPrefix(elementID, "jpcrp030000-asr_") { + if colonIdx := strings.Index(elementID, ":"); colonIdx >= 0 { + suffix := elementID[colonIdx+1:] + if def, ok := companySuffixes[suffix]; ok { + return ElementClassification{ + Statement: def.statement, + Category: def.category, + SortOrder: def.sortOrder, + IsTotal: def.isTotal, + SummaryKey: def.summaryKey, + } + } + } + } + + // 3. Keyword + pointType heuristic for unknown elements (no SummaryKey) + return classifyByKeyword(elementID, pointType) +} + +// classifyByKeyword uses keyword matching and pointType to classify unknown elements. +// Only positive matches are returned; no fallback to PL or BS. +func classifyByKeyword(elementID string, pointType string) ElementClassification { + // Extract the local name (after the colon) for keyword matching + localName := elementID + if colonIdx := strings.Index(elementID, ":"); colonIdx >= 0 { + localName = elementID[colonIdx+1:] + } + upper := strings.ToUpper(localName) + + // CF keywords — must be duration + if pointType == "duration" { + cfKeywords := []string{"CASHFLOW", "CASH_FLOW", "CASHPROVIDED", "CASHUSED", + "OPERATINGACTIVIT", "INVESTINGACTIVIT", "FINANCINGACTIVIT"} + for _, kw := range cfKeywords { + if strings.Contains(upper, kw) { + return ElementClassification{Statement: StmtCF, Category: "heuristic"} + } + } + } + + // BS keywords — must be instant + if pointType == "instant" { + bsKeywords := []string{"ASSET", "LIABILIT", "EQUITY", "CAPITAL", + "RECEIVABLE", "PAYABLE", "INVENTORY", "INVENTORIES", + "GOODWILL", "INTANGIBLE", "SECURITIES", "DEPOSIT", + "PROVISION", "RESERVE", "SURPLUS", "SHARES", "NETASSETS"} + for _, kw := range bsKeywords { + if strings.Contains(upper, kw) { + return ElementClassification{Statement: StmtBS, Category: "heuristic"} + } + } + } + + // PL keywords — must be duration + if pointType == "duration" { + plKeywords := []string{"REVENUE", "SALES", "INCOME", "LOSS", "PROFIT", + "EXPENSE", "COST", "EARNING", "DEPRECIATION", "AMORTIZATION", "AMORTISATION"} + for _, kw := range plKeywords { + if strings.Contains(upper, kw) { + return ElementClassification{Statement: StmtPL, Category: "heuristic"} + } + } + } + + return ElementClassification{Statement: StmtUnknown} +} + +// IsTextBlock returns true if the element ID represents a text block element +// that should be excluded from financial statement processing. +func IsTextBlock(elementID string) bool { + return strings.HasSuffix(elementID, "TextBlock") +} + +// Elements returns all known element mappings as a slice of ElementInfo. +// This is the single source of truth for the schema command. +func Elements() []ElementInfo { + result := make([]ElementInfo, 0, len(knownElements)) + for id, def := range knownElements { + result = append(result, ElementInfo{ + ID: id, + Statement: def.statement, + Category: def.category, + SummaryKey: def.summaryKey, + LabelEN: def.labelEN, + }) + } + // Also include company-specific suffixes with a prefix indicator + for suffix, def := range companySuffixes { + result = append(result, ElementInfo{ + ID: "jpcrp030000-asr_*:" + suffix, + Statement: def.statement, + Category: def.category, + SummaryKey: def.summaryKey, + LabelEN: def.labelEN, + }) + } + return result +} diff --git a/internal/financial/classifier_test.go b/internal/financial/classifier_test.go new file mode 100644 index 0000000..0b69e9c --- /dev/null +++ b/internal/financial/classifier_test.go @@ -0,0 +1,593 @@ +package financial + +import ( + "strings" + "testing" +) + +// --- Known element classification tests --- + +func TestClassify_KnownBSElements(t *testing.T) { + tests := []struct { + name string + elementID string + pointType string + wantStmt StatementType + wantKey string + wantIsTotal bool + }{ + // IFRS BS elements + {"IFRS total assets", "jpigp_cor:AssetsIFRS", "instant", StmtBS, "total_assets", true}, + {"IFRS current assets", "jpigp_cor:CurrentAssetsIFRS", "instant", StmtBS, "current_assets", true}, + {"IFRS cash", "jpigp_cor:CashAndCashEquivalentsIFRS", "instant", StmtBS, "cash_and_equivalents", false}, + {"IFRS total liabilities", "jpigp_cor:LiabilitiesIFRS", "instant", StmtBS, "total_liabilities", true}, + {"IFRS current liabilities", "jpigp_cor:TotalCurrentLiabilitiesIFRS", "instant", StmtBS, "current_liabilities", true}, + {"IFRS equity parent", "jpigp_cor:EquityAttributableToOwnersOfParentIFRS", "instant", StmtBS, "equity", true}, + {"IFRS equity total", "jpigp_cor:EquityIFRS", "instant", StmtBS, "net_assets", true}, + {"IFRS interest bearing CL", "jpigp_cor:InterestBearingLiabilitiesCLIFRS", "instant", StmtBS, "interest_bearing_debt", false}, + {"IFRS interest bearing NCL", "jpigp_cor:InterestBearingLiabilitiesNCLIFRS", "instant", StmtBS, "interest_bearing_debt", false}, + + // JP-GAAP BS elements + {"JPGAAP total assets", "jppfs_cor:TotalAssets", "instant", StmtBS, "total_assets", true}, + {"JPGAAP current assets", "jppfs_cor:CurrentAssets", "instant", StmtBS, "current_assets", true}, + {"JPGAAP cash", "jppfs_cor:CashAndDeposits", "instant", StmtBS, "cash_and_equivalents", false}, + {"JPGAAP current liabilities", "jppfs_cor:CurrentLiabilities", "instant", StmtBS, "current_liabilities", true}, + {"JPGAAP total liabilities", "jppfs_cor:TotalLiabilities", "instant", StmtBS, "total_liabilities", true}, + {"JPGAAP net assets", "jppfs_cor:NetAssets", "instant", StmtBS, "net_assets", true}, + {"JPGAAP shareholders equity", "jppfs_cor:ShareholdersEquity", "instant", StmtBS, "equity", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := Classify(tt.elementID, tt.pointType) + if c.Statement != tt.wantStmt { + t.Errorf("Statement = %q, want %q", c.Statement, tt.wantStmt) + } + if c.SummaryKey != tt.wantKey { + t.Errorf("SummaryKey = %q, want %q", c.SummaryKey, tt.wantKey) + } + if c.IsTotal != tt.wantIsTotal { + t.Errorf("IsTotal = %v, want %v", c.IsTotal, tt.wantIsTotal) + } + }) + } +} + +func TestClassify_KnownPLElements(t *testing.T) { + tests := []struct { + name string + elementID string + pointType string + wantStmt StatementType + wantKey string + }{ + // IFRS PL elements + {"IFRS revenue", "jpigp_cor:RevenueIFRS", "duration", StmtPL, "revenue"}, + {"IFRS cost of sales", "jpigp_cor:CostOfSalesIFRS", "duration", StmtPL, "cost_of_sales"}, + {"IFRS gross profit", "jpigp_cor:GrossProfitIFRS", "duration", StmtPL, "gross_profit"}, + {"IFRS operating profit", "jpigp_cor:OperatingProfitLossIFRS", "duration", StmtPL, "operating_income"}, + {"IFRS net income", "jpigp_cor:ProfitLossAttributableToOwnersOfParentIFRS", "duration", StmtPL, "net_income"}, + {"IFRS basic eps", "jpigp_cor:BasicEarningsLossPerShareIFRS", "duration", StmtPL, "eps"}, + {"IFRS basic and diluted eps", "jpigp_cor:BasicAndDilutedEarningsLossPerShareIFRS", "duration", StmtPL, "eps"}, + + // JP-GAAP PL elements + {"JPGAAP net sales", "jppfs_cor:NetSales", "duration", StmtPL, "revenue"}, + {"JPGAAP cost of sales", "jppfs_cor:CostOfSales", "duration", StmtPL, "cost_of_sales"}, + {"JPGAAP gross profit", "jppfs_cor:GrossProfit", "duration", StmtPL, "gross_profit"}, + {"JPGAAP operating income", "jppfs_cor:OperatingIncome", "duration", StmtPL, "operating_income"}, + {"JPGAAP ordinary income", "jppfs_cor:OrdinaryIncome", "duration", StmtPL, "ordinary_income"}, + {"JPGAAP net income", "jppfs_cor:NetIncome", "duration", StmtPL, "net_income"}, + {"JPGAAP SGA", "jppfs_cor:SellingGeneralAndAdministrativeExpenses", "duration", StmtPL, "sga_expenses"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := Classify(tt.elementID, tt.pointType) + if c.Statement != tt.wantStmt { + t.Errorf("Statement = %q, want %q", c.Statement, tt.wantStmt) + } + if c.SummaryKey != tt.wantKey { + t.Errorf("SummaryKey = %q, want %q", c.SummaryKey, tt.wantKey) + } + }) + } +} + +func TestClassify_KnownCFElements(t *testing.T) { + tests := []struct { + name string + elementID string + pointType string + wantStmt StatementType + wantKey string + }{ + // IFRS CF elements + {"IFRS operating CF", "jpigp_cor:CashFlowsFromUsedInOperatingActivitiesIFRS", "duration", StmtCF, "operating_cf"}, + {"IFRS investing CF", "jpigp_cor:CashFlowsFromUsedInInvestingActivitiesIFRS", "duration", StmtCF, "investing_cf"}, + {"IFRS financing CF", "jpigp_cor:CashFlowsFromUsedInFinancingActivitiesIFRS", "duration", StmtCF, "financing_cf"}, + {"IFRS depreciation", "jpigp_cor:DepreciationAndAmortisationIFRS", "duration", StmtCF, "depreciation"}, + {"IFRS capex", "jpigp_cor:CapitalExpendituresIFRS", "duration", StmtCF, "capital_expenditure"}, + + // JP-GAAP CF elements + {"JPGAAP operating CF", "jppfs_cor:NetCashProvidedByUsedInOperatingActivities", "duration", StmtCF, "operating_cf"}, + {"JPGAAP investing CF", "jppfs_cor:NetCashProvidedByUsedInInvestingActivities", "duration", StmtCF, "investing_cf"}, + {"JPGAAP financing CF", "jppfs_cor:NetCashProvidedByUsedInFinancingActivities", "duration", StmtCF, "financing_cf"}, + {"JPGAAP depreciation", "jppfs_cor:DepreciationAndAmortization", "duration", StmtCF, "depreciation"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := Classify(tt.elementID, tt.pointType) + if c.Statement != tt.wantStmt { + t.Errorf("Statement = %q, want %q", c.Statement, tt.wantStmt) + } + if c.SummaryKey != tt.wantKey { + t.Errorf("SummaryKey = %q, want %q", c.SummaryKey, tt.wantKey) + } + }) + } +} + +func TestClassify_JpcrpCorElements(t *testing.T) { + // jpcrp_cor: elements are cross-standard (shares, R&D, dividends, etc.) + tests := []struct { + name string + elementID string + pointType string + wantStmt StatementType + wantKey string + }{ + {"shares outstanding", "jpcrp_cor:NumberOfIssuedSharesAsOfFilingDateTotal", "instant", StmtBS, "shares_outstanding"}, + {"treasury shares", "jpcrp_cor:NumberOfTreasurySharesAsOfFilingDateTotal", "instant", StmtBS, "treasury_shares"}, + {"R&D expenses", "jpcrp_cor:ResearchAndDevelopmentExpensesTotal", "duration", StmtPL, "research_and_development"}, + {"dividend per share", "jpcrp_cor:DividendPaidPerShareSummaryOfBusinessResults", "duration", StmtPL, "dividend_per_share"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := Classify(tt.elementID, tt.pointType) + if c.Statement != tt.wantStmt { + t.Errorf("Statement = %q, want %q", c.Statement, tt.wantStmt) + } + if c.SummaryKey != tt.wantKey { + t.Errorf("SummaryKey = %q, want %q", c.SummaryKey, tt.wantKey) + } + }) + } +} + +// --- Unknown element classification tests --- + +func TestClassify_UnknownDurationElement_NotPL(t *testing.T) { + // Unknown duration elements must NOT fallback to PL + c := Classify("jpigp_cor:SomeCompletelyUnknownThing", "duration") + if c.Statement == StmtPL { + t.Errorf("Unknown duration element must NOT fallback to PL, got %q", c.Statement) + } + if c.Statement != StmtUnknown { + t.Errorf("Statement = %q, want %q", c.Statement, StmtUnknown) + } + if c.SummaryKey != "" { + t.Errorf("SummaryKey = %q, want empty for unknown element", c.SummaryKey) + } +} + +func TestClassify_UnknownInstantElement_NotBS(t *testing.T) { + // Unknown instant elements must NOT fallback to BS + c := Classify("jppfs_cor:SomeCompletelyUnknownThing", "instant") + if c.Statement == StmtBS { + t.Errorf("Unknown instant element must NOT fallback to BS, got %q", c.Statement) + } + if c.Statement != StmtUnknown { + t.Errorf("Statement = %q, want %q", c.Statement, StmtUnknown) + } +} + +// --- Keyword + pointType positive match tests --- + +func TestClassify_KeywordPositiveMatch_CashFlowDuration(t *testing.T) { + // Unknown element with CashFlow keyword + duration → CF + c := Classify("jpigp_cor:SomethingCashFlowRelated", "duration") + if c.Statement != StmtCF { + t.Errorf("CashFlow keyword + duration should be CF, got %q", c.Statement) + } + if c.SummaryKey != "" { + t.Errorf("SummaryKey should be empty for keyword match, got %q", c.SummaryKey) + } +} + +func TestClassify_KeywordPositiveMatch_AssetInstant(t *testing.T) { + // Unknown element with Asset keyword + instant → BS + c := Classify("jppfs_cor:SomeSpecialAssets", "instant") + if c.Statement != StmtBS { + t.Errorf("Asset keyword + instant should be BS, got %q", c.Statement) + } +} + +func TestClassify_KeywordPositiveMatch_LiabilityInstant(t *testing.T) { + c := Classify("jppfs_cor:SomeLiabilities", "instant") + if c.Statement != StmtBS { + t.Errorf("Liability keyword + instant should be BS, got %q", c.Statement) + } +} + +func TestClassify_KeywordPositiveMatch_EquityInstant(t *testing.T) { + c := Classify("jppfs_cor:SomeEquityComponent", "instant") + if c.Statement != StmtBS { + t.Errorf("Equity keyword + instant should be BS, got %q", c.Statement) + } +} + +func TestClassify_KeywordPositiveMatch_IncomeOrExpenseDuration(t *testing.T) { + // Revenue/income/expense keywords + duration → PL + c := Classify("jppfs_cor:ExtraordinaryIncome", "duration") + if c.Statement != StmtPL { + t.Errorf("Income keyword + duration should be PL, got %q", c.Statement) + } + + c2 := Classify("jppfs_cor:SomeExpenses", "duration") + if c2.Statement != StmtPL { + t.Errorf("Expense keyword + duration should be PL, got %q", c2.Statement) + } +} + +func TestClassify_KeywordPositiveMatch_SalesDuration(t *testing.T) { + c := Classify("jppfs_cor:SomeSalesItems", "duration") + if c.Statement != StmtPL { + t.Errorf("Sales keyword + duration should be PL, got %q", c.Statement) + } +} + +func TestClassify_KeywordPointTypeMismatch_AssetDuration(t *testing.T) { + // Asset keyword but duration → NOT BS + c := Classify("jppfs_cor:SomeUnknownAssetsChangeDuration", "duration") + // If it matches a PL keyword too, that's fine. But it should NOT be BS. + if c.Statement == StmtBS { + t.Errorf("Asset keyword + duration should NOT be BS, got %q", c.Statement) + } +} + +func TestClassify_KeywordPointTypeMismatch_CashFlowInstant(t *testing.T) { + // CashFlow keyword but instant → NOT CF + c := Classify("jpigp_cor:UnknownCashFlowItem", "instant") + if c.Statement == StmtCF { + t.Errorf("CashFlow keyword + instant should NOT be CF, got %q", c.Statement) + } +} + +// --- TextBlock exclusion tests --- + +func TestIsTextBlock_True(t *testing.T) { + tests := []string{ + "jpcrp_cor:BusinessResultsOfReportingCompanyTextBlock", + "jpigp_cor:NotesConsolidatedBalanceSheetIFRSTextBlock", + "jppfs_cor:NotesRegardingLossOfSignificantAccountTextBlock", + } + for _, id := range tests { + if !IsTextBlock(id) { + t.Errorf("IsTextBlock(%q) = false, want true", id) + } + } +} + +func TestIsTextBlock_False(t *testing.T) { + tests := []string{ + "jpigp_cor:AssetsIFRS", + "jppfs_cor:NetSales", + "jpcrp_cor:NumberOfIssuedSharesAsOfFilingDateTotal", + } + for _, id := range tests { + if IsTextBlock(id) { + t.Errorf("IsTextBlock(%q) = true, want false", id) + } + } +} + +// --- Company-specific element mapping tests --- + +func TestClassify_CompanySpecificElement_SuffixMatch(t *testing.T) { + // Company-specific elements (jpcrp030000-asr_*) should try suffix match + tests := []struct { + name string + elementID string + pointType string + wantStmt StatementType + wantKey string + }{ + { + "company-specific SalesRevenuesIFRS", + "jpcrp030000-asr_E02144-000:SalesRevenuesIFRS", + "duration", + StmtPL, + "revenue", + }, + { + "company-specific OperatingRevenuesIFRSKeyFinancialData", + "jpcrp030000-asr_E02144-000:OperatingRevenuesIFRSKeyFinancialData", + "duration", + StmtPL, + "revenue", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := Classify(tt.elementID, tt.pointType) + if c.Statement != tt.wantStmt { + t.Errorf("Statement = %q, want %q", c.Statement, tt.wantStmt) + } + if c.SummaryKey != tt.wantKey { + t.Errorf("SummaryKey = %q, want %q", c.SummaryKey, tt.wantKey) + } + }) + } +} + +// --- Elements() consistency tests --- + +func TestElements_ReturnsNonEmpty(t *testing.T) { + elems := Elements() + if len(elems) == 0 { + t.Fatal("Elements() returned empty slice") + } +} + +func TestElements_ConsistentWithClassify(t *testing.T) { + // Every element from Elements() should be classifiable with the correct statement and summary key + for _, elem := range Elements() { + // Determine the appropriate pointType for this element + var pt string + switch elem.Statement { + case StmtBS: + pt = "instant" + case StmtPL: + pt = "duration" + case StmtCF: + pt = "duration" + default: + t.Errorf("Elements() contains element %q with unexpected statement %q", elem.ID, elem.Statement) + continue + } + + c := Classify(elem.ID, pt) + if c.Statement != elem.Statement { + t.Errorf("Classify(%q, %q).Statement = %q, but Elements() says %q", elem.ID, pt, c.Statement, elem.Statement) + } + if c.SummaryKey != elem.SummaryKey { + t.Errorf("Classify(%q, %q).SummaryKey = %q, but Elements() says %q", elem.ID, pt, c.SummaryKey, elem.SummaryKey) + } + } +} + +func TestElements_UniqueIDs(t *testing.T) { + seen := make(map[string]bool) + for _, elem := range Elements() { + if seen[elem.ID] { + t.Errorf("duplicate element ID in Elements(): %q", elem.ID) + } + seen[elem.ID] = true + } +} + +func TestElements_AllHaveStatementType(t *testing.T) { + valid := map[StatementType]bool{StmtBS: true, StmtPL: true, StmtCF: true} + for _, elem := range Elements() { + if !valid[elem.Statement] { + t.Errorf("Elements() entry %q has invalid statement %q", elem.ID, elem.Statement) + } + } +} + +// --- Summary key coverage test --- + +func TestSummaryKeyCoverage_BuffettCodeMetrics(t *testing.T) { + // All these summary keys must be present in at least one element + requiredKeys := []string{ + "revenue", "cost_of_sales", "gross_profit", "operating_income", + "ordinary_income", "net_income", + "total_assets", "net_assets", "equity", "total_liabilities", + "current_assets", "current_liabilities", + "cash_and_equivalents", "interest_bearing_debt", + "operating_cf", "investing_cf", "financing_cf", + "depreciation", "capital_expenditure", + "research_and_development", "sga_expenses", + "shares_outstanding", "treasury_shares", + "eps", "dividend_per_share", + } + + // Collect all summary keys from Elements() + keySet := make(map[string]bool) + for _, elem := range Elements() { + if elem.SummaryKey != "" { + keySet[elem.SummaryKey] = true + } + } + + for _, key := range requiredKeys { + if !keySet[key] { + t.Errorf("required summary key %q not covered by any element in Elements()", key) + } + } +} + +// --- SortOrder tests --- + +func TestClassify_SortOrderIsPositive(t *testing.T) { + // Known elements should have positive sort order + c := Classify("jpigp_cor:AssetsIFRS", "instant") + if c.SortOrder <= 0 { + t.Errorf("SortOrder = %d, want > 0 for known element", c.SortOrder) + } +} + +func TestClassify_SortOrderPreservesStatementGrouping(t *testing.T) { + // Within the same statement type, elements should have logical ordering + // (total assets after individual asset items, etc.) + assets := Classify("jpigp_cor:CurrentAssetsIFRS", "instant") + totalAssets := Classify("jpigp_cor:AssetsIFRS", "instant") + + if assets.Statement != StmtBS || totalAssets.Statement != StmtBS { + t.Fatal("both should be BS") + } + // Total assets should come after current assets in sort order + if totalAssets.SortOrder <= assets.SortOrder { + t.Errorf("total assets SortOrder (%d) should be > current assets SortOrder (%d)", + totalAssets.SortOrder, assets.SortOrder) + } +} + +// --- Category tests --- + +func TestClassify_CategoryNotEmpty_ForKnownElements(t *testing.T) { + tests := []struct { + name string + elementID string + pointType string + }{ + {"IFRS assets", "jpigp_cor:AssetsIFRS", "instant"}, + {"JPGAAP revenue", "jppfs_cor:NetSales", "duration"}, + {"JPGAAP operating CF", "jppfs_cor:NetCashProvidedByUsedInOperatingActivities", "duration"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := Classify(tt.elementID, tt.pointType) + if c.Category == "" { + t.Errorf("Category should not be empty for known element %q", tt.elementID) + } + }) + } +} + +// --- Statement type constant tests --- + +func TestStatementType_Values(t *testing.T) { + if StmtBS != "bs" { + t.Errorf("StmtBS = %q, want %q", StmtBS, "bs") + } + if StmtPL != "pl" { + t.Errorf("StmtPL = %q, want %q", StmtPL, "pl") + } + if StmtCF != "cf" { + t.Errorf("StmtCF = %q, want %q", StmtCF, "cf") + } + if StmtUnknown != "unknown" { + t.Errorf("StmtUnknown = %q, want %q", StmtUnknown, "unknown") + } +} + +// --- Edge case: jpdei_cor elements --- + +func TestClassify_JpdeiCorElements_Unknown(t *testing.T) { + // jpdei_cor: document info elements are not financial statements + c := Classify("jpdei_cor:EDINETCodeDEI", "instant") + if c.Statement != StmtUnknown { + t.Errorf("jpdei_cor element should be unknown, got %q", c.Statement) + } +} + +// --- Edge case: empty element ID --- + +func TestClassify_EmptyElementID(t *testing.T) { + c := Classify("", "instant") + if c.Statement != StmtUnknown { + t.Errorf("empty element ID should be unknown, got %q", c.Statement) + } +} + +// --- IFRS PL elements that might appear as both basic and diluted EPS --- + +func TestClassify_EPSVariants(t *testing.T) { + tests := []struct { + elementID string + wantKey string + }{ + {"jpigp_cor:BasicEarningsLossPerShareIFRS", "eps"}, + {"jpigp_cor:BasicAndDilutedEarningsLossPerShareIFRS", "eps"}, + } + for _, tt := range tests { + c := Classify(tt.elementID, "duration") + if c.SummaryKey != tt.wantKey { + t.Errorf("Classify(%q).SummaryKey = %q, want %q", tt.elementID, c.SummaryKey, tt.wantKey) + } + } +} + +// --- Verify that Elements() contains a reasonable number of mappings --- + +func TestElements_MinimumCount(t *testing.T) { + elems := Elements() + // We expect roughly 80-120 mapped elements + if len(elems) < 50 { + t.Errorf("Elements() returned only %d elements, expected at least 50", len(elems)) + } +} + +// --- Verify Elements() has both IFRS and JPGAAP elements --- + +func TestElements_HasBothStandards(t *testing.T) { + hasIFRS := false + hasJPGAAP := false + for _, elem := range Elements() { + if strings.HasPrefix(elem.ID, "jpigp_cor:") { + hasIFRS = true + } + if strings.HasPrefix(elem.ID, "jppfs_cor:") { + hasJPGAAP = true + } + } + if !hasIFRS { + t.Error("Elements() contains no IFRS elements (jpigp_cor:)") + } + if !hasJPGAAP { + t.Error("Elements() contains no JP-GAAP elements (jppfs_cor:)") + } +} + +// --- Interest bearing debt is additive (multiple elements map to same key) --- + +func TestClassify_InterestBearingDebt_MultipleElements(t *testing.T) { + cl := Classify("jpigp_cor:InterestBearingLiabilitiesCLIFRS", "instant") + ncl := Classify("jpigp_cor:InterestBearingLiabilitiesNCLIFRS", "instant") + + if cl.SummaryKey != "interest_bearing_debt" { + t.Errorf("CL interest bearing debt SummaryKey = %q, want %q", cl.SummaryKey, "interest_bearing_debt") + } + if ncl.SummaryKey != "interest_bearing_debt" { + t.Errorf("NCL interest bearing debt SummaryKey = %q, want %q", ncl.SummaryKey, "interest_bearing_debt") + } +} + +// --- Keyword heuristic should not match when only partial keyword appears --- + +func TestClassify_KeywordHeuristic_NoPartialMatch(t *testing.T) { + // "assess" contains "asse" but not "Asset" — should not match BS + c := Classify("jpcrp_cor:SomeAssessmentNote", "instant") + // This should be unknown — "Assess" is not "Asset" + // The heuristic should match on "Asset" not "Asse" + // (implementation detail: case-insensitive keyword boundaries) + if c.Statement != StmtUnknown { + t.Logf("Note: %q matched statement %q — this is acceptable if the keyword matching is reasonable", "jpcrp_cor:SomeAssessmentNote", c.Statement) + } +} + +// --- JP-GAAP noncurrent liabilities --- + +func TestClassify_JPGAAPNoncurrentLiabilities(t *testing.T) { + c := Classify("jppfs_cor:NoncurrentLiabilities", "instant") + if c.Statement != StmtBS { + t.Errorf("Statement = %q, want BS", c.Statement) + } +} + +// --- IFRS noncurrent assets --- + +func TestClassify_IFRSNoncurrentAssets(t *testing.T) { + c := Classify("jpigp_cor:NonCurrentAssetsIFRS", "instant") + if c.Statement != StmtBS { + t.Errorf("Statement = %q, want BS", c.Statement) + } +} + +// --- JP-GAAP noncurrent assets --- + +func TestClassify_JPGAAPNoncurrentAssets(t *testing.T) { + c := Classify("jppfs_cor:NoncurrentAssets", "instant") + if c.Statement != StmtBS { + t.Errorf("Statement = %q, want BS", c.Statement) + } +} diff --git a/internal/financial/context.go b/internal/financial/context.go new file mode 100644 index 0000000..095410c --- /dev/null +++ b/internal/financial/context.go @@ -0,0 +1,123 @@ +package financial + +import "strings" + +// ContextInfo holds parsed information from an EDINET CSV context ID. +type ContextInfo struct { + Period string // "current", "prior1", "prior2", "prior3", "prior4", "filing_date", etc. + PointType string // "instant", "duration" + Consolidated string // "consolidated", "non_consolidated", "other", "unknown" + Member string // segment/equity member name if any, "" otherwise +} + +// ParseContextID parses an EDINET CSV context ID string and consolidated column value +// into a structured ContextInfo. +// +// Context ID examples: +// - "CurrentYearInstant" → current/instant +// - "Prior1YearDuration" → prior1/duration +// - "CurrentYearInstant_NonConsolidatedMember" → current/instant/non_consolidated +// - "CurrentYearDuration_SomeSegmentMember" → current/duration with member +// - "FilingDateInstant" → filing_date/instant +func ParseContextID(contextID string, consolidatedCol string) ContextInfo { + if contextID == "" { + return ContextInfo{Consolidated: parseConsolidatedCol(consolidatedCol)} + } + + var info ContextInfo + + // Split on first underscore to separate period+type from member + base := contextID + member := "" + if idx := strings.Index(contextID, "_"); idx >= 0 { + base = contextID[:idx] + member = contextID[idx+1:] + } + + // Parse period and point type from base + info.Period, info.PointType = parsePeriodAndType(base) + + // Parse member + if member == "NonConsolidatedMember" { + // NonConsolidatedMember overrides the column — it is always non-consolidated, + // even when 連結・個別 says その他 (common in IFRS filings). + info.Member = "" + info.Consolidated = "non_consolidated" + } else { + if member != "" { + info.Member = member + } + info.Consolidated = parseConsolidatedCol(consolidatedCol) + } + + return info +} + +func parsePeriodAndType(base string) (period, pointType string) { + switch { + case strings.HasPrefix(base, "CurrentYear"): + period = "current" + pointType = parsePointType(base[len("CurrentYear"):]) + case strings.HasPrefix(base, "CurrentQuarter"): + period = "current_quarter" + pointType = parsePointType(base[len("CurrentQuarter"):]) + case strings.HasPrefix(base, "CurrentInterim"): + period = "current_interim" + pointType = parsePointType(base[len("CurrentInterim"):]) + case strings.HasPrefix(base, "CurrentYTD"): + period = "current_ytd" + pointType = parsePointType(base[len("CurrentYTD"):]) + case strings.HasPrefix(base, "Prior") && len(base) > 5: + // Prior1YearInstant, Prior2YearDuration, Prior1QuarterDuration, etc. + numEnd := 5 // after "Prior" + for numEnd < len(base) && base[numEnd] >= '0' && base[numEnd] <= '9' { + numEnd++ + } + if numEnd > 5 { + num := base[5:numEnd] + rest := base[numEnd:] + switch { + case strings.HasPrefix(rest, "Year"): + period = "prior" + num + pointType = parsePointType(rest[len("Year"):]) + case strings.HasPrefix(rest, "Quarter"): + period = "prior" + num + "_quarter" + pointType = parsePointType(rest[len("Quarter"):]) + case strings.HasPrefix(rest, "Interim"): + period = "prior" + num + "_interim" + pointType = parsePointType(rest[len("Interim"):]) + case strings.HasPrefix(rest, "YTD"): + period = "prior" + num + "_ytd" + pointType = parsePointType(rest[len("YTD"):]) + } + } + case strings.HasPrefix(base, "FilingDate"): + period = "filing_date" + pointType = parsePointType(base[len("FilingDate"):]) + } + return period, pointType +} + +func parsePointType(suffix string) string { + switch suffix { + case "Instant": + return "instant" + case "Duration": + return "duration" + default: + return "" + } +} + +func parseConsolidatedCol(col string) string { + switch col { + case "連結": + return "consolidated" + case "個別": + return "non_consolidated" + case "その他": + return "other" + default: + return "unknown" + } +} diff --git a/internal/financial/context_test.go b/internal/financial/context_test.go new file mode 100644 index 0000000..3c1f343 --- /dev/null +++ b/internal/financial/context_test.go @@ -0,0 +1,150 @@ +package financial + +import "testing" + +func TestParseContextID_CurrentYearInstant(t *testing.T) { + ctx := ParseContextID("CurrentYearInstant", "連結") + if ctx.Period != "current" { + t.Errorf("Period = %q, want %q", ctx.Period, "current") + } + if ctx.PointType != "instant" { + t.Errorf("PointType = %q, want %q", ctx.PointType, "instant") + } + if ctx.Consolidated != "consolidated" { + t.Errorf("Consolidated = %q, want %q", ctx.Consolidated, "consolidated") + } + if ctx.Member != "" { + t.Errorf("Member = %q, want empty", ctx.Member) + } +} + +func TestParseContextID_Prior1YearDuration(t *testing.T) { + ctx := ParseContextID("Prior1YearDuration", "連結") + if ctx.Period != "prior1" { + t.Errorf("Period = %q, want %q", ctx.Period, "prior1") + } + if ctx.PointType != "duration" { + t.Errorf("PointType = %q, want %q", ctx.PointType, "duration") + } +} + +func TestParseContextID_Prior4YearDuration(t *testing.T) { + ctx := ParseContextID("Prior4YearDuration", "その他") + if ctx.Period != "prior4" { + t.Errorf("Period = %q, want %q", ctx.Period, "prior4") + } + if ctx.PointType != "duration" { + t.Errorf("PointType = %q, want %q", ctx.PointType, "duration") + } + if ctx.Consolidated != "other" { + t.Errorf("Consolidated = %q, want %q", ctx.Consolidated, "other") + } +} + +func TestParseContextID_NonConsolidatedMember(t *testing.T) { + ctx := ParseContextID("CurrentYearInstant_NonConsolidatedMember", "個別") + if ctx.Period != "current" { + t.Errorf("Period = %q, want %q", ctx.Period, "current") + } + if ctx.PointType != "instant" { + t.Errorf("PointType = %q, want %q", ctx.PointType, "instant") + } + if ctx.Consolidated != "non_consolidated" { + t.Errorf("Consolidated = %q, want %q", ctx.Consolidated, "non_consolidated") + } + if ctx.Member != "" { + t.Errorf("Member = %q, want empty (NonConsolidatedMember is not a segment)", ctx.Member) + } +} + +func TestParseContextID_SegmentMember(t *testing.T) { + ctx := ParseContextID("CurrentYearDuration_jpcrp030000-asr_E02144-000AutomotiveReportableSegmentMember", "その他") + if ctx.Period != "current" { + t.Errorf("Period = %q, want %q", ctx.Period, "current") + } + if ctx.PointType != "duration" { + t.Errorf("PointType = %q, want %q", ctx.PointType, "duration") + } + if ctx.Member != "jpcrp030000-asr_E02144-000AutomotiveReportableSegmentMember" { + t.Errorf("Member = %q, want segment member", ctx.Member) + } +} + +func TestParseContextID_ConsolidatedColumn(t *testing.T) { + tests := []struct { + name string + consolidatedCol string + want string + }{ + {"連結", "連結", "consolidated"}, + {"個別", "個別", "non_consolidated"}, + {"その他 (IFRS consolidated)", "その他", "other"}, + {"empty", "", "unknown"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := ParseContextID("CurrentYearInstant", tt.consolidatedCol) + if ctx.Consolidated != tt.want { + t.Errorf("Consolidated = %q, want %q", ctx.Consolidated, tt.want) + } + }) + } +} + +func TestParseContextID_FilingDateInstant(t *testing.T) { + ctx := ParseContextID("FilingDateInstant", "その他") + if ctx.Period != "filing_date" { + t.Errorf("Period = %q, want %q", ctx.Period, "filing_date") + } + if ctx.PointType != "instant" { + t.Errorf("PointType = %q, want %q", ctx.PointType, "instant") + } +} + +func TestParseContextID_Empty(t *testing.T) { + ctx := ParseContextID("", "") + if ctx.Period != "" { + t.Errorf("Period = %q, want empty", ctx.Period) + } + if ctx.PointType != "" { + t.Errorf("PointType = %q, want empty", ctx.PointType) + } +} + +func TestParseContextID_QuarterlyContexts(t *testing.T) { + tests := []struct { + contextID string + wantPeriod string + wantType string + }{ + {"CurrentQuarterDuration", "current_quarter", "duration"}, + {"CurrentQuarterInstant", "current_quarter", "instant"}, + {"CurrentInterimInstant", "current_interim", "instant"}, + {"CurrentYTDDuration", "current_ytd", "duration"}, + {"Prior1QuarterDuration", "prior1_quarter", "duration"}, + {"Prior1InterimInstant", "prior1_interim", "instant"}, + {"Prior1YTDDuration", "prior1_ytd", "duration"}, + } + for _, tt := range tests { + t.Run(tt.contextID, func(t *testing.T) { + ctx := ParseContextID(tt.contextID, "連結") + if ctx.Period != tt.wantPeriod { + t.Errorf("Period = %q, want %q", ctx.Period, tt.wantPeriod) + } + if ctx.PointType != tt.wantType { + t.Errorf("PointType = %q, want %q", ctx.PointType, tt.wantType) + } + }) + } +} + +func TestParseContextID_EquityMember(t *testing.T) { + // Context IDs like CurrentYearDuration_RetainedEarningsIFRSMember + ctx := ParseContextID("CurrentYearDuration_RetainedEarningsIFRSMember", "その他") + if ctx.Period != "current" { + t.Errorf("Period = %q, want %q", ctx.Period, "current") + } + if ctx.Member != "RetainedEarningsIFRSMember" { + t.Errorf("Member = %q, want %q", ctx.Member, "RetainedEarningsIFRSMember") + } +} diff --git a/internal/financial/parser.go b/internal/financial/parser.go new file mode 100644 index 0000000..e138413 --- /dev/null +++ b/internal/financial/parser.go @@ -0,0 +1,555 @@ +package financial + +import ( + "fmt" + "sort" + "strconv" + "strings" + + "github.com/beatinaniwa/edinet-cli/internal/extract" +) + +// ParseOpts configures the parser behavior. +type ParseOpts struct { + Consolidated *bool // nil=auto per-statement, true/false=explicit +} + +// requiredHeaders are the columns the parser needs to process a file. +var requiredHeaders = []string{"要素ID", "項目名", "コンテキストID", "連結・個別", "ユニットID", "単位", "値"} + +// parsedRow is an intermediate representation of a CSV row after initial parsing. +type parsedRow struct { + elementID string + label string + contextInfo ContextInfo + rawContextID string + unitID string + unit string + rawValue string + value *float64 + classification ElementClassification +} + +// consolidationGroup holds rows grouped by consolidation type for a statement. +type consolidationGroup struct { + consolidated []parsedRow + nonConsolidated []parsedRow + other []parsedRow +} + +// dedupeKey uniquely identifies an element within a statement and period. +type dedupeKey struct { + stmtType StatementType + period string + elementID string +} + +// Parse converts raw CSVDataResult into structured ParseResult with summary. +func Parse(csvResult *extract.CSVDataResult, opts ParseOpts) (*ParseResult, error) { + if csvResult == nil { + return nil, fmt.Errorf("csv result is nil") + } + if len(csvResult.Files) == 0 { + return nil, fmt.Errorf("no CSV files to parse") + } + + // Sort files: main file (001) first, then others in order + files := sortFiles(csvResult.Files) + + // Track warnings + var warnings []string + + // Check annual report marker + if !hasASRMarker(files) { + warnings = append(warnings, "no annual report (asr) marker found in filenames; data may be from a quarterly or other report") + } + + // Parse all rows from eligible files + var allRows []parsedRow + processedAny := false + + for _, file := range files { + // File selection: only jpcrp prefix files, skip jpaud + if !isEligibleFile(file.Filename) { + continue + } + + // Header validation + colMap, err := buildColumnMap(file.Headers) + if err != nil { + warnings = append(warnings, fmt.Sprintf("skipped file %s: %s", file.Filename, err.Error())) + continue + } + + processedAny = true + + // Parse rows from this file + for _, row := range file.Rows { + parsed := parseRow(row, colMap) + if parsed == nil { + continue + } + allRows = append(allRows, *parsed) + } + } + + if !processedAny { + return nil, fmt.Errorf("no eligible CSV files found (all files were skipped)") + } + + // Build result from parsed rows + return buildResult(allRows, opts, warnings) +} + +// sortFiles returns files sorted with main file (001) first. +func sortFiles(files []extract.CSVFile) []extract.CSVFile { + sorted := make([]extract.CSVFile, len(files)) + copy(sorted, files) + sort.SliceStable(sorted, func(i, j int) bool { + iMain := isMainFile(sorted[i].Filename) + jMain := isMainFile(sorted[j].Filename) + if iMain != jMain { + return iMain + } + return false + }) + return sorted +} + +// isMainFile returns true if the filename matches the main file pattern (contains "-001"). +func isMainFile(filename string) bool { + return strings.Contains(filename, "-001") +} + +// isEligibleFile returns true if the file should be processed. +func isEligibleFile(filename string) bool { + return strings.HasPrefix(strings.ToLower(filename), "jpcrp") +} + +// hasASRMarker checks if any eligible file has "asr" in its filename. +func hasASRMarker(files []extract.CSVFile) bool { + for _, f := range files { + lower := strings.ToLower(f.Filename) + if strings.HasPrefix(lower, "jpcrp") && strings.Contains(lower, "-asr-") { + return true + } + } + return false +} + +// columnMap maps header names to column indices. +type columnMap struct { + elementID int + label int + contextID int + consolidated int + pointType int + unitID int + unit int + value int +} + +// buildColumnMap validates headers and returns a column mapping. +func buildColumnMap(headers []string) (*columnMap, error) { + idx := make(map[string]int) + for i, h := range headers { + idx[h] = i + } + + for _, req := range requiredHeaders { + if _, ok := idx[req]; !ok { + return nil, fmt.Errorf("missing required header %q", req) + } + } + + cm := &columnMap{ + elementID: idx["要素ID"], + label: idx["項目名"], + contextID: idx["コンテキストID"], + consolidated: idx["連結・個別"], + unitID: idx["ユニットID"], + unit: idx["単位"], + value: idx["値"], + } + + if i, ok := idx["期間・時点"]; ok { + cm.pointType = i + } else { + cm.pointType = -1 + } + + return cm, nil +} + +// safeGet returns the value at index i in row, or "" if out of bounds. +func safeGet(row []string, i int) string { + if i < 0 || i >= len(row) { + return "" + } + return row[i] +} + +// parseRow parses a single CSV row into a parsedRow, or nil if the row should be skipped. +func parseRow(row []string, cm *columnMap) *parsedRow { + elementID := safeGet(row, cm.elementID) + if elementID == "" { + return nil + } + + // Skip TextBlock elements + if IsTextBlock(elementID) { + return nil + } + + contextID := safeGet(row, cm.contextID) + consolidatedCol := safeGet(row, cm.consolidated) + + // Parse context + ctxInfo := ParseContextID(contextID, consolidatedCol) + + // Skip segment members + if ctxInfo.Member != "" { + return nil + } + + // Parse value + rawValue := safeGet(row, cm.value) + var value *float64 + if rawValue != "" && rawValue != "-" { + if v, err := strconv.ParseFloat(rawValue, 64); err == nil { + value = &v + } + } + + // Classify element + classification := Classify(elementID, ctxInfo.PointType) + + return &parsedRow{ + elementID: elementID, + label: safeGet(row, cm.label), + contextInfo: ctxInfo, + rawContextID: contextID, + unitID: safeGet(row, cm.unitID), + unit: safeGet(row, cm.unit), + rawValue: rawValue, + value: value, + classification: classification, + } +} + +// buildResult constructs the ParseResult from parsed rows. +func buildResult(rows []parsedRow, opts ParseOpts, warnings []string) (*ParseResult, error) { + // Group rows by statement type and consolidation + byStmt := make(map[StatementType]*consolidationGroup) + for _, st := range []StatementType{StmtBS, StmtPL, StmtCF} { + byStmt[st] = &consolidationGroup{} + } + + for _, r := range rows { + if r.classification.Statement == StmtUnknown { + continue + } + sr, ok := byStmt[r.classification.Statement] + if !ok { + continue + } + switch r.contextInfo.Consolidated { + case "consolidated": + sr.consolidated = append(sr.consolidated, r) + case "non_consolidated": + sr.nonConsolidated = append(sr.nonConsolidated, r) + default: + sr.other = append(sr.other, r) + } + } + + // Select consolidation per statement type + type stmtSelection struct { + stmtType StatementType + rows []parsedRow + consolidated bool + } + + var selections []stmtSelection + hasConsolidatedStmt := false + + for _, st := range []StatementType{StmtBS, StmtPL, StmtCF} { + sr := byStmt[st] + selected, isCons := selectConsolidation(sr, st, opts, &warnings) + if len(selected) > 0 { + selections = append(selections, stmtSelection{ + stmtType: st, + rows: selected, + consolidated: isCons, + }) + if isCons { + hasConsolidatedStmt = true + } + } + } + + // Detect accounting standard + acctStd := detectAccountingStandard(rows) + + // Build statements + summary := make(Summary) + var statements []FinancialStatement + seen := make(map[dedupeKey]bool) + + for _, sel := range selections { + stmt := buildStatement(sel.stmtType, sel.rows, sel.consolidated, acctStd, seen) + if stmt != nil { + statements = append(statements, *stmt) + } + } + + // Build summary from current period items + buildSummary(summary, statements) + + return &ParseResult{ + Summary: summary, + Statements: statements, + AccountingStd: acctStd, + Consolidated: hasConsolidatedStmt, + Warnings: warnings, + }, nil +} + +// selectConsolidation chooses which set of rows to use for a statement type. +func selectConsolidation(sr *consolidationGroup, st StatementType, opts ParseOpts, warnings *[]string) ([]parsedRow, bool) { + // "other" rows (連結・個別=その他) are IFRS consolidated data, so include + // them with consolidated but NOT with explicit non-consolidated. + consRows := append(sr.consolidated, sr.other...) + nonConsRows := sr.nonConsolidated // exclude "other" — it is IFRS consolidated + + // IFRS consolidated data comes through as "other" (連結・個別=その他), + // so include "other" rows when checking for consolidated availability. + hasCons := len(sr.consolidated) > 0 || len(sr.other) > 0 + hasNonCons := len(sr.nonConsolidated) > 0 + + if opts.Consolidated != nil { + if *opts.Consolidated { + if hasCons { + return consRows, true + } + if hasNonCons { + *warnings = append(*warnings, fmt.Sprintf("statement %s: consolidated data requested but not available, using non_consolidated as fallback", st)) + return nonConsRows, false + } + if len(sr.other) > 0 { + return sr.other, true + } + return nil, false + } + // Explicit non-consolidated — do not include "other" (IFRS consolidated) + if hasNonCons { + return nonConsRows, false + } + if hasCons { + *warnings = append(*warnings, fmt.Sprintf("statement %s: non_consolidated data requested but not available, using consolidated as fallback", st)) + return consRows, true + } + return nil, false + } + + // Auto mode: prefer consolidated, fallback to non-consolidated + if hasCons { + return consRows, true + } + if hasNonCons { + *warnings = append(*warnings, fmt.Sprintf("statement %s: no consolidated data available, using non_consolidated as fallback", st)) + return nonConsRows, false + } + if len(sr.other) > 0 { + // "other" (その他) is typically IFRS consolidated data + return sr.other, true + } + return nil, false +} + +// detectAccountingStandard determines the accounting standard from element ID prefixes. +func detectAccountingStandard(rows []parsedRow) string { + ifrsCount := 0 + jpgaapCount := 0 + + for _, r := range rows { + if r.classification.Statement == StmtUnknown { + continue + } + prefix := elementPrefix(r.elementID) + switch prefix { + case "jpigp_cor": + ifrsCount++ + case "jppfs_cor": + jpgaapCount++ + } + } + + if ifrsCount > jpgaapCount { + return "ifrs" + } + if jpgaapCount > 0 { + return "jpgaap" + } + if ifrsCount > 0 { + return "ifrs" + } + return "unknown" +} + +// elementPrefix extracts the namespace prefix from an element ID (before the colon). +func elementPrefix(elementID string) string { + if idx := strings.Index(elementID, ":"); idx >= 0 { + return elementID[:idx] + } + return "" +} + +// buildStatement constructs a FinancialStatement from rows. +func buildStatement(stmtType StatementType, rows []parsedRow, consolidated bool, acctStd string, seen map[dedupeKey]bool) *FinancialStatement { + if len(rows) == 0 { + return nil + } + + type itemWithOrder struct { + item LineItem + sortOrder int + } + periodItems := make(map[string][]itemWithOrder) + for _, r := range rows { + if r.contextInfo.Period == "" { + continue + } + + key := dedupeKey{stmtType, r.contextInfo.Period, r.elementID} + + if seen[key] { + continue + } + seen[key] = true + + iwo := itemWithOrder{ + item: LineItem{ + ElementID: r.elementID, + Label: r.label, + Category: r.classification.Category, + Value: r.value, + RawValue: r.rawValue, + Unit: r.unit, + UnitID: r.unitID, + SummaryKey: r.classification.SummaryKey, + IsTotal: r.classification.IsTotal, + }, + sortOrder: r.classification.SortOrder, + } + periodItems[r.contextInfo.Period] = append(periodItems[r.contextInfo.Period], iwo) + } + + if len(periodItems) == 0 { + return nil + } + + var periods []PeriodData + for period, items := range periodItems { + sort.SliceStable(items, func(i, j int) bool { + return items[i].sortOrder < items[j].sortOrder + }) + sortedItems := make([]LineItem, len(items)) + for i, iwo := range items { + sortedItems[i] = iwo.item + } + + periods = append(periods, PeriodData{ + Period: period, + Items: sortedItems, + }) + } + + sort.SliceStable(periods, func(i, j int) bool { + return periodOrder(periods[i].Period) < periodOrder(periods[j].Period) + }) + + return &FinancialStatement{ + Type: string(stmtType), + Consolidated: consolidated, + AccountingStd: acctStd, + Periods: periods, + } +} + +// periodOrder returns a sort key for period names. +// current < current_quarter < current_ytd < current_interim < filing_date < prior1 < prior1_quarter < ... +func periodOrder(period string) int { + // Suffix weights for sub-period types + suffixWeight := func(s string) int { + switch { + case strings.HasSuffix(s, "_quarter"): + return 1 + case strings.HasSuffix(s, "_ytd"): + return 2 + case strings.HasSuffix(s, "_interim"): + return 3 + default: + return 0 // annual (no suffix) + } + } + + switch { + case period == "current": + return 0 + case strings.HasPrefix(period, "current_"): + return 2 + suffixWeight(period) + case period == "filing_date": + return 6 + case strings.HasPrefix(period, "prior"): + rest := period[5:] + // Extract the numeric part + numEnd := 0 + for numEnd < len(rest) && rest[numEnd] >= '0' && rest[numEnd] <= '9' { + numEnd++ + } + n := 0 + if numEnd > 0 { + n, _ = strconv.Atoi(rest[:numEnd]) + } + return 10 + n*10 + suffixWeight(period) + default: + return 200 + } +} + +// buildSummary populates the Summary map from current period items. +func buildSummary(summary Summary, statements []FinancialStatement) { + additiveKeys := map[string]bool{ + "interest_bearing_debt": true, + } + + for _, stmt := range statements { + for _, pd := range stmt.Periods { + if pd.Period != "current" && pd.Period != "filing_date" { + continue + } + for _, item := range pd.Items { + if item.SummaryKey == "" || item.Value == nil { + continue + } + + 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 + } + } + } + } + } +} diff --git a/internal/financial/parser_test.go b/internal/financial/parser_test.go new file mode 100644 index 0000000..f4f45e9 --- /dev/null +++ b/internal/financial/parser_test.go @@ -0,0 +1,914 @@ +package financial + +import ( + "math" + "strings" + "testing" + + "github.com/beatinaniwa/edinet-cli/internal/extract" +) + +// --- Helper functions --- + +// makeCSVFile creates an extract.CSVFile from headers and rows for testing. +func makeCSVFile(filename string, headers []string, rows [][]string) extract.CSVFile { + return extract.CSVFile{ + Filename: filename, + Headers: headers, + Rows: rows, + } +} + +// standardHeaders returns the standard EDINET CSV headers. +func standardHeaders() []string { + return []string{"要素ID", "項目名", "コンテキストID", "相対年度", "連結・個別", "期間・時点", "ユニットID", "単位", "値"} +} + +// makeRow creates a row with standard column count, filling specified values. +func makeRow(elementID, label, contextID, relYear, consolidated, pointType, unitID, unit, value string) []string { + return []string{elementID, label, contextID, relYear, consolidated, pointType, unitID, unit, value} +} + +// findSummaryKey returns the summary value for a key, or nil if not present. +func findSummaryKey(s Summary, key string) *float64 { + if s == nil { + return nil + } + return s[key] +} + +// --- IFRS Consolidated CSV test --- + +func TestParse_IFRSConsolidated(t *testing.T) { + file := makeCSVFile( + "jpcrp030000-asr-001_E02144-000_2025-03-31_01_2025-06-20.csv", + standardHeaders(), + [][]string{ + // BS items (current period, consolidated, instant) + makeRow("jpigp_cor:AssetsIFRS", "資産合計", "CurrentYearInstant", "当期", "連結", "時点", "JPY", "円", "48036704000000"), + makeRow("jpigp_cor:LiabilitiesIFRS", "負債合計", "CurrentYearInstant", "当期", "連結", "時点", "JPY", "円", "30000000000000"), + makeRow("jpigp_cor:EquityIFRS", "資本合計", "CurrentYearInstant", "当期", "連結", "時点", "JPY", "円", "18036704000000"), + makeRow("jpigp_cor:CashAndCashEquivalentsIFRS", "現金", "CurrentYearInstant", "当期", "連結", "時点", "JPY", "円", "5000000000000"), + makeRow("jpigp_cor:CurrentAssetsIFRS", "流動資産", "CurrentYearInstant", "当期", "連結", "時点", "JPY", "円", "20000000000000"), + makeRow("jpigp_cor:TotalCurrentLiabilitiesIFRS", "流動負債", "CurrentYearInstant", "当期", "連結", "時点", "JPY", "円", "15000000000000"), + makeRow("jpigp_cor:InterestBearingLiabilitiesCLIFRS", "有利子負債CL", "CurrentYearInstant", "当期", "連結", "時点", "JPY", "円", "3000000000000"), + makeRow("jpigp_cor:InterestBearingLiabilitiesNCLIFRS", "有利子負債NCL", "CurrentYearInstant", "当期", "連結", "時点", "JPY", "円", "7000000000000"), + makeRow("jpigp_cor:EquityAttributableToOwnersOfParentIFRS", "親会社所有者帰属持分", "CurrentYearInstant", "当期", "連結", "時点", "JPY", "円", "17000000000000"), + // Prior period BS + makeRow("jpigp_cor:AssetsIFRS", "資産合計", "Prior1YearInstant", "前期", "連結", "時点", "JPY", "円", "45000000000000"), + // PL items (current period, consolidated, duration) + makeRow("jpigp_cor:RevenueIFRS", "売上収益", "CurrentYearDuration", "当期", "連結", "期間", "JPY", "円", "37154298000000"), + makeRow("jpigp_cor:CostOfSalesIFRS", "売上原価", "CurrentYearDuration", "当期", "連結", "期間", "JPY", "円", "30000000000000"), + makeRow("jpigp_cor:GrossProfitIFRS", "売上総利益", "CurrentYearDuration", "当期", "連結", "期間", "JPY", "円", "7154298000000"), + makeRow("jpigp_cor:OperatingProfitLossIFRS", "営業利益", "CurrentYearDuration", "当期", "連結", "期間", "JPY", "円", "5352934000000"), + makeRow("jpigp_cor:ProfitLossAttributableToOwnersOfParentIFRS", "親会社帰属当期利益", "CurrentYearDuration", "当期", "連結", "期間", "JPY", "円", "4944898000000"), + makeRow("jpigp_cor:BasicEarningsLossPerShareIFRS", "基本EPS", "CurrentYearDuration", "当期", "連結", "期間", "JPYPerShares", "円/株", "359.56"), + // CF items + makeRow("jpigp_cor:CashFlowsFromUsedInOperatingActivitiesIFRS", "営業CF", "CurrentYearDuration", "当期", "連結", "期間", "JPY", "円", "4300000000000"), + makeRow("jpigp_cor:CashFlowsFromUsedInInvestingActivitiesIFRS", "投資CF", "CurrentYearDuration", "当期", "連結", "期間", "JPY", "円", "-2100000000000"), + makeRow("jpigp_cor:CashFlowsFromUsedInFinancingActivitiesIFRS", "財務CF", "CurrentYearDuration", "当期", "連結", "期間", "JPY", "円", "-1500000000000"), + makeRow("jpigp_cor:DepreciationAndAmortisationIFRS", "減価償却", "CurrentYearDuration", "当期", "連結", "期間", "JPY", "円", "1200000000000"), + // Prior period PL + makeRow("jpigp_cor:RevenueIFRS", "売上収益", "Prior1YearDuration", "前期", "連結", "期間", "JPY", "円", "31000000000000"), + }, + ) + + csvResult := &extract.CSVDataResult{Files: []extract.CSVFile{file}} + result, err := Parse(csvResult, ParseOpts{}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + // Check accounting standard + if result.AccountingStd != "ifrs" { + t.Errorf("AccountingStd = %q, want %q", result.AccountingStd, "ifrs") + } + // Check consolidated + if !result.Consolidated { + t.Error("Consolidated = false, want true") + } + + // Check summary values + assertSummaryValue(t, result.Summary, "total_assets", 48036704000000) + assertSummaryValue(t, result.Summary, "total_liabilities", 30000000000000) + assertSummaryValue(t, result.Summary, "net_assets", 18036704000000) + assertSummaryValue(t, result.Summary, "revenue", 37154298000000) + assertSummaryValue(t, result.Summary, "operating_income", 5352934000000) + assertSummaryValue(t, result.Summary, "net_income", 4944898000000) + assertSummaryValue(t, result.Summary, "cash_and_equivalents", 5000000000000) + assertSummaryValue(t, result.Summary, "current_assets", 20000000000000) + assertSummaryValue(t, result.Summary, "current_liabilities", 15000000000000) + assertSummaryValue(t, result.Summary, "equity", 17000000000000) + assertSummaryValue(t, result.Summary, "operating_cf", 4300000000000) + assertSummaryValue(t, result.Summary, "investing_cf", -2100000000000) + assertSummaryValue(t, result.Summary, "financing_cf", -1500000000000) + assertSummaryValue(t, result.Summary, "depreciation", 1200000000000) + assertSummaryValue(t, result.Summary, "eps", 359.56) + + // Interest bearing debt is additive: CL + NCL = 3T + 7T = 10T + assertSummaryValue(t, result.Summary, "interest_bearing_debt", 10000000000000) + + // Check statements exist + if len(result.Statements) == 0 { + t.Fatal("Statements is empty") + } + + // Check we have BS, PL, CF statements + stmtTypes := make(map[string]bool) + for _, stmt := range result.Statements { + stmtTypes[stmt.Type] = true + } + for _, st := range []string{"bs", "pl", "cf"} { + if !stmtTypes[st] { + t.Errorf("missing statement type %q", st) + } + } + + // Check that statements have periods + for _, stmt := range result.Statements { + if len(stmt.Periods) == 0 { + t.Errorf("statement %q has no periods", stmt.Type) + } + } +} + +// --- JP-GAAP Consolidated CSV test --- + +func TestParse_JPGAAPConsolidated(t *testing.T) { + file := makeCSVFile( + "jpcrp030000-asr-001_E00001-000_2025-03-31_01_2025-06-20.csv", + standardHeaders(), + [][]string{ + // BS + makeRow("jppfs_cor:TotalAssets", "総資産", "CurrentYearInstant", "当期", "連結", "時点", "JPY", "円", "10000000000"), + makeRow("jppfs_cor:TotalLiabilities", "負債合計", "CurrentYearInstant", "当期", "連結", "時点", "JPY", "円", "6000000000"), + makeRow("jppfs_cor:NetAssets", "純資産合計", "CurrentYearInstant", "当期", "連結", "時点", "JPY", "円", "4000000000"), + makeRow("jppfs_cor:CurrentAssets", "流動資産", "CurrentYearInstant", "当期", "連結", "時点", "JPY", "円", "5000000000"), + makeRow("jppfs_cor:CurrentLiabilities", "流動負債", "CurrentYearInstant", "当期", "連結", "時点", "JPY", "円", "3000000000"), + makeRow("jppfs_cor:CashAndDeposits", "現金預金", "CurrentYearInstant", "当期", "連結", "時点", "JPY", "円", "2000000000"), + makeRow("jppfs_cor:ShareholdersEquity", "株主資本", "CurrentYearInstant", "当期", "連結", "時点", "JPY", "円", "3500000000"), + // PL + makeRow("jppfs_cor:NetSales", "売上高", "CurrentYearDuration", "当期", "連結", "期間", "JPY", "円", "8000000000"), + makeRow("jppfs_cor:CostOfSales", "売上原価", "CurrentYearDuration", "当期", "連結", "期間", "JPY", "円", "5000000000"), + makeRow("jppfs_cor:GrossProfit", "売上総利益", "CurrentYearDuration", "当期", "連結", "期間", "JPY", "円", "3000000000"), + makeRow("jppfs_cor:SellingGeneralAndAdministrativeExpenses", "販管費", "CurrentYearDuration", "当期", "連結", "期間", "JPY", "円", "2000000000"), + makeRow("jppfs_cor:OperatingIncome", "営業利益", "CurrentYearDuration", "当期", "連結", "期間", "JPY", "円", "1000000000"), + makeRow("jppfs_cor:OrdinaryIncome", "経常利益", "CurrentYearDuration", "当期", "連結", "期間", "JPY", "円", "1100000000"), + makeRow("jppfs_cor:NetIncome", "当期純利益", "CurrentYearDuration", "当期", "連結", "期間", "JPY", "円", "700000000"), + // CF + makeRow("jppfs_cor:NetCashProvidedByUsedInOperatingActivities", "営業CF", "CurrentYearDuration", "当期", "連結", "期間", "JPY", "円", "1500000000"), + makeRow("jppfs_cor:NetCashProvidedByUsedInInvestingActivities", "投資CF", "CurrentYearDuration", "当期", "連結", "期間", "JPY", "円", "-800000000"), + makeRow("jppfs_cor:NetCashProvidedByUsedInFinancingActivities", "財務CF", "CurrentYearDuration", "当期", "連結", "期間", "JPY", "円", "-300000000"), + }, + ) + + csvResult := &extract.CSVDataResult{Files: []extract.CSVFile{file}} + result, err := Parse(csvResult, ParseOpts{}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + if result.AccountingStd != "jpgaap" { + t.Errorf("AccountingStd = %q, want %q", result.AccountingStd, "jpgaap") + } + if !result.Consolidated { + t.Error("Consolidated = false, want true") + } + + assertSummaryValue(t, result.Summary, "total_assets", 10000000000) + assertSummaryValue(t, result.Summary, "revenue", 8000000000) + assertSummaryValue(t, result.Summary, "operating_income", 1000000000) + assertSummaryValue(t, result.Summary, "ordinary_income", 1100000000) + assertSummaryValue(t, result.Summary, "net_income", 700000000) + assertSummaryValue(t, result.Summary, "sga_expenses", 2000000000) + assertSummaryValue(t, result.Summary, "operating_cf", 1500000000) +} + +// --- Non-consolidated only company test --- + +func TestParse_NonConsolidatedOnly(t *testing.T) { + file := makeCSVFile( + "jpcrp030000-asr-001_E99999-000_2025-03-31_01_2025-06-20.csv", + standardHeaders(), + [][]string{ + makeRow("jppfs_cor:TotalAssets", "総資産", "CurrentYearInstant", "当期", "個別", "時点", "JPY", "円", "5000000000"), + makeRow("jppfs_cor:NetSales", "売上高", "CurrentYearDuration", "当期", "個別", "期間", "JPY", "円", "3000000000"), + makeRow("jppfs_cor:NetCashProvidedByUsedInOperatingActivities", "営業CF", "CurrentYearDuration", "当期", "個別", "期間", "JPY", "円", "800000000"), + }, + ) + + csvResult := &extract.CSVDataResult{Files: []extract.CSVFile{file}} + result, err := Parse(csvResult, ParseOpts{}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + if result.Consolidated { + t.Error("Consolidated = true, want false for non-consolidated only company") + } + assertSummaryValue(t, result.Summary, "total_assets", 5000000000) + assertSummaryValue(t, result.Summary, "revenue", 3000000000) +} + +// --- Mixed consolidation: BS consolidated, CF non-consolidated only --- + +func TestParse_MixedConsolidation(t *testing.T) { + file := makeCSVFile( + "jpcrp030000-asr-001_E88888-000_2025-03-31_01_2025-06-20.csv", + standardHeaders(), + [][]string{ + // BS has both consolidated and non-consolidated → auto picks consolidated + makeRow("jppfs_cor:TotalAssets", "総資産", "CurrentYearInstant", "当期", "連結", "時点", "JPY", "円", "10000000000"), + makeRow("jppfs_cor:TotalAssets", "総資産", "CurrentYearInstant", "当期", "個別", "時点", "JPY", "円", "8000000000"), + // PL has consolidated + makeRow("jppfs_cor:NetSales", "売上高", "CurrentYearDuration", "当期", "連結", "期間", "JPY", "円", "5000000000"), + // CF only has non-consolidated (no consolidated CF data) + makeRow("jppfs_cor:NetCashProvidedByUsedInOperatingActivities", "営業CF", "CurrentYearDuration", "当期", "個別", "期間", "JPY", "円", "600000000"), + }, + ) + + csvResult := &extract.CSVDataResult{Files: []extract.CSVFile{file}} + result, err := Parse(csvResult, ParseOpts{}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + // BS should use consolidated + assertSummaryValue(t, result.Summary, "total_assets", 10000000000) + // CF should fallback to non-consolidated (with warning) + assertSummaryValue(t, result.Summary, "operating_cf", 600000000) + + // Should have a warning about mixed consolidation + hasWarning := false + for _, w := range result.Warnings { + if strings.Contains(w, "non_consolidated") || strings.Contains(w, "fallback") { + hasWarning = true + break + } + } + if !hasWarning { + t.Error("expected warning about CF fallback to non-consolidated, got none") + } +} + +// --- Explicit Consolidated=true option --- + +func TestParse_ExplicitConsolidated(t *testing.T) { + consolidated := true + file := makeCSVFile( + "jpcrp030000-asr-001_E88888-000_2025-03-31_01_2025-06-20.csv", + standardHeaders(), + [][]string{ + makeRow("jppfs_cor:TotalAssets", "総資産", "CurrentYearInstant", "当期", "連結", "時点", "JPY", "円", "10000000000"), + makeRow("jppfs_cor:TotalAssets", "総資産", "CurrentYearInstant", "当期", "個別", "時点", "JPY", "円", "8000000000"), + }, + ) + + csvResult := &extract.CSVDataResult{Files: []extract.CSVFile{file}} + result, err := Parse(csvResult, ParseOpts{Consolidated: &consolidated}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + assertSummaryValue(t, result.Summary, "total_assets", 10000000000) + if !result.Consolidated { + t.Error("Consolidated = false, want true") + } +} + +// --- Explicit Consolidated=false option --- + +func TestParse_ExplicitNonConsolidated(t *testing.T) { + nonConsolidated := false + file := makeCSVFile( + "jpcrp030000-asr-001_E88888-000_2025-03-31_01_2025-06-20.csv", + standardHeaders(), + [][]string{ + makeRow("jppfs_cor:TotalAssets", "総資産", "CurrentYearInstant", "当期", "連結", "時点", "JPY", "円", "10000000000"), + makeRow("jppfs_cor:TotalAssets", "総資産", "CurrentYearInstant", "当期", "個別", "時点", "JPY", "円", "8000000000"), + }, + ) + + csvResult := &extract.CSVDataResult{Files: []extract.CSVFile{file}} + result, err := Parse(csvResult, ParseOpts{Consolidated: &nonConsolidated}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + assertSummaryValue(t, result.Summary, "total_assets", 8000000000) + if result.Consolidated { + t.Error("Consolidated = true, want false for explicit non-consolidated") + } +} + +// --- Multiple CSV files: jpaud excluded, header-missing file skipped --- + +func TestParse_MultipleFiles_JpaudExcludedAndHeaderMissing(t *testing.T) { + mainFile := makeCSVFile( + "jpcrp030000-asr-001_E02144-000_2025-03-31_01_2025-06-20.csv", + standardHeaders(), + [][]string{ + makeRow("jppfs_cor:TotalAssets", "総資産", "CurrentYearInstant", "当期", "連結", "時点", "JPY", "円", "10000000000"), + }, + ) + + // jpaud file should be skipped + jpaudFile := makeCSVFile( + "jpaud-asr-001_E02144-000_2025-03-31_01_2025-06-20.csv", + standardHeaders(), + [][]string{ + makeRow("jppfs_cor:TotalAssets", "総資産", "CurrentYearInstant", "当期", "連結", "時点", "JPY", "円", "99999999"), + }, + ) + + // File with missing required headers should be skipped with warning + badHeaderFile := makeCSVFile( + "jpcrp030000-asr-002_E02144-000_2025-03-31_01_2025-06-20.csv", + []string{"col1", "col2", "col3"}, + [][]string{ + {"a", "b", "c"}, + }, + ) + + csvResult := &extract.CSVDataResult{Files: []extract.CSVFile{mainFile, jpaudFile, badHeaderFile}} + result, err := Parse(csvResult, ParseOpts{}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + // Should use main file's value, not jpaud's 99999999 + assertSummaryValue(t, result.Summary, "total_assets", 10000000000) + + // Should have warning about skipped file with bad headers + hasHeaderWarning := false + for _, w := range result.Warnings { + if strings.Contains(w, "header") || strings.Contains(w, "jpcrp030000-asr-002") { + hasHeaderWarning = true + break + } + } + if !hasHeaderWarning { + t.Error("expected warning about skipped file with missing headers, got none") + } +} + +// --- Value "-" → Value=nil, RawValue preserved --- + +func TestParse_DashValue_NilWithRawValue(t *testing.T) { + file := makeCSVFile( + "jpcrp030000-asr-001_E00001-000_2025-03-31_01_2025-06-20.csv", + standardHeaders(), + [][]string{ + makeRow("jppfs_cor:OrdinaryIncome", "経常利益", "CurrentYearDuration", "当期", "連結", "期間", "JPY", "円", "-"), + makeRow("jppfs_cor:NetSales", "売上高", "CurrentYearDuration", "当期", "連結", "期間", "JPY", "円", "1000000"), + }, + ) + + csvResult := &extract.CSVDataResult{Files: []extract.CSVFile{file}} + result, err := Parse(csvResult, ParseOpts{}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + // Find the dash item in statements + found := false + for _, stmt := range result.Statements { + for _, pd := range stmt.Periods { + for _, item := range pd.Items { + if item.ElementID == "jppfs_cor:OrdinaryIncome" { + found = true + if item.Value != nil { + t.Errorf("Value for '-' should be nil, got %v", *item.Value) + } + if item.RawValue != "-" { + t.Errorf("RawValue = %q, want %q", item.RawValue, "-") + } + } + } + } + } + if !found { + t.Error("could not find OrdinaryIncome item in statements") + } + + // Summary should not have this key (it's nil) + if v := findSummaryKey(result.Summary, "ordinary_income"); v != nil { + t.Errorf("Summary ordinary_income should be nil for '-' value, got %v", *v) + } +} + +// --- Empty value → Value=nil --- + +func TestParse_EmptyValue_Nil(t *testing.T) { + file := makeCSVFile( + "jpcrp030000-asr-001_E00001-000_2025-03-31_01_2025-06-20.csv", + standardHeaders(), + [][]string{ + makeRow("jppfs_cor:OrdinaryIncome", "経常利益", "CurrentYearDuration", "当期", "連結", "期間", "JPY", "円", ""), + makeRow("jppfs_cor:NetSales", "売上高", "CurrentYearDuration", "当期", "連結", "期間", "JPY", "円", "1000000"), + }, + ) + + csvResult := &extract.CSVDataResult{Files: []extract.CSVFile{file}} + result, err := Parse(csvResult, ParseOpts{}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + // Find the empty value item in statements + found := false + for _, stmt := range result.Statements { + for _, pd := range stmt.Periods { + for _, item := range pd.Items { + if item.ElementID == "jppfs_cor:OrdinaryIncome" { + found = true + if item.Value != nil { + t.Errorf("Value for empty string should be nil, got %v", *item.Value) + } + } + } + } + } + if !found { + t.Error("could not find OrdinaryIncome item in statements") + } +} + +// --- Decimal values (EPS 359.56) → float64 --- + +func TestParse_DecimalValue(t *testing.T) { + file := makeCSVFile( + "jpcrp030000-asr-001_E02144-000_2025-03-31_01_2025-06-20.csv", + standardHeaders(), + [][]string{ + makeRow("jpigp_cor:BasicEarningsLossPerShareIFRS", "基本EPS", "CurrentYearDuration", "当期", "連結", "期間", "JPYPerShares", "円/株", "359.56"), + }, + ) + + csvResult := &extract.CSVDataResult{Files: []extract.CSVFile{file}} + result, err := Parse(csvResult, ParseOpts{}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + assertSummaryValue(t, result.Summary, "eps", 359.56) +} + +// --- Duplicate element resolution: first occurrence (main file) wins --- + +func TestParse_DuplicateResolution_FirstWins(t *testing.T) { + // Main file (001) has priority over supplementary file (002) + mainFile := makeCSVFile( + "jpcrp030000-asr-001_E00001-000_2025-03-31_01_2025-06-20.csv", + standardHeaders(), + [][]string{ + makeRow("jppfs_cor:TotalAssets", "総資産", "CurrentYearInstant", "当期", "連結", "時点", "JPY", "円", "10000000000"), + }, + ) + + suppFile := makeCSVFile( + "jpcrp030000-asr-002_E00001-000_2025-03-31_01_2025-06-20.csv", + standardHeaders(), + [][]string{ + makeRow("jppfs_cor:TotalAssets", "総資産", "CurrentYearInstant", "当期", "連結", "時点", "JPY", "円", "99999999"), + }, + ) + + csvResult := &extract.CSVDataResult{Files: []extract.CSVFile{mainFile, suppFile}} + result, err := Parse(csvResult, ParseOpts{}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + // Should use main file's value + assertSummaryValue(t, result.Summary, "total_assets", 10000000000) +} + +// --- Interest bearing debt additive (CL + NCL) --- + +func TestParse_InterestBearingDebt_Additive(t *testing.T) { + file := makeCSVFile( + "jpcrp030000-asr-001_E00001-000_2025-03-31_01_2025-06-20.csv", + standardHeaders(), + [][]string{ + // JP-GAAP interest-bearing debt components + makeRow("jppfs_cor:ShortTermLoansPayable", "短期借入金", "CurrentYearInstant", "当期", "連結", "時点", "JPY", "円", "1000000000"), + makeRow("jppfs_cor:CurrentPortionOfLongTermLoansPayable", "一年以内返済長期借入金", "CurrentYearInstant", "当期", "連結", "時点", "JPY", "円", "500000000"), + makeRow("jppfs_cor:BondsPayable", "社債", "CurrentYearInstant", "当期", "連結", "時点", "JPY", "円", "2000000000"), + makeRow("jppfs_cor:LongTermLoansPayable", "長期借入金", "CurrentYearInstant", "当期", "連結", "時点", "JPY", "円", "3000000000"), + }, + ) + + csvResult := &extract.CSVDataResult{Files: []extract.CSVFile{file}} + result, err := Parse(csvResult, ParseOpts{}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + // 1B + 500M + 2B + 3B = 6.5B + assertSummaryValue(t, result.Summary, "interest_bearing_debt", 6500000000) +} + +// --- Annual report check: warn if filename lacks "asr" marker --- + +func TestParse_NonAnnualReport_Warning(t *testing.T) { + // Quarterly report (not annual "asr") + file := makeCSVFile( + "jpcrp030000-q1r-001_E00001-000_2025-03-31_01_2025-06-20.csv", + standardHeaders(), + [][]string{ + makeRow("jppfs_cor:TotalAssets", "総資産", "CurrentYearInstant", "当期", "連結", "時点", "JPY", "円", "5000000000"), + }, + ) + + csvResult := &extract.CSVDataResult{Files: []extract.CSVFile{file}} + result, err := Parse(csvResult, ParseOpts{}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + hasWarning := false + for _, w := range result.Warnings { + if strings.Contains(w, "annual") || strings.Contains(w, "asr") { + hasWarning = true + break + } + } + if !hasWarning { + t.Error("expected warning about non-annual report, got none") + } +} + +// --- Empty CSVDataResult → error --- + +func TestParse_EmptyCSVResult_Error(t *testing.T) { + csvResult := &extract.CSVDataResult{Files: []extract.CSVFile{}} + _, err := Parse(csvResult, ParseOpts{}) + if err == nil { + t.Fatal("Parse() should return error for empty CSV result") + } +} + +// --- All files skipped → error --- + +func TestParse_AllFilesSkipped_Error(t *testing.T) { + // Only jpaud files and files with bad headers + jpaudFile := makeCSVFile( + "jpaud-asr-001_E02144-000_2025-03-31_01_2025-06-20.csv", + standardHeaders(), + [][]string{ + makeRow("jppfs_cor:TotalAssets", "総資産", "CurrentYearInstant", "当期", "連結", "時点", "JPY", "円", "10000000000"), + }, + ) + + csvResult := &extract.CSVDataResult{Files: []extract.CSVFile{jpaudFile}} + _, err := Parse(csvResult, ParseOpts{}) + if err == nil { + t.Fatal("Parse() should return error when all files are skipped") + } +} + +// --- TextBlock rows are skipped --- + +func TestParse_TextBlockSkipped(t *testing.T) { + file := makeCSVFile( + "jpcrp030000-asr-001_E00001-000_2025-03-31_01_2025-06-20.csv", + standardHeaders(), + [][]string{ + makeRow("jpcrp_cor:BusinessResultsOfReportingCompanyTextBlock", "事業の内容", "CurrentYearDuration", "当期", "連結", "期間", "", "", "long text..."), + makeRow("jppfs_cor:NetSales", "売上高", "CurrentYearDuration", "当期", "連結", "期間", "JPY", "円", "1000000"), + }, + ) + + csvResult := &extract.CSVDataResult{Files: []extract.CSVFile{file}} + result, err := Parse(csvResult, ParseOpts{}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + // TextBlock should not appear in any statement + for _, stmt := range result.Statements { + for _, pd := range stmt.Periods { + for _, item := range pd.Items { + if strings.HasSuffix(item.ElementID, "TextBlock") { + t.Errorf("TextBlock item %q should not be in statements", item.ElementID) + } + } + } + } +} + +// --- Segment member rows are skipped --- + +func TestParse_SegmentMemberSkipped(t *testing.T) { + file := makeCSVFile( + "jpcrp030000-asr-001_E02144-000_2025-03-31_01_2025-06-20.csv", + standardHeaders(), + [][]string{ + // Normal row + makeRow("jpigp_cor:RevenueIFRS", "売上収益", "CurrentYearDuration", "当期", "連結", "期間", "JPY", "円", "37000000000000"), + // Segment member row should be skipped + makeRow("jpigp_cor:RevenueIFRS", "売上収益", "CurrentYearDuration_jpcrp030000-asr_E02144-000AutomotiveReportableSegmentMember", "当期", "その他", "期間", "JPY", "円", "20000000000000"), + }, + ) + + csvResult := &extract.CSVDataResult{Files: []extract.CSVFile{file}} + result, err := Parse(csvResult, ParseOpts{}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + // Revenue should be the non-segment value + assertSummaryValue(t, result.Summary, "revenue", 37000000000000) +} + +// --- File selection: only jpcrp prefix files --- + +func TestParse_NonJpcrpFilesSkipped(t *testing.T) { + // Non-jpcrp file (e.g., jpdei) should be skipped + nonJpcrpFile := makeCSVFile( + "jpdei030000-asr-001_E00001-000_2025-03-31_01_2025-06-20.csv", + standardHeaders(), + [][]string{ + makeRow("jpdei_cor:EDINETCodeDEI", "EDINETコード", "FilingDateInstant", "", "その他", "時点", "", "", "E00001"), + }, + ) + + jpcrpFile := makeCSVFile( + "jpcrp030000-asr-001_E00001-000_2025-03-31_01_2025-06-20.csv", + standardHeaders(), + [][]string{ + makeRow("jppfs_cor:TotalAssets", "総資産", "CurrentYearInstant", "当期", "連結", "時点", "JPY", "円", "5000000000"), + }, + ) + + csvResult := &extract.CSVDataResult{Files: []extract.CSVFile{nonJpcrpFile, jpcrpFile}} + result, err := Parse(csvResult, ParseOpts{}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + assertSummaryValue(t, result.Summary, "total_assets", 5000000000) +} + +// --- Accounting standard detection: per-statement prefix majority --- + +func TestParse_AccountingStdDetection_IFRS(t *testing.T) { + file := makeCSVFile( + "jpcrp030000-asr-001_E02144-000_2025-03-31_01_2025-06-20.csv", + standardHeaders(), + [][]string{ + makeRow("jpigp_cor:RevenueIFRS", "売上収益", "CurrentYearDuration", "当期", "連結", "期間", "JPY", "円", "1000000"), + makeRow("jpigp_cor:OperatingProfitLossIFRS", "営業利益", "CurrentYearDuration", "当期", "連結", "期間", "JPY", "円", "500000"), + makeRow("jpigp_cor:AssetsIFRS", "資産合計", "CurrentYearInstant", "当期", "連結", "時点", "JPY", "円", "2000000"), + }, + ) + + csvResult := &extract.CSVDataResult{Files: []extract.CSVFile{file}} + result, err := Parse(csvResult, ParseOpts{}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + if result.AccountingStd != "ifrs" { + t.Errorf("AccountingStd = %q, want %q", result.AccountingStd, "ifrs") + } +} + +func TestParse_AccountingStdDetection_JPGAAP(t *testing.T) { + file := makeCSVFile( + "jpcrp030000-asr-001_E00001-000_2025-03-31_01_2025-06-20.csv", + standardHeaders(), + [][]string{ + makeRow("jppfs_cor:NetSales", "売上高", "CurrentYearDuration", "当期", "連結", "期間", "JPY", "円", "1000000"), + makeRow("jppfs_cor:OperatingIncome", "営業利益", "CurrentYearDuration", "当期", "連結", "期間", "JPY", "円", "500000"), + makeRow("jppfs_cor:TotalAssets", "総資産", "CurrentYearInstant", "当期", "連結", "時点", "JPY", "円", "2000000"), + }, + ) + + csvResult := &extract.CSVDataResult{Files: []extract.CSVFile{file}} + result, err := Parse(csvResult, ParseOpts{}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + if result.AccountingStd != "jpgaap" { + t.Errorf("AccountingStd = %q, want %q", result.AccountingStd, "jpgaap") + } +} + +// --- Main file (001) has priority in ordering --- + +func TestParse_MainFilePriority(t *testing.T) { + // Supplementary file comes first in the slice, but main file (001) should have priority + suppFile := makeCSVFile( + "jpcrp030000-asr-002_E00001-000_2025-03-31_01_2025-06-20.csv", + standardHeaders(), + [][]string{ + makeRow("jppfs_cor:NetSales", "売上高", "CurrentYearDuration", "当期", "連結", "期間", "JPY", "円", "999"), + }, + ) + + mainFile := makeCSVFile( + "jpcrp030000-asr-001_E00001-000_2025-03-31_01_2025-06-20.csv", + standardHeaders(), + [][]string{ + makeRow("jppfs_cor:NetSales", "売上高", "CurrentYearDuration", "当期", "連結", "期間", "JPY", "円", "5000000"), + }, + ) + + csvResult := &extract.CSVDataResult{Files: []extract.CSVFile{suppFile, mainFile}} + result, err := Parse(csvResult, ParseOpts{}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + assertSummaryValue(t, result.Summary, "revenue", 5000000) +} + +// --- Nil CSVDataResult → error --- + +func TestParse_NilCSVResult_Error(t *testing.T) { + _, err := Parse(nil, ParseOpts{}) + if err == nil { + t.Fatal("Parse() should return error for nil CSV result") + } +} + +// --- Shares and cross-standard elements --- + +func TestParse_CrossStandardElements(t *testing.T) { + file := makeCSVFile( + "jpcrp030000-asr-001_E00001-000_2025-03-31_01_2025-06-20.csv", + standardHeaders(), + [][]string{ + makeRow("jpcrp_cor:NumberOfIssuedSharesAsOfFilingDateTotal", "発行済株式数", "FilingDateInstant", "", "その他", "時点", "shares", "株", "1000000000"), + makeRow("jpcrp_cor:NumberOfTreasurySharesAsOfFilingDateTotal", "自己株式数", "FilingDateInstant", "", "その他", "時点", "shares", "株", "50000000"), + makeRow("jpcrp_cor:DividendPaidPerShareSummaryOfBusinessResults", "一株配当", "CurrentYearDuration", "当期", "その他", "期間", "JPYPerShares", "円/株", "50.00"), + makeRow("jppfs_cor:NetSales", "売上高", "CurrentYearDuration", "当期", "連結", "期間", "JPY", "円", "1000000"), + }, + ) + + csvResult := &extract.CSVDataResult{Files: []extract.CSVFile{file}} + result, err := Parse(csvResult, ParseOpts{}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + assertSummaryValue(t, result.Summary, "shares_outstanding", 1000000000) + assertSummaryValue(t, result.Summary, "treasury_shares", 50000000) + assertSummaryValue(t, result.Summary, "dividend_per_share", 50.00) +} + +// --- Negative values --- + +func TestParse_NegativeValues(t *testing.T) { + file := makeCSVFile( + "jpcrp030000-asr-001_E00001-000_2025-03-31_01_2025-06-20.csv", + standardHeaders(), + [][]string{ + makeRow("jppfs_cor:NetCashProvidedByUsedInInvestingActivities", "投資CF", "CurrentYearDuration", "当期", "連結", "期間", "JPY", "円", "-800000000"), + makeRow("jppfs_cor:NetSales", "売上高", "CurrentYearDuration", "当期", "連結", "期間", "JPY", "円", "1000000"), + }, + ) + + csvResult := &extract.CSVDataResult{Files: []extract.CSVFile{file}} + result, err := Parse(csvResult, ParseOpts{}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + assertSummaryValue(t, result.Summary, "investing_cf", -800000000) +} + +// --- Statement accounting standard field --- + +func TestParse_StatementAccountingStd(t *testing.T) { + file := makeCSVFile( + "jpcrp030000-asr-001_E02144-000_2025-03-31_01_2025-06-20.csv", + standardHeaders(), + [][]string{ + makeRow("jpigp_cor:RevenueIFRS", "売上収益", "CurrentYearDuration", "当期", "連結", "期間", "JPY", "円", "1000000"), + makeRow("jpigp_cor:AssetsIFRS", "資産合計", "CurrentYearInstant", "当期", "連結", "時点", "JPY", "円", "2000000"), + }, + ) + + csvResult := &extract.CSVDataResult{Files: []extract.CSVFile{file}} + result, err := Parse(csvResult, ParseOpts{}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + for _, stmt := range result.Statements { + if stmt.AccountingStd != "ifrs" { + t.Errorf("statement %q AccountingStd = %q, want %q", stmt.Type, stmt.AccountingStd, "ifrs") + } + } +} + +// --- LineItem fields populated correctly --- + +func TestParse_LineItemFields(t *testing.T) { + file := makeCSVFile( + "jpcrp030000-asr-001_E00001-000_2025-03-31_01_2025-06-20.csv", + standardHeaders(), + [][]string{ + makeRow("jppfs_cor:NetSales", "売上高", "CurrentYearDuration", "当期", "連結", "期間", "JPY", "円", "8000000000"), + }, + ) + + csvResult := &extract.CSVDataResult{Files: []extract.CSVFile{file}} + result, err := Parse(csvResult, ParseOpts{}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + // Find the item + found := false + for _, stmt := range result.Statements { + if stmt.Type != "pl" { + continue + } + for _, pd := range stmt.Periods { + for _, item := range pd.Items { + if item.ElementID == "jppfs_cor:NetSales" { + found = true + if item.Label != "売上高" { + t.Errorf("Label = %q, want %q", item.Label, "売上高") + } + if item.Value == nil || *item.Value != 8000000000 { + t.Errorf("Value = %v, want 8000000000", item.Value) + } + if item.RawValue != "8000000000" { + t.Errorf("RawValue = %q, want %q", item.RawValue, "8000000000") + } + if item.Unit != "円" { + t.Errorf("Unit = %q, want %q", item.Unit, "円") + } + if item.UnitID != "JPY" { + t.Errorf("UnitID = %q, want %q", item.UnitID, "JPY") + } + if item.SummaryKey != "revenue" { + t.Errorf("SummaryKey = %q, want %q", item.SummaryKey, "revenue") + } + if !item.IsTotal { + t.Error("IsTotal = false, want true for NetSales") + } + } + } + } + } + if !found { + t.Error("could not find NetSales item in PL statement") + } +} + +// --- Prior period data is included --- + +func TestParse_PriorPeriodIncluded(t *testing.T) { + file := makeCSVFile( + "jpcrp030000-asr-001_E00001-000_2025-03-31_01_2025-06-20.csv", + standardHeaders(), + [][]string{ + makeRow("jppfs_cor:TotalAssets", "総資産", "CurrentYearInstant", "当期", "連結", "時点", "JPY", "円", "10000000000"), + makeRow("jppfs_cor:TotalAssets", "総資産", "Prior1YearInstant", "前期", "連結", "時点", "JPY", "円", "9000000000"), + }, + ) + + csvResult := &extract.CSVDataResult{Files: []extract.CSVFile{file}} + result, err := Parse(csvResult, ParseOpts{}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + // Find BS statement + for _, stmt := range result.Statements { + if stmt.Type != "bs" { + continue + } + periods := make(map[string]bool) + for _, pd := range stmt.Periods { + periods[pd.Period] = true + } + if !periods["current"] { + t.Error("missing current period in BS statement") + } + if !periods["prior1"] { + t.Error("missing prior1 period in BS statement") + } + } +} + +// --- Helper assertion --- + +func assertSummaryValue(t *testing.T, s Summary, key string, want float64) { + t.Helper() + v := s[key] + if v == nil { + t.Errorf("Summary[%q] is nil, want %v", key, want) + return + } + if math.Abs(*v-want) > 0.001 { + t.Errorf("Summary[%q] = %v, want %v", key, *v, want) + } +} diff --git a/internal/financial/types.go b/internal/financial/types.go new file mode 100644 index 0000000..5fb6e65 --- /dev/null +++ b/internal/financial/types.go @@ -0,0 +1,72 @@ +package financial + +// Summary holds key financial figures extracted from the current period. +// Keys are standardized English names (e.g., "revenue", "total_assets"). +// nil values indicate the item was not found or not applicable. +type Summary map[string]*float64 + +// LineItem is a single row in a financial statement. +type LineItem struct { + ElementID string `json:"element_id"` + Label string `json:"label"` + Category string `json:"category,omitempty"` + Value *float64 `json:"value"` + RawValue string `json:"raw_value,omitempty"` + Unit string `json:"unit,omitempty"` + UnitID string `json:"unit_id,omitempty"` + SummaryKey string `json:"summary_key,omitempty"` + IsTotal bool `json:"is_total,omitempty"` +} + +// PeriodData holds line items for a specific period. +type PeriodData struct { + Period string `json:"period"` + Items []LineItem `json:"items"` +} + +// FinancialStatement is a structured BS, PL, or CF statement. +type FinancialStatement struct { + Type string `json:"type"` + Consolidated bool `json:"consolidated"` + AccountingStd string `json:"accounting_standard"` + PeriodEnd string `json:"period_end,omitempty"` + PeriodStart string `json:"period_start,omitempty"` + Periods []PeriodData `json:"periods"` +} + +// ParseResult is the output of the CSV parser before service-layer metadata is added. +type ParseResult struct { + Summary Summary `json:"summary"` + Statements []FinancialStatement `json:"statements"` + AccountingStd string `json:"accounting_standard"` + Consolidated bool `json:"consolidated"` + Warnings []string `json:"warnings,omitempty"` +} + +// CompanyFinancialsResult is the output of company financials command. +type CompanyFinancialsResult struct { + Company CompanyInfo `json:"company"` + Periods []FinancialData `json:"periods"` + Warnings []string `json:"warnings,omitempty"` +} + +// CompanyInfo identifies a company in the output. +type CompanyInfo struct { + EdinetCode string `json:"edinet_code"` + SecCode string `json:"sec_code,omitempty"` + Name string `json:"name,omitempty"` +} + +// FinancialData is the final output of the service layer with document metadata. +type FinancialData struct { + DocID string `json:"doc_id"` + CompanyName string `json:"company_name,omitempty"` + EdinetCode string `json:"edinet_code,omitempty"` + SecCode string `json:"sec_code,omitempty"` + FiscalYear string `json:"fiscal_year,omitempty"` + AccountingStd string `json:"accounting_standard"` + Consolidated bool `json:"consolidated"` + Summary Summary `json:"summary"` + Statements []FinancialStatement `json:"statements"` + Warnings []string `json:"warnings,omitempty"` +} diff --git a/internal/financial/types_test.go b/internal/financial/types_test.go new file mode 100644 index 0000000..2219f9c --- /dev/null +++ b/internal/financial/types_test.go @@ -0,0 +1,73 @@ +package financial + +import ( + "encoding/json" + "testing" +) + +func TestSummary_JSONRoundtrip(t *testing.T) { + rev := 48036704000000.0 + s := Summary{ + "revenue": &rev, + "net_income": nil, + "total_assets": nil, + } + + data, err := json.Marshal(s) + if err != nil { + t.Fatalf("Marshal error: %v", err) + } + + var got Summary + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("Unmarshal error: %v", err) + } + + if got["revenue"] == nil || *got["revenue"] != rev { + t.Errorf("revenue = %v, want %v", got["revenue"], rev) + } + // nil values should roundtrip as JSON null + if got["net_income"] != nil { + t.Errorf("net_income = %v, want nil", got["net_income"]) + } +} + +func TestFinancialData_JSONOutput(t *testing.T) { + val := 1000.0 + fd := FinancialData{ + DocID: "S100TEST", + AccountingStd: "jpgaap", + Consolidated: true, + Summary: Summary{ + "revenue": &val, + }, + Statements: []FinancialStatement{ + { + Type: "pl", + Consolidated: true, + AccountingStd: "jpgaap", + Periods: []PeriodData{ + { + Period: "current", + Items: []LineItem{ + { + ElementID: "jppfs_cor:NetSales", + Label: "売上高", + Value: &val, + SummaryKey: "revenue", + }, + }, + }, + }, + }, + }, + } + + data, err := json.Marshal(fd) + if err != nil { + t.Fatalf("Marshal error: %v", err) + } + if !json.Valid(data) { + t.Error("output is not valid JSON") + } +} diff --git a/internal/schema/schema.go b/internal/schema/schema.go index 8876724..b0391fc 100644 --- a/internal/schema/schema.go +++ b/internal/schema/schema.go @@ -128,6 +128,19 @@ func ListCommands() []CommandInfo { "edinet doc text --list-sections", }, }, + { + Name: "doc financial", + Description: "Extract structured financial statements from CSV", + Flags: []FlagInfo{ + {Name: "--statement", Type: "string", Default: "all", Description: "Statement type: bs, pl, cf, all"}, + {Name: "--non-consolidated", Type: "bool", Description: "Prefer non-consolidated statements"}, + }, + Examples: []string{ + "edinet doc financial S100ABCD", + "edinet doc financial S100ABCD --statement pl", + "edinet doc financial S100ABCD --non-consolidated", + }, + }, { Name: "company search", Description: "Search for companies by name, code, or industry", @@ -150,6 +163,20 @@ func ListCommands() []CommandInfo { }, Examples: []string{"edinet company filings 7203 --doc-type 120 --limit 5"}, }, + { + Name: "company financials", + Description: "Extract financial statements for multiple fiscal periods", + Flags: []FlagInfo{ + {Name: "--periods", Type: "int", Default: "3", Description: "Number of fiscal periods (1-10)"}, + {Name: "--statement", Type: "string", Default: "all", Description: "Statement type: bs, pl, cf, all"}, + {Name: "--non-consolidated", Type: "bool", Description: "Prefer non-consolidated statements"}, + }, + Examples: []string{ + "edinet company financials E02144", + "edinet company financials 7203 --periods 5", + "edinet company financials トヨタ --statement pl", + }, + }, { Name: "company update", Description: "Download and update the EDINET code list", @@ -167,6 +194,10 @@ func ListCommands() []CommandInfo { Name: "schema sections", Description: "List known sections for text extraction", }, + { + Name: "schema financial-elements", + Description: "List all known financial XBRL element mappings", + }, } } diff --git a/internal/schema/schema_test.go b/internal/schema/schema_test.go index d89a19b..2968e24 100644 --- a/internal/schema/schema_test.go +++ b/internal/schema/schema_test.go @@ -40,7 +40,7 @@ func TestListCommands_HasAllTopLevel(t *testing.T) { t.Fatal("ListCommands() returned empty") } - wantNames := []string{"doc list", "doc get", "doc data", "doc text", "company search", "company filings", "company update", "schema commands", "schema doc-types", "schema sections"} + wantNames := []string{"doc list", "doc get", "doc data", "doc text", "doc financial", "company search", "company filings", "company financials", "company update", "schema commands", "schema doc-types", "schema sections", "schema financial-elements"} cmdMap := map[string]bool{} for _, c := range cmds { cmdMap[c.Name] = true diff --git a/internal/service/financial.go b/internal/service/financial.go new file mode 100644 index 0000000..935c879 --- /dev/null +++ b/internal/service/financial.go @@ -0,0 +1,317 @@ +package service + +import ( + "context" + "fmt" + "strconv" + "time" + + "github.com/beatinaniwa/edinet-cli/internal/api" + "github.com/beatinaniwa/edinet-cli/internal/cache" + "github.com/beatinaniwa/edinet-cli/internal/extract" + "github.com/beatinaniwa/edinet-cli/internal/financial" +) + +// CompanyFinancialsOpts configures a company financials query. +type CompanyFinancialsOpts struct { + StatementOpts + Periods int // number of fiscal years (default 3) + RateLimit time.Duration // delay between API calls (default 100ms) +} + +// permanentCacheTTL is used for downloaded documents that never change. +// Securities reports are immutable once filed, so we use a very large TTL. +const permanentCacheTTL = 100 * 365 * 24 * time.Hour // ~100 years + +// FinancialService provides structured financial statement extraction from EDINET CSV data. +type FinancialService struct { + client *api.Client + cache cache.Cache +} + +// StatementOpts configures the financial statement extraction. +type StatementOpts struct { + Statement string // "bs", "pl", "cf", "all" (default "all") + Consolidated *bool // nil=auto, true=consolidated, false=non-consolidated +} + +// NewFinancialService creates a new FinancialService. +func NewFinancialService(client *api.Client, c cache.Cache) *FinancialService { + return &FinancialService{client: client, cache: c} +} + +// GetStatements retrieves and parses financial statements for a document. +func (s *FinancialService) GetStatements(ctx context.Context, docID string, opts StatementOpts) (*financial.FinancialData, error) { + cacheKey := fmt.Sprintf("files/%s/5", docID) + + body, fromCache, err := s.fetchCSV(ctx, docID, cacheKey) + if err != nil { + return nil, err + } + + // Extract CSV from ZIP — retry on cache corruption (extraction failure only) + csvResult, err := extract.ExtractCSVData(body) + if err != nil && fromCache { + freshBody, dlErr := s.downloadAndCache(ctx, docID, cacheKey) + if dlErr != nil { + return nil, &api.EDINETError{ + Code: api.ErrInternal, + Message: fmt.Sprintf("cache corruption recovery failed: %v (original: %v)", dlErr, err), + } + } + csvResult, err = extract.ExtractCSVData(freshBody) + } + if err != nil { + return nil, fmt.Errorf("csv extraction failed: %w", err) + } + + // Parse (semantic errors are NOT retried — they are not cache corruption) + data, err := s.parseAndBuild(csvResult, docID, opts) + if err != nil { + return nil, err + } + + return data, nil +} + +// fetchCSV retrieves the CSV ZIP data, using cache. +// Returns (body, fromCache, error). +func (s *FinancialService) fetchCSV(ctx context.Context, docID, cacheKey string) ([]byte, bool, error) { + // Try cache first + if data, err := s.cache.Get(cacheKey, permanentCacheTTL); err == nil { + return data, true, nil + } + + // Cache miss — download from API + body, err := s.downloadAndCache(ctx, docID, cacheKey) + if err != nil { + return nil, false, err + } + return body, false, nil +} + +// downloadAndCache downloads CSV data from the API and stores it in cache. +func (s *FinancialService) downloadAndCache(ctx context.Context, docID, cacheKey string) ([]byte, error) { + body, _, err := s.client.DownloadDocument(ctx, docID, 5) + if err != nil { + return nil, err + } + + _ = s.cache.Set(cacheKey, body) + return body, nil +} + +// GetCompanyFinancials retrieves financial statements for multiple fiscal periods of a company. +func (s *FinancialService) GetCompanyFinancials(ctx context.Context, companySvc *CompanyService, code string, opts CompanyFinancialsOpts) (*financial.CompanyFinancialsResult, error) { + periods := opts.Periods + if periods <= 0 { + periods = 3 + } + + rateLimit := opts.RateLimit + if rateLimit == 0 { + rateLimit = 100 * time.Millisecond + } + + // Find annual reports (doc type 120) going back enough days to cover the requested periods + lookbackDays := periods * 400 + jst := time.FixedZone("JST", 9*60*60) + nowJST := time.Now().In(jst) + from := nowJST.AddDate(0, 0, -lookbackDays).Format("2006-01-02") + to := nowJST.Format("2006-01-02") + + filingsResult, err := companySvc.Filings(ctx, code, FilingsOptions{ + DocType: "120", + From: from, + To: to, + RateLimit: rateLimit, + Limit: 0, // fetch all, then take latest N + }) + if err != nil { + return nil, err + } + + // Take the latest N filings (results are in ascending date order) + if len(filingsResult.Results) > periods { + filingsResult.Results = filingsResult.Results[len(filingsResult.Results)-periods:] + } + + if len(filingsResult.Results) == 0 { + return nil, &api.EDINETError{ + Code: api.ErrNotFound, + Message: fmt.Sprintf("no annual reports found for %s in the last %d days", code, lookbackDays), + } + } + + // Build company info from first filing + first := filingsResult.Results[0] + companyInfo := financial.CompanyInfo{ + EdinetCode: first.EdinetCode, + SecCode: first.SecCode, + Name: first.FilerName, + } + + stmtOpts := opts.StatementOpts + + var financialPeriods []financial.FinancialData + var warnings []string + + for i, filing := range filingsResult.Results { + if ctx.Err() != nil { + return nil, ctx.Err() + } + + // Check if data is in cache before applying rate limit + cacheKey := fmt.Sprintf("files/%s/5", filing.DocID) + _, cacheErr := s.cache.Get(cacheKey, permanentCacheTTL) + isCacheHit := cacheErr == nil + + // Rate limit only for API calls (not cache hits), and skip for first request + if i > 0 && !isCacheHit { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(rateLimit): + } + } + + data, err := s.GetStatements(ctx, filing.DocID, stmtOpts) + if err != nil { + warnings = append(warnings, fmt.Sprintf("%s: %v", filing.DocID, err)) + continue + } + + // Enrich with filing metadata + data.EdinetCode = filing.EdinetCode + data.SecCode = filing.SecCode + data.CompanyName = filing.FilerName + data.FiscalYear = filing.PeriodEnd + + financialPeriods = append(financialPeriods, *data) + } + + if len(financialPeriods) == 0 { + return nil, &api.EDINETError{ + Code: api.ErrServer, + Message: "all " + strconv.Itoa(len(filingsResult.Results)) + " filings failed: " + fmt.Sprintf("%v", warnings), + } + } + + // Add filing-level warnings + if len(filingsResult.Metadata.Warnings) > 0 { + warnings = append(warnings, filingsResult.Metadata.Warnings...) + } + + result := &financial.CompanyFinancialsResult{ + Company: companyInfo, + Periods: financialPeriods, + } + if len(warnings) > 0 { + result.Warnings = warnings + } + + return result, nil +} + +// rebuildSummary builds a summary from the given statements' current period items. +func rebuildSummary(stmts []financial.FinancialStatement) financial.Summary { + summary := make(financial.Summary) + additiveKeys := map[string]bool{"interest_bearing_debt": true} + + for _, stmt := range stmts { + for _, pd := range stmt.Periods { + if pd.Period != "current" && pd.Period != "filing_date" { + continue + } + for _, item := range pd.Items { + if item.SummaryKey == "" || item.Value == nil { + continue + } + 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 + } + } + } + } + return summary +} + +// parseAndBuild parses CSV data and builds the FinancialData output. +func (s *FinancialService) parseAndBuild(csvResult *extract.CSVDataResult, docID string, opts StatementOpts) (*financial.FinancialData, error) { + parseOpts := financial.ParseOpts{ + Consolidated: opts.Consolidated, + } + + parseResult, err := financial.Parse(csvResult, parseOpts) + if err != nil { + return nil, &api.EDINETError{ + Code: api.ErrBadRequest, + Message: fmt.Sprintf("failed to parse financial data: %v", err), + } + } + + // Build FinancialData from ParseResult + data := &financial.FinancialData{ + DocID: docID, + AccountingStd: parseResult.AccountingStd, + Consolidated: parseResult.Consolidated, + Summary: parseResult.Summary, + Statements: parseResult.Statements, + Warnings: parseResult.Warnings, + } + + // Filter by statement type if requested + stmtFilter := opts.Statement + if stmtFilter == "" { + stmtFilter = "all" + } + + if stmtFilter != "all" { + var filtered []financial.FinancialStatement + for _, stmt := range data.Statements { + if stmt.Type == stmtFilter { + filtered = append(filtered, stmt) + } + } + if len(filtered) == 0 { + return nil, &api.EDINETError{ + Code: api.ErrNotFound, + Message: fmt.Sprintf("statement type %q not found in document %s", stmtFilter, docID), + } + } + data.Statements = filtered + + // Recompute top-level fields from filtered statements + hasCons := false + for _, stmt := range data.Statements { + if stmt.Consolidated { + hasCons = true + break + } + } + data.Consolidated = hasCons + + // Rebuild summary from only the filtered statements + data.Summary = rebuildSummary(data.Statements) + } + + // Empty result check + if len(data.Statements) == 0 { + return nil, &api.EDINETError{ + Code: api.ErrBadRequest, + Message: fmt.Sprintf("no financial statements found in document %s", docID), + } + } + + return data, nil +} diff --git a/internal/service/financial_test.go b/internal/service/financial_test.go new file mode 100644 index 0000000..25ff8df --- /dev/null +++ b/internal/service/financial_test.go @@ -0,0 +1,443 @@ +package service + +import ( + "archive/zip" + "bytes" + "context" + "encoding/csv" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/beatinaniwa/edinet-cli/internal/api" + "github.com/beatinaniwa/edinet-cli/internal/cache" + "github.com/beatinaniwa/edinet-cli/internal/company" +) + +// createCSVZip creates a ZIP archive containing a single CSV file. +func createCSVZip(t *testing.T, filename string, headers []string, rows [][]string) []byte { + t.Helper() + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + + fw, err := zw.Create(filename) + if err != nil { + t.Fatalf("failed to create zip entry: %v", err) + } + + cw := csv.NewWriter(fw) + if err := cw.Write(headers); err != nil { + t.Fatalf("failed to write CSV headers: %v", err) + } + for _, row := range rows { + if err := cw.Write(row); err != nil { + t.Fatalf("failed to write CSV row: %v", err) + } + } + cw.Flush() + + if err := zw.Close(); err != nil { + t.Fatalf("failed to close zip writer: %v", err) + } + return buf.Bytes() +} + +// makeCSVZip creates a minimal ZIP containing a CSV file in the XBRL_TO_CSV directory. +func makeCSVZip(t *testing.T) []byte { + t.Helper() + return createCSVZip(t, "XBRL_TO_CSV/jpcrp030000-asr-001_E00001-000_2025-03-31_01_2025-06-20.csv", + []string{"要素ID", "項目名", "コンテキストID", "連結・個別", "期間・時点", "ユニットID", "単位", "値"}, + [][]string{ + {"jppfs_cor:NetSales", "売上高", "CurrentYearDuration", "連結", "期間", "JPY", "円", "1000000"}, + {"jppfs_cor:OperatingIncome", "営業利益", "CurrentYearDuration", "連結", "期間", "JPY", "円", "200000"}, + {"jppfs_cor:TotalAssets", "総資産", "CurrentYearInstant", "連結", "時点", "JPY", "円", "5000000"}, + {"jppfs_cor:NetAssets", "純資産", "CurrentYearInstant", "連結", "時点", "JPY", "円", "3000000"}, + {"jppfs_cor:NetCashProvidedByUsedInOperatingActivities", "営業活動によるCF", "CurrentYearDuration", "連結", "期間", "JPY", "円", "300000"}, + }, + ) +} + +// mapCache is a simple in-memory cache for testing. +type mapCache struct { + data map[string][]byte +} + +func (m *mapCache) Get(key string, _ time.Duration) ([]byte, error) { + if data, ok := m.data[key]; ok { + return data, nil + } + return nil, cache.ErrCacheMiss +} + +func (m *mapCache) Set(key string, data []byte) error { + m.data[key] = data + return nil +} + +func TestFinancialService_GetStatements_CacheMiss(t *testing.T) { + zipData := makeCSVZip(t) + client, _ := setupMockServer(t, func(w http.ResponseWriter, r *http.Request) { + // Verify it's a CSV download (type=5) + if r.URL.Query().Get("type") != "5" { + t.Errorf("expected type=5, got %q", r.URL.Query().Get("type")) + } + w.Header().Set("Content-Type", "application/octet-stream") + _, _ = w.Write(zipData) + }) + + svc := NewFinancialService(client, cache.NoopCache{}) + result, err := svc.GetStatements(context.Background(), "S100ABCD", StatementOpts{}) + if err != nil { + t.Fatalf("GetStatements() error = %v", err) + } + if result.DocID != "S100ABCD" { + t.Errorf("DocID = %q, want %q", result.DocID, "S100ABCD") + } + if len(result.Statements) == 0 { + t.Error("expected at least one statement") + } + if result.Summary == nil { + t.Error("expected non-nil summary") + } + // Check that revenue was parsed + if rev, ok := result.Summary["revenue"]; !ok || rev == nil || *rev != 1000000 { + t.Errorf("expected revenue=1000000, got %v", result.Summary["revenue"]) + } +} + +func TestFinancialService_GetStatements_CacheHit(t *testing.T) { + zipData := makeCSVZip(t) + + // Server should NOT be called if cache hits + callCount := 0 + client, _ := setupMockServer(t, func(w http.ResponseWriter, r *http.Request) { + callCount++ + w.Header().Set("Content-Type", "application/octet-stream") + _, _ = w.Write(zipData) + }) + + // Pre-populate cache + mc := &mapCache{data: map[string][]byte{"files/S100ABCD/5": zipData}} + + svc := NewFinancialService(client, mc) + result, err := svc.GetStatements(context.Background(), "S100ABCD", StatementOpts{}) + if err != nil { + t.Fatalf("GetStatements() error = %v", err) + } + if callCount != 0 { + t.Errorf("API called %d times, expected 0 (cache hit)", callCount) + } + if result.DocID != "S100ABCD" { + t.Errorf("DocID = %q, want %q", result.DocID, "S100ABCD") + } +} + +func TestFinancialService_GetStatements_CacheCorruptionRecovery(t *testing.T) { + zipData := makeCSVZip(t) + + callCount := 0 + client, _ := setupMockServer(t, func(w http.ResponseWriter, r *http.Request) { + callCount++ + w.Header().Set("Content-Type", "application/octet-stream") + _, _ = w.Write(zipData) + }) + + // Pre-populate cache with corrupt data + mc := &mapCache{data: map[string][]byte{"files/S100ABCD/5": []byte("corrupt zip data")}} + + svc := NewFinancialService(client, mc) + result, err := svc.GetStatements(context.Background(), "S100ABCD", StatementOpts{}) + if err != nil { + t.Fatalf("GetStatements() error = %v", err) + } + if callCount != 1 { + t.Errorf("API called %d times, expected 1 (re-download after corruption)", callCount) + } + if result.DocID != "S100ABCD" { + t.Errorf("DocID = %q, want %q", result.DocID, "S100ABCD") + } +} + +func TestFinancialService_GetStatements_DownloadFailure(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":{"title":"API","status":"404","message":"Not Found"}}`)) + }) + + svc := NewFinancialService(client, cache.NoopCache{}) + _, err := svc.GetStatements(context.Background(), "S100ABCD", StatementOpts{}) + if err == nil { + t.Fatal("expected error for download failure") + } + edinetErr, ok := err.(*api.EDINETError) + if !ok { + t.Fatalf("expected *api.EDINETError, got %T", err) + } + if edinetErr.Code != api.ErrNotFound { + t.Errorf("error code = %q, want %q", edinetErr.Code, api.ErrNotFound) + } +} + +func TestFinancialService_GetStatements_FilterByStatement(t *testing.T) { + zipData := makeCSVZip(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(), "S100ABCD", StatementOpts{Statement: "pl"}) + if err != nil { + t.Fatalf("GetStatements() error = %v", err) + } + for _, stmt := range result.Statements { + if stmt.Type != "pl" { + t.Errorf("expected only pl statements, got %q", stmt.Type) + } + } +} + +func TestFinancialService_GetStatements_StatementNotFound(t *testing.T) { + // Create CSV with only PL data (no CF data at all) + zipData := createCSVZip(t, "XBRL_TO_CSV/jpcrp030000-asr-001_E00001-000_2025-03-31_01_2025-06-20.csv", + []string{"要素ID", "項目名", "コンテキストID", "連結・個別", "期間・時点", "ユニットID", "単位", "値"}, + [][]string{ + {"jppfs_cor:NetSales", "売上高", "CurrentYearDuration", "連結", "期間", "JPY", "円", "1000000"}, + }, + ) + 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{}) + _, err := svc.GetStatements(context.Background(), "S100ABCD", StatementOpts{Statement: "cf"}) + if err == nil { + t.Fatal("expected error when requested statement type not found") + } + edinetErr, ok := err.(*api.EDINETError) + if !ok { + t.Fatalf("expected *api.EDINETError, got %T", err) + } + if edinetErr.Code != api.ErrNotFound { + t.Errorf("error code = %q, want %q", edinetErr.Code, api.ErrNotFound) + } +} + +func TestFinancialService_GetStatements_EmptyCSV(t *testing.T) { + // Create ZIP with an empty CSV (no data rows) + zipData := createCSVZip(t, "XBRL_TO_CSV/jpcrp030000-asr-001_E00001-000_2025-03-31_01_2025-06-20.csv", + []string{"要素ID", "項目名", "コンテキストID", "連結・個別", "期間・時点", "ユニットID", "単位", "値"}, + [][]string{}, + ) + 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{}) + _, err := svc.GetStatements(context.Background(), "S100ABCD", StatementOpts{}) + if err == nil { + t.Fatal("expected error for empty CSV result") + } + edinetErr, ok := err.(*api.EDINETError) + if !ok { + t.Fatalf("expected *api.EDINETError, got %T", err) + } + if edinetErr.Code != api.ErrBadRequest { + t.Errorf("error code = %q, want %q", edinetErr.Code, api.ErrBadRequest) + } +} + +func TestFinancialService_GetStatements_NonConsolidated(t *testing.T) { + zipData := makeCSVZip(t) // has consolidated data + client, _ := setupMockServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/octet-stream") + _, _ = w.Write(zipData) + }) + + nonCons := false + svc := NewFinancialService(client, cache.NoopCache{}) + result, err := svc.GetStatements(context.Background(), "S100ABCD", StatementOpts{Consolidated: &nonCons}) + if err != nil { + t.Fatalf("GetStatements() error = %v", err) + } + // The result should still work (fallback with warning), or have data + if result.DocID != "S100ABCD" { + t.Errorf("DocID = %q, want %q", result.DocID, "S100ABCD") + } +} + +// makeDocListResponse creates a JSON document list response with the given documents. +func makeDocListResponse(date string, docs []map[string]string) string { + var results []string + for i, doc := range docs { + docID := doc["docID"] + edinetCode := doc["edinetCode"] + secCode := "null" + if v, ok := doc["secCode"]; ok { + secCode = fmt.Sprintf("%q", v) + } + filerName := "null" + if v, ok := doc["filerName"]; ok { + filerName = fmt.Sprintf("%q", v) + } + docTypeCode := "120" + if v, ok := doc["docTypeCode"]; ok { + docTypeCode = v + } + periodEnd := "null" + if v, ok := doc["periodEnd"]; ok { + periodEnd = fmt.Sprintf("%q", v) + } + results = append(results, fmt.Sprintf(`{"seqNumber":%d,"docID":%q,"edinetCode":%q,"secCode":%s,"JCN":null,"filerName":%s,"fundCode":null,"ordinanceCode":null,"formCode":null,"docTypeCode":%q,"periodStart":null,"periodEnd":%s,"submitDateTime":null,"docDescription":null,"issuerEdinetCode":null,"subjectEdinetCode":null,"subsidiaryEdinetCode":null,"currentReportReason":null,"parentDocID":null,"opeDateTime":null,"withdrawalStatus":"0","docInfoEditStatus":"0","disclosureStatus":"0","xbrlFlag":"1","pdfFlag":"1","attachDocFlag":"0","englishDocFlag":"0","csvFlag":"1","legalStatus":"1"}`, i+1, docID, edinetCode, secCode, filerName, docTypeCode, periodEnd)) + } + return fmt.Sprintf(`{"metadata":{"status":"200","message":"OK","parameter":{"date":"%s","type":"2"},"resultset":{"count":%d},"processDateTime":"2025-06-20 13:01"},"results":[%s]}`, date, len(results), strings.Join(results, ",")) +} + +func TestGetCompanyFinancials_MultiplePeriods(t *testing.T) { + zipData := makeCSVZip(t) + + // Track requests by path to route doc list vs download + mux := http.NewServeMux() + mux.HandleFunc("/api/v2/documents.json", func(w http.ResponseWriter, r *http.Request) { + date := r.URL.Query().Get("date") + w.Header().Set("Content-Type", "application/json; charset=utf-8") + // Return a filing for E02144 on specific dates + resp := makeDocListResponse(date, []map[string]string{ + { + "docID": "S" + strings.ReplaceAll(date, "-", ""), + "edinetCode": "E02144", + "secCode": "72030", + "filerName": "トヨタ自動車株式会社", + "docTypeCode": "120", + "periodEnd": date, + }, + }) + _, _ = w.Write([]byte(resp)) + }) + mux.HandleFunc("/api/v2/documents/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/octet-stream") + _, _ = w.Write(zipData) + }) + + server := newTestServer(t, mux) + client := api.NewClient("test-key", server.URL, false) + + mc := &mapCache{data: map[string][]byte{}} + finSvc := NewFinancialService(client, mc) + docSvc := NewDocumentService(client, mc, nil) + + reg := loadTestRegistry(t) + companySvc := NewCompanyService(reg, docSvc) + + result, err := finSvc.GetCompanyFinancials(context.Background(), companySvc, "E02144", CompanyFinancialsOpts{ + Periods: 2, + RateLimit: time.Millisecond, + }) + if err != nil { + t.Fatalf("GetCompanyFinancials() error = %v", err) + } + + if result.Company.EdinetCode != "E02144" { + t.Errorf("Company.EdinetCode = %q, want %q", result.Company.EdinetCode, "E02144") + } + if result.Company.Name != "トヨタ自動車株式会社" { + t.Errorf("Company.Name = %q, want %q", result.Company.Name, "トヨタ自動車株式会社") + } + if len(result.Periods) == 0 { + t.Fatal("expected at least one period") + } + // Each period should have financial data + for i, p := range result.Periods { + if p.DocID == "" { + t.Errorf("Periods[%d].DocID is empty", i) + } + if len(p.Statements) == 0 { + t.Errorf("Periods[%d] has no statements", i) + } + } +} + +func TestGetCompanyFinancials_PartialFailure(t *testing.T) { + zipData := makeCSVZip(t) + + downloadCount := 0 + mux := http.NewServeMux() + mux.HandleFunc("/api/v2/documents.json", func(w http.ResponseWriter, r *http.Request) { + date := r.URL.Query().Get("date") + w.Header().Set("Content-Type", "application/json; charset=utf-8") + resp := makeDocListResponse(date, []map[string]string{ + { + "docID": "S" + strings.ReplaceAll(date, "-", ""), + "edinetCode": "E02144", + "secCode": "72030", + "filerName": "トヨタ自動車株式会社", + "docTypeCode": "120", + "periodEnd": date, + }, + }) + _, _ = w.Write([]byte(resp)) + }) + mux.HandleFunc("/api/v2/documents/", func(w http.ResponseWriter, r *http.Request) { + downloadCount++ + if downloadCount == 1 { + // First download fails + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _, _ = w.Write([]byte(`{"metadata":{"title":"API","status":"404","message":"Not Found"}}`)) + return + } + w.Header().Set("Content-Type", "application/octet-stream") + _, _ = w.Write(zipData) + }) + + server := newTestServer(t, mux) + client := api.NewClient("test-key", server.URL, false) + + mc := &mapCache{data: map[string][]byte{}} + finSvc := NewFinancialService(client, mc) + docSvc := NewDocumentService(client, mc, nil) + + reg := loadTestRegistry(t) + companySvc := NewCompanyService(reg, docSvc) + + result, err := finSvc.GetCompanyFinancials(context.Background(), companySvc, "E02144", CompanyFinancialsOpts{ + Periods: 2, + RateLimit: time.Millisecond, + }) + if err != nil { + t.Fatalf("GetCompanyFinancials() error = %v (should succeed with partial results)", err) + } + + // Should have at least one successful period + if len(result.Periods) == 0 { + t.Fatal("expected at least one successful period") + } + // Should have warnings for the failed period + if len(result.Warnings) == 0 { + t.Error("expected warnings for failed period") + } +} + +// loadTestRegistry loads a test registry for company resolution. +func loadTestRegistry(t *testing.T) *company.Registry { + t.Helper() + // Minimal CSV data for E02144 (Toyota) + csvData := []byte("EDINETコードリスト\nEDINETコード,提出者種別,上場区分,連結の有無,資本金,決算日,提出者名,提出者名(英字),提出者名(ヨミ),所在地,提出者業種,証券コード,提出者法人番号\nE02144,内国法人・組合,上場,あり,635402000000,3月,トヨタ自動車株式会社,TOYOTA MOTOR CORPORATION,トヨタジドウシャ,愛知県豊田市トヨタ町1番地,輸送用機器,72030,1180301018771\n") + r := &company.Registry{} + if err := r.LoadFromCSV(csvData); err != nil { + t.Fatalf("LoadFromCSV() error = %v", err) + } + return r +} + +// newTestServer creates an httptest.Server with automatic cleanup. +func newTestServer(t *testing.T, handler http.Handler) *httptest.Server { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + return server +} From c8d2e677d1940bb49b52ac78d9d499e9e42e28ee Mon Sep 17 00:00:00 2001 From: beatinaniwa Date: Wed, 1 Apr 2026 14:49:33 +0900 Subject: [PATCH 2/6] =?UTF-8?q?fix(parser):=20=E9=9D=9E=E9=80=A3=E7=B5=90?= =?UTF-8?q?=E3=83=A2=E3=83=BC=E3=83=89=E3=81=A7=E3=82=82=E3=83=8B=E3=83=A5?= =?UTF-8?q?=E3=83=BC=E3=83=88=E3=83=A9=E3=83=AB=E3=81=AAother=E8=A6=81?= =?UTF-8?q?=E7=B4=A0=EF=BC=88jpcrp=5Fcor/jpdei=5Fcor=EF=BC=89=E3=82=92?= =?UTF-8?q?=E4=BF=9D=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex PRレビュー指摘: --non-consolidated時にjpcrp_cor:NumberOfIssuedShares等の 連結・個別に依存しない共通項目が除外されていた。other行をニュートラル(jpcrp_cor/jpdei_cor)と IFRS連結(jpigp_cor等)に分離し、前者は常に含める。 --- internal/financial/parser.go | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/internal/financial/parser.go b/internal/financial/parser.go index e138413..a5ce035 100644 --- a/internal/financial/parser.go +++ b/internal/financial/parser.go @@ -317,14 +317,22 @@ func buildResult(rows []parsedRow, opts ParseOpts, warnings []string) (*ParseRes // selectConsolidation chooses which set of rows to use for a statement type. func selectConsolidation(sr *consolidationGroup, st StatementType, opts ParseOpts, warnings *[]string) ([]parsedRow, bool) { - // "other" rows (連結・個別=その他) are IFRS consolidated data, so include - // them with consolidated but NOT with explicit non-consolidated. - consRows := append(sr.consolidated, sr.other...) - nonConsRows := sr.nonConsolidated // exclude "other" — it is IFRS consolidated - - // IFRS consolidated data comes through as "other" (連結・個別=その他), - // so include "other" rows when checking for consolidated availability. - hasCons := len(sr.consolidated) > 0 || len(sr.other) > 0 + // Split "other" rows into neutral (jpcrp_cor/jpdei_cor — applies to both modes) + // and IFRS-consolidated (jpigp_cor, company-specific — consolidated only). + var neutralOther, ifrsOther []parsedRow + for _, r := range sr.other { + prefix := elementPrefix(r.elementID) + if prefix == "jpcrp_cor" || prefix == "jpdei_cor" { + neutralOther = append(neutralOther, r) + } else { + ifrsOther = append(ifrsOther, r) + } + } + + consRows := append(append(sr.consolidated, ifrsOther...), neutralOther...) + nonConsRows := append(sr.nonConsolidated, neutralOther...) // include neutral, exclude IFRS consolidated + + hasCons := len(sr.consolidated) > 0 || len(ifrsOther) > 0 hasNonCons := len(sr.nonConsolidated) > 0 if opts.Consolidated != nil { From c0e2a7ee915dd6b2d8d9601c47c05f6f1d602dd0 Mon Sep 17 00:00:00 2001 From: beatinaniwa Date: Wed, 1 Apr 2026 15:07:45 +0900 Subject: [PATCH 3/6] =?UTF-8?q?fix(parser):=20AccountingStd=E3=82=92?= =?UTF-8?q?=E9=81=B8=E6=8A=9E=E3=81=95=E3=82=8C=E3=81=9F=E8=A1=8C=E3=81=AE?= =?UTF-8?q?=E3=81=BF=E3=81=8B=E3=82=89=E6=A4=9C=E5=87=BA=E3=81=99=E3=82=8B?= =?UTF-8?q?=E3=82=88=E3=81=86=E3=81=AB=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 混合ファイリング(連結IFRS+非連結JP-GAAP)で非連結を指定した場合、 全行から検出していたため誤ってIFRSと判定されていた問題を修正。 selections(選択済み行)からのみ会計基準を検出するよう変更。 --- internal/financial/parser.go | 8 +++++-- internal/financial/parser_test.go | 40 +++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/internal/financial/parser.go b/internal/financial/parser.go index a5ce035..13c1ff4 100644 --- a/internal/financial/parser.go +++ b/internal/financial/parser.go @@ -288,8 +288,12 @@ func buildResult(rows []parsedRow, opts ParseOpts, warnings []string) (*ParseRes } } - // Detect accounting standard - acctStd := detectAccountingStandard(rows) + // Detect accounting standard from selected rows only + var selectedRows []parsedRow + for _, sel := range selections { + selectedRows = append(selectedRows, sel.rows...) + } + acctStd := detectAccountingStandard(selectedRows) // Build statements summary := make(Summary) diff --git a/internal/financial/parser_test.go b/internal/financial/parser_test.go index f4f45e9..f946008 100644 --- a/internal/financial/parser_test.go +++ b/internal/financial/parser_test.go @@ -899,6 +899,46 @@ func TestParse_PriorPeriodIncluded(t *testing.T) { } } +// --- AccountingStd computed from selected rows only --- + +func TestParse_AccountingStdFromSelectedRows(t *testing.T) { + // Mixed filing: consolidated IFRS rows + non-consolidated JP-GAAP rows. + // When requesting non-consolidated, AccountingStd should be "jpgaap" + // because only JP-GAAP rows are selected. + nonCons := false + file := makeCSVFile( + "jpcrp030000-asr-001_E99999-000_2025-03-31_01_2025-06-20.csv", + standardHeaders(), + [][]string{ + // Consolidated IFRS rows (should be excluded when non-consolidated is requested) + makeRow("jpigp_cor:RevenueIFRS", "売上収益", "CurrentYearDuration", "当期", "連結", "期間", "JPY", "円", "5000000"), + makeRow("jpigp_cor:AssetsIFRS", "資産合計", "CurrentYearInstant", "当期", "連結", "時点", "JPY", "円", "10000000"), + makeRow("jpigp_cor:OperatingProfitLossIFRS", "営業利益", "CurrentYearDuration", "当期", "連結", "期間", "JPY", "円", "1000000"), + // Non-consolidated JP-GAAP rows + makeRow("jppfs_cor:NetSales", "売上高", "CurrentYearDuration", "当期", "個別", "期間", "JPY", "円", "3000000"), + makeRow("jppfs_cor:TotalAssets", "総資産", "CurrentYearInstant", "当期", "個別", "時点", "JPY", "円", "8000000"), + }, + ) + + csvResult := &extract.CSVDataResult{Files: []extract.CSVFile{file}} + result, err := Parse(csvResult, ParseOpts{Consolidated: &nonCons}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + // Result-level AccountingStd should reflect the selected (non-consolidated) rows + if result.AccountingStd != "jpgaap" { + t.Errorf("AccountingStd = %q, want %q", result.AccountingStd, "jpgaap") + } + + // Each statement should also have jpgaap + for _, stmt := range result.Statements { + if stmt.AccountingStd != "jpgaap" { + t.Errorf("statement %q AccountingStd = %q, want %q", stmt.Type, stmt.AccountingStd, "jpgaap") + } + } +} + // --- Helper assertion --- func assertSummaryValue(t *testing.T, s Summary, key string, want float64) { From d6742bea12b8f00fb67c91b3c0a4b2604c407bba Mon Sep 17 00:00:00 2001 From: beatinaniwa Date: Wed, 1 Apr 2026 15:17:46 +0900 Subject: [PATCH 4/6] =?UTF-8?q?fix(service):=20=E6=9C=89=E5=A0=B1=E6=A4=9C?= =?UTF-8?q?=E7=B4=A2=E3=82=92=E6=9C=80=E6=96=B0=E6=97=A5=E4=BB=98=E3=81=8B?= =?UTF-8?q?=E3=82=89=E9=80=86=E9=A0=86=E3=82=B9=E3=82=AD=E3=83=A3=E3=83=B3?= =?UTF-8?q?+=E6=97=A9=E6=9C=9F=E7=B5=82=E4=BA=86=E3=81=AB=E5=A4=89?= =?UTF-8?q?=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GetCompanyFinancialsが全期間を日単位で順スキャン(periods*400日分)して 大量のAPIコールを発生させていた問題を修正。 ListOptionsにReverseフラグを追加し、最新日付から逆順にスキャンして 必要数の有報を見つけた時点で早期終了するよう変更。 --- internal/service/company.go | 2 ++ internal/service/document.go | 7 ++++++ internal/service/document_test.go | 41 +++++++++++++++++++++++++++++++ internal/service/financial.go | 9 ++++--- 4 files changed, 55 insertions(+), 4 deletions(-) diff --git a/internal/service/company.go b/internal/service/company.go index ad281de..f69c07b 100644 --- a/internal/service/company.go +++ b/internal/service/company.go @@ -28,6 +28,7 @@ type FilingsOptions struct { To string RateLimit time.Duration Limit int + Reverse bool } // NewCompanyService creates a new CompanyService. @@ -79,6 +80,7 @@ func (s *CompanyService) Filings(ctx context.Context, code string, opts FilingsO DocType: opts.DocType, RateLimit: opts.RateLimit, Limit: opts.Limit, + Reverse: opts.Reverse, }) } diff --git a/internal/service/document.go b/internal/service/document.go index bdb18e7..f531e91 100644 --- a/internal/service/document.go +++ b/internal/service/document.go @@ -30,6 +30,7 @@ type ListOptions struct { FilerName string RateLimit time.Duration Limit int + Reverse bool } // NewDocumentService creates a new DocumentService. @@ -67,6 +68,12 @@ func (s *DocumentService) listDateRange(ctx context.Context, opts ListOptions) ( return nil, err } + if opts.Reverse { + for i, j := 0, len(dates)-1; i < j; i, j = i+1, j-1 { + dates[i], dates[j] = dates[j], dates[i] + } + } + var allResults []DocumentInfo var warnings []string var lastErr error diff --git a/internal/service/document_test.go b/internal/service/document_test.go index c52a7ab..54bad96 100644 --- a/internal/service/document_test.go +++ b/internal/service/document_test.go @@ -199,3 +199,44 @@ func TestDocumentService_List_ProgressOutput(t *testing.T) { t.Error("expected progress output on stderr, got none") } } + +func TestDocumentService_List_Reverse(t *testing.T) { + // 3-day range, 1 result per day. Reverse=true + Limit=2 should: + // - Start from the latest date (2025-06-20) + // - Return the 2 most recent results + // - Stop early (not call all 3 days) + var requestedDates []string + client, _ := setupMockServer(t, func(w http.ResponseWriter, r *http.Request) { + date := r.URL.Query().Get("date") + requestedDates = append(requestedDates, date) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _, _ = w.Write([]byte(`{"metadata":{"status":"200","message":"OK","parameter":{"date":"` + date + `","type":"2"},"resultset":{"count":1},"processDateTime":"2025-06-20 13:01"},"results":[{"seqNumber":1,"docID":"D` + date + `","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{ + From: "2025-06-18", + To: "2025-06-20", + RateLimit: time.Millisecond, + Limit: 2, + Reverse: true, + }) + if err != nil { + t.Fatalf("List() error = %v", err) + } + + // Should have 2 results + if len(result.Results) != 2 { + t.Errorf("len(Results) = %d, want 2", len(result.Results)) + } + + // Should have started from the latest date + if len(requestedDates) > 0 && requestedDates[0] != "2025-06-20" { + t.Errorf("first requested date = %q, want %q", requestedDates[0], "2025-06-20") + } + + // Should stop early — not call all 3 days + if len(requestedDates) > 2 { + t.Errorf("requested %d dates, want <=2 (should stop early)", len(requestedDates)) + } +} diff --git a/internal/service/financial.go b/internal/service/financial.go index 935c879..982baf8 100644 --- a/internal/service/financial.go +++ b/internal/service/financial.go @@ -125,15 +125,16 @@ func (s *FinancialService) GetCompanyFinancials(ctx context.Context, companySvc From: from, To: to, RateLimit: rateLimit, - Limit: 0, // fetch all, then take latest N + Limit: periods, + Reverse: true, // scan from newest to oldest, stop once enough found }) if err != nil { return nil, err } - // Take the latest N filings (results are in ascending date order) - if len(filingsResult.Results) > periods { - filingsResult.Results = filingsResult.Results[len(filingsResult.Results)-periods:] + // Reverse results back to chronological order (oldest first) + for i, j := 0, len(filingsResult.Results)-1; i < j; i, j = i+1, j-1 { + filingsResult.Results[i], filingsResult.Results[j] = filingsResult.Results[j], filingsResult.Results[i] } if len(filingsResult.Results) == 0 { From 9b512b6be9ba74e4a288b1930965f7705050c183 Mon Sep 17 00:00:00 2001 From: beatinaniwa Date: Wed, 1 Apr 2026 15:35:15 +0900 Subject: [PATCH 5/6] =?UTF-8?q?fix(parser):=20=E6=98=8E=E7=A4=BA=E7=9A=84?= =?UTF-8?q?=E9=9D=9E=E9=80=A3=E7=B5=90=E3=83=A2=E3=83=BC=E3=83=89=E3=81=A7?= =?UTF-8?q?neutral-only=E8=A1=8C=E3=81=8C=E6=B6=88=E3=81=88=E3=82=8B?= =?UTF-8?q?=E5=95=8F=E9=A1=8C=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hasNonConsがfalseでもneutralOther(jpcrp_cor/jpdei_cor)に 行がある場合にnonConsRowsを返すよう条件を拡張。 filing-date項目など非連結行なしでneutral行のみのstatementが 消失していた問題を修正。 --- internal/financial/parser.go | 2 +- internal/financial/parser_test.go | 29 +++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/internal/financial/parser.go b/internal/financial/parser.go index 13c1ff4..9e61c47 100644 --- a/internal/financial/parser.go +++ b/internal/financial/parser.go @@ -354,7 +354,7 @@ func selectConsolidation(sr *consolidationGroup, st StatementType, opts ParseOpt return nil, false } // Explicit non-consolidated — do not include "other" (IFRS consolidated) - if hasNonCons { + if hasNonCons || len(neutralOther) > 0 { return nonConsRows, false } if hasCons { diff --git a/internal/financial/parser_test.go b/internal/financial/parser_test.go index f946008..a0042c3 100644 --- a/internal/financial/parser_test.go +++ b/internal/financial/parser_test.go @@ -939,6 +939,35 @@ func TestParse_AccountingStdFromSelectedRows(t *testing.T) { } } +// --- Neutral-only rows preserved in explicit non-consolidated mode --- + +func TestParse_NeutralOnlyRows_NonConsolidated(t *testing.T) { + // When only neutral "other" rows exist (jpcrp_cor) with no explicit + // non-consolidated rows, explicit non-consolidated mode should still + // return those neutral rows rather than dropping the statement. + nonCons := false + file := makeCSVFile( + "jpcrp030000-asr-001_E99999-000_2025-03-31_01_2025-06-20.csv", + standardHeaders(), + [][]string{ + // Neutral rows — tagged as "その他" in 連結・個別 column + makeRow("jpcrp_cor:NumberOfIssuedSharesAsOfFilingDateTotal", "発行済株式総数", "FilingDateInstant", "当期", "その他", "時点", "shares", "株", "1000000"), + makeRow("jpcrp_cor:DividendPerShareSummary", "1株当たり配当額", "CurrentYearDuration", "当期", "その他", "期間", "JPYPerShares", "円", "50"), + }, + ) + + csvResult := &extract.CSVDataResult{Files: []extract.CSVFile{file}} + result, err := Parse(csvResult, ParseOpts{Consolidated: &nonCons}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + // Should have at least one statement with the neutral rows + if len(result.Statements) == 0 { + t.Fatal("expected at least one statement with neutral rows, got 0") + } +} + // --- Helper assertion --- func assertSummaryValue(t *testing.T, s Summary, key string, want float64) { From 4434d669669e13308390291b36b5202598cb5dbf Mon Sep 17 00:00:00 2001 From: beatinaniwa Date: Wed, 1 Apr 2026 15:45:14 +0900 Subject: [PATCH 6/6] =?UTF-8?q?fix(parser):=20consolidated+neutral?= =?UTF-8?q?=E8=A1=8C=E5=AD=98=E5=9C=A8=E6=99=82=E3=81=ABnon-consolidated?= =?UTF-8?q?=E3=83=95=E3=82=A9=E3=83=BC=EF=BF=BD=EF=BF=BD=EF=BF=BD=E3=83=90?= =?UTF-8?q?=E3=83=83=E3=82=AF=E3=81=8C=E6=AD=A3=E3=81=97=E3=81=8F=E5=8B=95?= =?UTF-8?q?=E4=BD=9C=E3=81=99=E3=82=8B=E3=82=88=E3=81=86=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hasNonConsとlen(neutralOther)の条件を分離し、 consolidated行がある場合は必ずconsolidatedフォールバックを使用するよう修正。 neutral-onlyの場合のみneutral行を返す。 --- internal/financial/parser.go | 6 +++- internal/financial/parser_test.go | 47 +++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/internal/financial/parser.go b/internal/financial/parser.go index 9e61c47..3627929 100644 --- a/internal/financial/parser.go +++ b/internal/financial/parser.go @@ -354,13 +354,17 @@ func selectConsolidation(sr *consolidationGroup, st StatementType, opts ParseOpt return nil, false } // Explicit non-consolidated — do not include "other" (IFRS consolidated) - if hasNonCons || len(neutralOther) > 0 { + if hasNonCons { return nonConsRows, false } if hasCons { *warnings = append(*warnings, fmt.Sprintf("statement %s: non_consolidated data requested but not available, using consolidated as fallback", st)) return consRows, true } + // Neither consolidated nor non-consolidated, but neutral rows exist + if len(neutralOther) > 0 { + return nonConsRows, false + } return nil, false } diff --git a/internal/financial/parser_test.go b/internal/financial/parser_test.go index a0042c3..a8a0082 100644 --- a/internal/financial/parser_test.go +++ b/internal/financial/parser_test.go @@ -968,6 +968,53 @@ func TestParse_NeutralOnlyRows_NonConsolidated(t *testing.T) { } } +// --- Consolidated + neutral rows: non-consolidated fallback uses consolidated --- + +func TestParse_ConsolidatedPlusNeutral_NonConsolidatedFallback(t *testing.T) { + // When consolidated rows + neutral rows exist but NO non-consolidated rows, + // and non-consolidated is explicitly requested, it should fallback to + // consolidated data (which includes neutral) with a warning. + // It should NOT return only neutral rows. + nonCons := false + file := makeCSVFile( + "jpcrp030000-asr-001_E99999-000_2025-03-31_01_2025-06-20.csv", + standardHeaders(), + [][]string{ + // Consolidated rows + makeRow("jppfs_cor:NetSales", "売上高", "CurrentYearDuration", "当期", "連結", "期間", "JPY", "円", "10000000"), + makeRow("jppfs_cor:TotalAssets", "総資産", "CurrentYearInstant", "当期", "連結", "時点", "JPY", "円", "50000000"), + // Neutral rows + makeRow("jpcrp_cor:NumberOfIssuedSharesAsOfFilingDateTotal", "発行済株式総数", "FilingDateInstant", "当期", "その他", "時点", "shares", "株", "1000000"), + }, + ) + + csvResult := &extract.CSVDataResult{Files: []extract.CSVFile{file}} + result, err := Parse(csvResult, ParseOpts{Consolidated: &nonCons}) + if err != nil { + t.Fatalf("Parse() error = %v", err) + } + + // Should have consolidated fallback with summary keys + if result.Summary["revenue"] == nil { + t.Error("Summary[revenue] should exist (consolidated fallback), got nil") + } + if result.Summary["total_assets"] == nil { + t.Error("Summary[total_assets] should exist (consolidated fallback), got nil") + } + + // Should have a warning about fallback + hasWarning := false + for _, w := range result.Warnings { + if strings.Contains(w, "non_consolidated") && strings.Contains(w, "fallback") { + hasWarning = true + break + } + } + if !hasWarning { + t.Error("expected warning about non_consolidated fallback, got none") + } +} + // --- Helper assertion --- func assertSummaryValue(t *testing.T, s Summary, key string, want float64) {