fix(ownership): accept the register's new CONTROLLER_OR_AFFILIATE column

IDX's above-1% register as of 30 Oct 2026 (peng-2026-10-00029) inserts a
CONTROLLER_OR_AFFILIATE column after INVESTOR_CLASSIFICATION. The header
check rejected any column it did not know, so `ownership import` failed
with PARSEERROR and `releases`/`ticker` had nothing to show.

Accept that one column by name and skip it; any other unknown column is
still rejected, so the above-5% and investor-type workbooks stay out.
Storing the flag is a follow-up (ROADMAP).

The fixture is an 11-row excerpt of the real October workbook (disclaimer
rows, header, BBCA and ZYRX). The full file imports 7,149 rows across 961
tickers; live ownership smoke: 4 passed, 0 failed, no 403.

Claude-Session: https://claude.ai/code/session_01RqPPVeVDrnLoP7NUZmLRxY
This commit is contained in:
Rasyidan Akbar F. 2026-11-03 15:44:50 +07:00
commit 1cd8a4b191
5 changed files with 107 additions and 4 deletions

View file

@ -3,9 +3,10 @@
//! Since June 2026 IDX publishes the monthly above-1% holder register as an
//! XLSX file on the "Data Kepemilikan Saham" page instead of a PDF
//! announcement. The workbook has one sheet: a disclaimer block, a `[PUBLIC]`
//! marker, then a fixed 12-column table. Only that exact header is accepted,
//! so the daily above-5% workbook and the investor-type / classification
//! workbooks published on the same page are rejected instead of mis-imported.
//! marker, then a 12-column table (13 since October 2026, see
//! `IGNORED_HEADER`). Only those columns are accepted, so the daily above-5%
//! workbook and the investor-type / classification workbooks published on the
//! same page are rejected instead of mis-imported.
use std::collections::HashMap;
use std::fs;
@ -35,6 +36,12 @@ const EXPECTED_HEADER: [&str; 12] = [
"PERCENTAGE",
];
/// Columns IDX has added to the register that the import does not store yet.
/// The October 2026 register (as of 30 Oct) inserted CONTROLLER_OR_AFFILIATE
/// (`CONTROLLER`, `AFFILIATE OF THE CONTROLLER`, or blank) after
/// INVESTOR_CLASSIFICATION. Any other unknown column still fails the header.
const IGNORED_HEADER: [&str; 1] = ["CONTROLLER_OR_AFFILIATE"];
/// Rows above this line are disclaimer text; the header must appear before it.
const MAX_HEADER_SEARCH_ROWS: usize = 50;
@ -400,6 +407,9 @@ fn header_columns(row: &SheetRow) -> Option<HashMap<&'static str, String>> {
if label == "INVESTOR_TYPE" {
label = "INVESTOR_CLASSIFICATION".to_string();
}
if IGNORED_HEADER.contains(&label.as_str()) {
continue;
}
let expected = EXPECTED_HEADER.iter().find(|name| **name == label)?;
found.insert(expected, column.clone());
}
@ -505,6 +515,43 @@ mod tests {
);
}
#[test]
fn parses_october_2026_register_with_controller_column() {
let drafts = parse_above1_xlsx_bytes(include_bytes!(
"../../tests/fixtures/ksei_above1_20261030_excerpt.xlsx"
))
.expect("October 2026 register with CONTROLLER_OR_AFFILIATE parses");
assert_eq!(drafts.len(), 11);
assert!(
drafts
.iter()
.all(|d| d.report_date == NaiveDate::from_ymd_opt(2026, 10, 30).unwrap())
);
// The new column sits at F, so every later column moved one to the
// right; fields are still read by name.
let timothy = drafts
.iter()
.find(|d| d.ticker_code == "ZYRX" && d.raw_investor_name == "TIMOTHY SIDDIK SHU")
.expect("ZYRX controller present");
assert_eq!(timothy.total_shares, 752_076_700);
assert_eq!(timothy.percentage_bps, 5641);
assert_eq!(timothy.nationality.as_deref(), Some("INDONESIAN"));
assert!(timothy.locality.is_some());
}
#[test]
fn controller_column_is_optional_but_other_extras_are_not() {
let mut labels = EXPECTED_HEADER.to_vec();
labels.insert(5, "CONTROLLER_OR_AFFILIATE");
let columns = header_columns(&header_row(&labels)).expect("13-column header accepted");
assert_eq!(columns["LOCAL_FOREIGN"], "G");
assert_eq!(columns["PERCENTAGE"], "M");
labels[5] = "PENGENDALI";
assert!(header_columns(&header_row(&labels)).is_none());
}
fn header_row(labels: &[&str]) -> SheetRow {
sheet_row(1, labels)
}

Binary file not shown.