fix: handle missing scrip column in April 2026 KSEI PDF

The April 2026 IDX ownership PDF omits the zero-valued scrip column for
some holders, causing the parser to leave holdings_scripless/scrip as
empty strings. This broke normalize_ksei_row which called parse_id_number
on empty input.

Two-layer fix:
- Parser: pop_numeric_tail handles 2-column (missing scrip) rows by
  defaulting scrip to "0"
- Normalizer: parse_id_number_or_zero backstop treats empty component
  share fields as 0 while still requiring total_shares

Also includes AGENTS.md refresh, formatting cleanup (rustfmt), and
version bump to 0.2.2.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rasyidan Akbar F. 2026-04-06 19:15:19 +07:00
commit 04eaa75882
10 changed files with 286 additions and 144 deletions

View file

@ -355,23 +355,23 @@ fn parse_row_segments(texts: &[String]) -> KseiRawRow {
{
row.percentage = remaining.pop().unwrap_or_default();
}
if remaining
.last()
.is_some_and(|segment| is_id_number_like(segment))
{
row.total_holding_shares = remaining.pop().unwrap_or_default();
}
if remaining
.last()
.is_some_and(|segment| is_id_number_like(segment))
{
row.holdings_scrip = remaining.pop().unwrap_or_default();
}
if remaining
.last()
.is_some_and(|segment| is_id_number_like(segment))
{
row.holdings_scripless = remaining.pop().unwrap_or_default();
let numeric_tail = pop_numeric_tail(&mut remaining);
match numeric_tail.as_slice() {
[scripless, scrip, total] => {
row.holdings_scripless = scripless.clone();
row.holdings_scrip = scrip.clone();
row.total_holding_shares = total.clone();
}
// Some PDFs omit the zero-valued scrip column entirely.
[scripless, total] => {
row.holdings_scripless = scripless.clone();
row.holdings_scrip = "0".to_string();
row.total_holding_shares = total.clone();
}
[total] => {
row.total_holding_shares = total.clone();
}
_ => {}
}
let mut geo_fields = Vec::new();
@ -439,6 +439,19 @@ fn is_share_code_like(value: &str) -> bool {
.all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit())
}
fn pop_numeric_tail(remaining: &mut Vec<String>) -> Vec<String> {
let mut numeric_tail = Vec::new();
while numeric_tail.len() < 3
&& remaining
.last()
.is_some_and(|segment| is_id_number_like(segment))
{
numeric_tail.push(remaining.pop().unwrap_or_default());
}
numeric_tail.reverse();
numeric_tail
}
fn is_id_number_like(value: &str) -> bool {
let trimmed = value.trim();
!trimmed.is_empty()
@ -554,8 +567,11 @@ fn is_percentage_like(s: &str) -> bool {
mod tests {
use std::path::Path;
use crate::ownership::entities::normalize_ksei_row;
use super::{
OwnershipPdfSchema, check_mutool, classify_stext_xml, parse_ksei_pdf, parse_stext_xml,
OwnershipPdfSchema, check_mutool, classify_stext_xml, parse_ksei_pdf, parse_row_segments,
parse_stext_xml,
};
#[test]
@ -608,6 +624,51 @@ mod tests {
assert_eq!(row.percentage, "41,10");
}
#[test]
fn test_parse_row_segments_missing_scrip_defaults_to_zero() {
let texts = vec![
"27-Apr-2026 AADI".to_string(),
"ADARO ANDALAN INDONESIA Tbk".to_string(),
"ADARO STRATEGIC INVESTMENTS".to_string(),
"CP".to_string(),
"D".to_string(),
"INDONESIA".to_string(),
"3.200.142.830".to_string(),
"3.200.142.830".to_string(),
"66,18".to_string(),
];
let row = parse_row_segments(&texts);
assert_eq!(row.date, "27-Apr-2026");
assert_eq!(row.share_code, "AADI");
assert_eq!(row.holdings_scripless, "3.200.142.830");
assert_eq!(row.holdings_scrip, "0");
assert_eq!(row.total_holding_shares, "3.200.142.830");
assert_eq!(row.percentage, "66,18");
}
#[test]
fn test_parse_row_segments_missing_scrip_normalizes_without_error() {
let texts = vec![
"27-Apr-2026 AADI".to_string(),
"ADARO ANDALAN INDONESIA Tbk".to_string(),
"ADARO STRATEGIC INVESTMENTS".to_string(),
"CP".to_string(),
"D".to_string(),
"INDONESIA".to_string(),
"3.200.142.830".to_string(),
"3.200.142.830".to_string(),
"66,18".to_string(),
];
let row = parse_row_segments(&texts);
let draft = normalize_ksei_row(&row).expect("missing scrip column should normalize");
assert_eq!(draft.holdings_scripless, 3_200_142_830);
assert_eq!(draft.holdings_scrip, 0);
assert_eq!(draft.total_shares, 3_200_142_830);
}
#[test]
fn classify_stext_xml_detects_supported_holder_register_schema() {
let xml = include_str!("../../tests/fixtures/ksei_above1_stext_excerpt.xml");