import { readFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const scriptDirectory = dirname(fileURLToPath(import.meta.url)); const projectRoot = resolve(scriptDirectory, "../.."); const ownerFile = resolve(projectRoot, "业主账户.md"); const usageFile = resolve(projectRoot, "使用记录.md"); const ROOM_TYPES = [ "RM1", "RM2", "RM3", "RM4", "UG1", "UG2", "SU1", "SU2", "SU6", "SU3", "AC2" ]; const ROOM_TYPE_SET = new Set(ROOM_TYPES); const TIER = new Map([ ["RM1", 1], ["RM2", 1], ["RM3", 1], ["RM4", 1], ["UG1", 1], ["UG2", 1], ["SU1", 2], ["SU2", 2], ["SU6", 2], ["SU3", 3] ]); function markdownCells(line) { const value = line.trim(); if (!value.startsWith("|") || !value.endsWith("|")) return null; return value .slice(1, -1) .split("|") .map(cell => cell.trim()); } function isSeparatorRow(cells) { return cells?.length > 0 && cells.every(cell => /^:?-{3,}:?$/.test(cell)); } function tableRows(lines, headerIndex, endIndex) { const rows = []; for (let index = headerIndex + 1; index < endIndex; index += 1) { if (!lines[index].trim()) { if (rows.length) break; continue; } const cells = markdownCells(lines[index]); if (!cells) { if (rows.length) break; continue; } if (isSeparatorRow(cells)) continue; rows.push({ cells, line: index + 1 }); } return rows; } function integerValue(value) { return /^-?\d+$/.test(value) ? Number(value) : null; } function roomNumbers(value) { return [...new Set(value.match(/\b\d{4}\b/g) ?? [])]; } function normalizedName(value) { return value .normalize("NFKC") .toUpperCase() .replace(/[^A-Z0-9\u0E00-\u0E7F]+/g, ""); } function sameValues(left, right) { return [...left].sort().join("|") === [...right].sort().join("|"); } function validIsoDate(value) { if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; const timestamp = Date.parse(`${value}T00:00:00Z`); return Number.isFinite(timestamp) && new Date(timestamp).toISOString().slice(0, 10) === value; } function dateDifference(checkIn, checkOut) { if (!validIsoDate(checkIn) || !validIsoDate(checkOut)) return null; return Math.round( (Date.parse(`${checkOut}T00:00:00Z`) - Date.parse(`${checkIn}T00:00:00Z`)) / 86_400_000 ); } function grouped(items, keyOf) { const groups = new Map(); for (const item of items) { const key = keyOf(item); const values = groups.get(key) ?? []; values.push(item); groups.set(key, values); } return groups; } function duplicateSummary(items, keyOf, project) { return [...grouped(items, keyOf)] .filter(([, values]) => values.length > 1) .map(([key, values]) => ({ key, count: values.length, records: values.map(project) })); } function explicitRoomTypeCodes(value) { const upper = value.toUpperCase(); return ROOM_TYPES.filter(code => { const expression = new RegExp(`(^|[^A-Z0-9])${code}([^A-Z0-9]|$)`); return expression.test(upper); }); } function classifyUsedRoomType(value) { const normalized = value .replace(//gi, " ") .replace(/\s+/g, " ") .trim() .toUpperCase(); const explicitCodes = explicitRoomTypeCodes(normalized); if (explicitCodes.length === 1) { const code = explicitCodes[0]; return { kind: "canonical", code, tier: TIER.get(code) ?? null, candidates: [code], normalized }; } if (/\bFAM(?:ILY)?\b/.test(normalized)) { return { kind: "alias", code: null, tier: 3, candidates: ["SU3"], normalized }; } if (normalized.includes("JUNIOR SUITE")) { return { kind: "alias", code: null, tier: 2, candidates: ["SU1", "SU2", "SU6"], normalized }; } if (normalized.includes("SUPERIOR ROOM")) { return { kind: "alias", code: null, tier: 1, candidates: ["RM1", "RM2", "RM3", "RM4"], normalized }; } if (normalized.includes("DELUXE ROOM")) { return { kind: "alias", code: null, tier: 1, candidates: ["UG1", "UG2"], normalized }; } return { kind: "unknown", code: null, tier: null, candidates: [], normalized }; } function automaticMultiplier(purchasedCodes, usedClassification, impliedEffectiveRatio) { if ( purchasedCodes.includes("AC2") || usedClassification.code === "AC2" ) { return Number.isInteger(impliedEffectiveRatio) && impliedEffectiveRatio >= 1 && impliedEffectiveRatio <= 3 ? impliedEffectiveRatio : null; } const purchasedTiers = purchasedCodes.map(code => TIER.get(code)); if ( !purchasedTiers.length || purchasedTiers.some(tier => !tier) || new Set(purchasedTiers).size !== 1 || !usedClassification.tier ) return null; return Math.max(1, usedClassification.tier - purchasedTiers[0] + 1); } const [ownerText, usageText] = await Promise.all([ readFile(ownerFile, "utf8"), readFile(usageFile, "utf8") ]); const ownerLines = ownerText.split(/\r?\n/); const usageLines = usageText.split(/\r?\n/); const ownerHeaderIndex = ownerLines.findIndex(line => line.startsWith("| Name | Room No. | Room Type | Unit No. | Member No. |") ); if (ownerHeaderIndex < 0) throw new Error("OWNER_HEADER_NOT_FOUND"); const ownerRows = tableRows(ownerLines, ownerHeaderIndex, ownerLines.length) .map(({ cells, line }) => { if (cells.length !== 5) { return { line, malformed: true, cells }; } const [name, roomNo, roomType, unitNo, memberNo] = cells; return { line, malformed: false, name, roomNo, roomType, unitNo, memberNo }; }); const validOwnerRows = ownerRows.filter(row => !row.malformed); const ownersByRoom = new Map(validOwnerRows.map(owner => [owner.roomNo, owner])); const sectionStarts = usageLines .map((line, index) => { const match = line.match(/^## Room No\. (.+)$/); return match ? { index, heading: match[1].trim() } : null; }) .filter(Boolean); const sections = sectionStarts.map((start, sectionIndex) => { const end = sectionStarts[sectionIndex + 1]?.index ?? usageLines.length; const metadataHeaderIndex = usageLines.findIndex( (line, index) => index > start.index && index < end && line.startsWith("| Owenr Name | Room No. | PURCHASED ROOM TYPE |"), ); const usageHeaderIndex = usageLines.findIndex( (line, index) => index > start.index && index < end && line.startsWith("| Confirmation No. | Check -IN | Check-OUT |"), ); const metadataRows = metadataHeaderIndex < 0 ? [] : tableRows(usageLines, metadataHeaderIndex, usageHeaderIndex < 0 ? end : usageHeaderIndex); const usageRows = usageHeaderIndex < 0 ? [] : tableRows(usageLines, usageHeaderIndex, end); const metadata = metadataRows[0]?.cells.length === 4 ? { ownerName: metadataRows[0].cells[0], roomNoRaw: metadataRows[0].cells[1], purchasedTypeRaw: metadataRows[0].cells[2], transferOnRaw: metadataRows[0].cells[3], line: metadataRows[0].line } : null; return { heading: start.heading, line: start.index + 1, headingRooms: roomNumbers(start.heading), metadata, metadataRooms: metadata ? roomNumbers(metadata.roomNoRaw) : [], usageRows: usageRows.map(({ cells, line }) => { if (cells.length !== 9) return { line, malformed: true, cells }; const [ confirmationNo, checkIn, checkOut, nightRaw, roomRaw, useRaw, balanceRaw, usedRoomTypeRaw, remark ] = cells; const night = integerValue(nightRaw); const room = integerValue(roomRaw); const use = integerValue(useRaw); const balance = integerValue(balanceRaw); const denominator = night && room ? night * room : null; const impliedEffectiveRatio = denominator && use !== null && use % denominator === 0 ? use / denominator : null; const usedClassification = classifyUsedRoomType(usedRoomTypeRaw); const remarkRoomNumbers = roomNumbers(remark); const remarkOwners = remarkRoomNumbers .map(roomNo => ownersByRoom.get(roomNo)) .filter(Boolean); const remarkRoomTypes = [...new Set(remarkOwners.map(owner => owner.roomType))]; const remarkResolutionComplete = remarkRoomNumbers.length > 0 && remarkOwners.length === remarkRoomNumbers.length && remarkRoomTypes.length === 1; const resolvedUsedClassification = remarkResolutionComplete ? { kind: "remark", code: remarkRoomTypes[0], tier: TIER.get(remarkRoomTypes[0]) ?? null, candidates: [remarkRoomTypes[0]], normalized: usedClassification.normalized } : usedClassification; return { line, malformed: false, confirmationNo, checkIn, checkOut, nightRaw, roomRaw, useRaw, balanceRaw, night, room, use, balance, usedRoomTypeRaw, usedClassification, resolvedUsedClassification, usedTypeConflict: Boolean( usedClassification.code && resolvedUsedClassification.code && usedClassification.code !== resolvedUsedClassification.code ), remarkRoomNumbers, remarkRoomsFound: remarkOwners.length, remarkRoomTypes, remark, impliedEffectiveRatio }; }) }; }); const issues = []; function issue(code, severity, location, detail) { issues.push({ code, severity, ...location, detail }); } for (const row of ownerRows) { if (row.malformed) { issue("OWNER_ROW_MALFORMED", "blocker", { file: "业主账户.md", line: row.line }, `Expected 5 columns, found ${row.cells.length}`); continue; } for (const field of ["name", "roomNo", "roomType", "unitNo", "memberNo"]) { if (!row[field]) { issue("OWNER_REQUIRED_VALUE_MISSING", "blocker", { file: "业主账户.md", line: row.line, roomNo: row.roomNo }, `Missing ${field}`); } } if (!/^\d{4}$/.test(row.roomNo)) { issue("OWNER_ROOM_FORMAT_INVALID", "blocker", { file: "业主账户.md", line: row.line, roomNo: row.roomNo }, "Room No. is not exactly four digits"); } if (!ROOM_TYPE_SET.has(row.roomType)) { issue("OWNER_ROOM_TYPE_UNKNOWN", "blocker", { file: "业主账户.md", line: row.line, roomNo: row.roomNo }, `Unknown purchased room type ${row.roomType}`); } } const ownerRoomDuplicates = duplicateSummary( validOwnerRows, owner => owner.roomNo, owner => ({ line: owner.line, name: owner.name }) ); for (const duplicate of ownerRoomDuplicates) { issue("OWNER_ROOM_DUPLICATE", "blocker", { file: "业主账户.md", roomNo: duplicate.key }, `${duplicate.count} owner rows use this room number`); } const allUsageRows = []; for (const section of sections) { if (!section.metadata) { issue("SECTION_METADATA_MISSING", "blocker", { file: "使用记录.md", line: section.line, section: section.heading }, "Owner metadata row is missing or malformed"); } if (!section.usageRows.length) { issue("SECTION_USAGE_MISSING", "blocker", { file: "使用记录.md", line: section.line, section: section.heading }, "No usage rows were parsed"); } if (section.metadata && !sameValues(section.headingRooms, section.metadataRooms)) { issue("SECTION_ROOM_HEADING_MISMATCH", "blocker", { file: "使用记录.md", line: section.line, section: section.heading }, `Heading rooms ${section.headingRooms.join(",")} differ from metadata rooms ${section.metadataRooms.join(",")}`); } for (const roomNo of section.metadataRooms) { if (!ownersByRoom.has(roomNo)) { issue("SECTION_OWNER_ROOM_NOT_FOUND", "blocker", { file: "使用记录.md", line: section.metadata?.line ?? section.line, section: section.heading, roomNo }, "Room number is absent from 业主账户.md"); } } if (section.metadataRooms.length > 1) { issue("SECTION_MULTIPLE_ENTITLEMENT_ROOMS", "review", { file: "使用记录.md", line: section.metadata?.line ?? section.line, section: section.heading }, `${section.metadataRooms.length} independent owner rooms are combined`); } if (section.metadata) { const explicitPurchased = explicitRoomTypeCodes(section.metadata.purchasedTypeRaw); if (!explicitPurchased.length) { issue("SECTION_PURCHASED_TYPE_NONCANONICAL", "review", { file: "使用记录.md", line: section.metadata.line, section: section.heading }, `Noncanonical purchased type: ${section.metadata.purchasedTypeRaw}`); } if (section.metadataRooms.length === 1) { const master = ownersByRoom.get(section.metadataRooms[0]); if ( master && explicitPurchased.length === 1 && explicitPurchased[0] !== master.roomType ) { issue("SECTION_PURCHASED_TYPE_MISMATCH", "blocker", { file: "使用记录.md", line: section.metadata.line, section: section.heading, roomNo: master.roomNo }, `Metadata says ${explicitPurchased[0]}, owner master says ${master.roomType}`); } if ( master && normalizedName(section.metadata.ownerName) !== normalizedName(master.name) ) { issue("SECTION_OWNER_NAME_MISMATCH", "review", { file: "使用记录.md", line: section.metadata.line, section: section.heading, roomNo: master.roomNo }, `Metadata owner "${section.metadata.ownerName}" differs from master "${master.name}"`); } } } const linkedOwners = section.metadataRooms .map(roomNo => ownersByRoom.get(roomNo)) .filter(Boolean); const balancesByYear = new Map(); const currentRuleBalancesByYear = new Map(); const initialBalance = section.metadataRooms.length * 15; for (const row of section.usageRows) { row.section = section.heading; row.sectionLine = section.line; row.metadataRooms = section.metadataRooms; row.headingRooms = section.headingRooms; row.linkedOwners = linkedOwners; row.balanceChainOk = false; allUsageRows.push(row); const location = { file: "使用记录.md", line: row.line, section: section.heading, confirmationNo: row.confirmationNo }; if (row.malformed) { issue("USAGE_ROW_MALFORMED", "blocker", location, `Expected 9 columns, found ${row.cells.length}`); continue; } if (!/^\d+$/.test(row.confirmationNo)) { issue("CONFIRMATION_NOT_DIGITS", "blocker", location, `Value is ${row.confirmationNo}`); } for (const field of ["night", "room", "use", "balance"]) { if (!Number.isInteger(row[field])) { issue("USAGE_INTEGER_INVALID", "blocker", location, `${field} is not an integer`); } } if (!validIsoDate(row.checkIn) || !validIsoDate(row.checkOut)) { issue("USAGE_DATE_INVALID", "blocker", location, `${row.checkIn} → ${row.checkOut}`); } else { const difference = dateDifference(row.checkIn, row.checkOut); if (difference !== row.night || difference <= 0) { issue("NIGHT_DATE_MISMATCH", "blocker", location, `Source Night ${row.night}; date difference ${difference}`); } if (row.checkIn.slice(0, 4) !== row.checkOut.slice(0, 4)) { issue("CROSS_YEAR_STAY", "blocker", location, `${row.checkIn} → ${row.checkOut}`); } } if (!Number.isInteger(row.room) || row.room <= 0) { issue("ROOM_COUNT_INVALID", "blocker", location, `Room is ${row.roomRaw}`); } else if (row.room > 1) { issue("ROOM_COUNT_GT_ONE", "transform", location, `Room=${row.room}; current usage model represents one room`); } if (!Number.isInteger(row.impliedEffectiveRatio)) { issue("IMPLIED_EFFECTIVE_RATIO_NOT_INTEGER", "blocker", location, `Use ${row.use} is not divisible by Night×Room`); } else if (row.impliedEffectiveRatio > 3) { issue("IMPLIED_EFFECTIVE_RATIO_GT_THREE", "blocker", location, `Use÷(Night×Room) implies ${row.impliedEffectiveRatio}; the source has no multiplier field`); } if (row.remarkRoomNumbers.length !== row.remarkRoomsFound) { issue("REMARK_ROOM_NOT_FOUND", "review", location, `Remark rooms ${row.remarkRoomNumbers.join(",")}; ${row.remarkRoomsFound} found in owner master`); } if ( Number.isInteger(row.room) && row.remarkRoomNumbers.length > 0 && row.remarkRoomNumbers.length !== row.room ) { issue("REMARK_ROOM_COUNT_MISMATCH", "review", location, `Room=${row.room}; Remark contains ${row.remarkRoomNumbers.length} room numbers`); } if (row.remarkRoomTypes.length > 1) { issue("REMARK_MULTIPLE_USED_TYPES", "transform", location, `Remark rooms resolve to ${row.remarkRoomTypes.join(",")}`); } if (row.usedTypeConflict) { issue("USED_TYPE_REMARK_CONFLICT", "blocker", location, `Raw code ${row.usedClassification.code}; Remark rooms resolve to ${row.resolvedUsedClassification.code}`); } if (!row.resolvedUsedClassification.code) { issue("USED_ROOM_TYPE_MAPPING_REQUIRED", "review", location, `${row.usedRoomTypeRaw} → candidates ${row.resolvedUsedClassification.candidates.join(",") || "unknown"}`); } if ( validIsoDate(row.checkIn) && Number.isInteger(row.use) && Number.isInteger(row.balance) && initialBalance > 0 ) { const year = row.checkIn.slice(0, 4); const balanceBefore = balancesByYear.get(year) ?? initialBalance; const expectedBalance = balanceBefore - row.use; row.balanceBeforeSource = balanceBefore; row.expectedSourceBalance = expectedBalance; row.balanceChainOk = row.balance === expectedBalance; if (!row.balanceChainOk) { issue("BALANCE_CHAIN_MISMATCH", "blocker", location, `Expected ${expectedBalance} after ${balanceBefore}-${row.use}; source has ${row.balance}`); } balancesByYear.set(year, row.balance); } const purchasedTypes = linkedOwners.map(owner => owner.roomType); const expectedMultiplier = automaticMultiplier( purchasedTypes, row.resolvedUsedClassification, row.impliedEffectiveRatio ); row.purchasedTypesForRule = purchasedTypes; row.expectedMultiplier = expectedMultiplier; row.expectedUseCurrentRule = expectedMultiplier !== null && Number.isInteger(row.night) && Number.isInteger(row.room) ? row.night * row.room * expectedMultiplier : null; row.currentRuleCompatible = expectedMultiplier !== null && row.use === row.expectedUseCurrentRule; if ( expectedMultiplier !== null && Number.isInteger(row.use) && Number.isInteger(row.night) && Number.isInteger(row.room) && !row.currentRuleCompatible ) { issue("CURRENT_RULE_USE_MISMATCH", "blocker", location, `Source Use ${row.use}; current room lookup/rules imply ${row.expectedUseCurrentRule}`); } if (validIsoDate(row.checkIn)) { const year = row.checkIn.slice(0, 4); const balanceBefore = currentRuleBalancesByYear.has(year) ? currentRuleBalancesByYear.get(year) : initialBalance; if ( balanceBefore !== null && Number.isInteger(row.expectedUseCurrentRule) ) { row.balanceBeforeCurrentRule = balanceBefore; row.balanceAfterCurrentRule = balanceBefore - row.expectedUseCurrentRule; row.currentRuleBalanceMatchesSource = row.balanceAfterCurrentRule === row.balance; if (!row.currentRuleBalanceMatchesSource) { issue("CURRENT_RULE_BALANCE_MISMATCH", "blocker", location, `Current-rule balance would be ${row.balanceAfterCurrentRule}; source has ${row.balance}`); } currentRuleBalancesByYear.set(year, row.balanceAfterCurrentRule); } else { row.currentRuleBalanceMatchesSource = null; currentRuleBalancesByYear.set(year, null); } } } } const usableRows = allUsageRows.filter(row => !row.malformed); const confirmationDuplicates = duplicateSummary( usableRows, row => row.confirmationNo, row => ({ line: row.line, section: row.section, balance: row.balance, usedRoomType: row.usedRoomTypeRaw }) ); for (const duplicate of confirmationDuplicates) { issue("CONFIRMATION_DUPLICATE", "blocker", { file: "使用记录.md", confirmationNo: duplicate.key }, `${duplicate.count} usage lines share this confirmation`); } const duplicateConfirmations = new Set(confirmationDuplicates.map(item => item.key)); for (const row of usableRows) { const singleLinkedOwner = row.metadataRooms.length === 1 && row.linkedOwners.length === 1; const headingMatches = sameValues(row.headingRooms, row.metadataRooms); const validDates = validIsoDate(row.checkIn) && validIsoDate(row.checkOut) && dateDifference(row.checkIn, row.checkOut) === row.night && row.checkIn.slice(0, 4) === row.checkOut.slice(0, 4); row.structurallyDirect = singleLinkedOwner && headingMatches && row.room === 1 && /^\d+$/.test(row.confirmationNo) && !duplicateConfirmations.has(row.confirmationNo) && validDates && row.balanceChainOk; row.fullyDirect = row.structurallyDirect && Boolean(row.resolvedUsedClassification.code) && !row.usedTypeConflict && row.currentRuleCompatible && row.currentRuleBalanceMatchesSource !== false; } const declaredOwnerCount = Number(ownerText.match(/共\s*(\d+)\s*条记录/)?.[1]); const declaredSectionCount = Number(usageText.match(/共\s*(\d+)\s*个房号子表/)?.[1]); const declaredUsageCount = Number(usageText.match(/、(\d+)\s*条实际使用记录/)?.[1]); const declaredCoveredRoomCount = Number(usageText.match(/覆盖\s*(\d+)\s*个房号/)?.[1]); if (declaredOwnerCount !== validOwnerRows.length) { issue("DECLARED_OWNER_COUNT_MISMATCH", "blocker", { file: "业主账户.md", line: 3 }, `Declared ${declaredOwnerCount}; parsed ${validOwnerRows.length}`); } if (declaredSectionCount !== sections.length) { issue("DECLARED_SECTION_COUNT_MISMATCH", "blocker", { file: "使用记录.md", line: 3 }, `Declared ${declaredSectionCount}; parsed ${sections.length}`); } if (declaredUsageCount !== usableRows.length) { issue("DECLARED_USAGE_COUNT_MISMATCH", "blocker", { file: "使用记录.md", line: 3 }, `Declared ${declaredUsageCount}; parsed ${usableRows.length}`); } const uniqueHeadingRooms = new Set(sections.flatMap(section => section.headingRooms)); if (declaredCoveredRoomCount !== uniqueHeadingRooms.size) { issue("DECLARED_COVERED_ROOM_COUNT_MISMATCH", "blocker", { file: "使用记录.md", line: 5 }, `Declared ${declaredCoveredRoomCount}; heading tokens contain ${uniqueHeadingRooms.size}`); } const issueCounts = Object.fromEntries( [...grouped(issues, item => item.code)] .sort(([left], [right]) => left.localeCompare(right)) .map(([code, values]) => [code, values.length]) ); const issueSamples = Object.fromEntries( [...grouped(issues, item => item.code)] .sort(([left], [right]) => left.localeCompare(right)) .map(([code, values]) => [code, values.slice(0, 8)]) ); const blockerIssues = issues.filter(item => item.severity === "blocker"); const transformIssues = issues.filter(item => item.severity === "transform"); const roomTypeDistribution = Object.fromEntries( [...grouped(validOwnerRows, owner => owner.roomType)] .sort(([left], [right]) => left.localeCompare(right)) .map(([code, values]) => [code, values.length]) ); const usedRoomTypeDistribution = Object.fromEntries( [...grouped(usableRows, row => row.usedRoomTypeRaw)] .sort((left, right) => right[1].length - left[1].length || left[0].localeCompare(right[0])) .map(([label, values]) => [label, values.length]) ); const impliedEffectiveRatioDistribution = Object.fromEntries( [...grouped(usableRows, row => String(row.impliedEffectiveRatio))] .sort(([left], [right]) => Number(left) - Number(right)) .map(([value, rows]) => [value, rows.length]) ); const yearDistribution = Object.fromEntries( [...grouped(usableRows, row => row.checkIn.slice(0, 4))] .sort(([left], [right]) => left.localeCompare(right)) .map(([year, rows]) => [year, rows.length]) ); const metadataValues = sections .filter(section => section.metadata) .map(section => ({ line: section.metadata.line, section: section.heading, transferOnRaw: section.metadata.transferOnRaw, purchasedTypeRaw: section.metadata.purchasedTypeRaw })); const report = { mode: "read-only", sourceFiles: { owners: "业主账户.md", usage: "使用记录.md" }, owners: { declaredRecords: declaredOwnerCount, parsedRecords: validOwnerRows.length, malformedRows: ownerRows.length - validOwnerRows.length, uniqueRoomNumbers: new Set(validOwnerRows.map(owner => owner.roomNo)).size, roomTypeDistribution, importDefaultsRequired: { accountNo: "not supplied; database column is nullable", transferDate: "not supplied in 业主账户.md; database column is nullable" }, maxFieldLengths: { name: Math.max(...validOwnerRows.map(owner => owner.name.length)), roomNo: Math.max(...validOwnerRows.map(owner => owner.roomNo.length)), roomType: Math.max(...validOwnerRows.map(owner => owner.roomType.length)), unitNo: Math.max(...validOwnerRows.map(owner => owner.unitNo.length)), memberNo: Math.max(...validOwnerRows.map(owner => owner.memberNo.length)) }, duplicateRoomNumbers: ownerRoomDuplicates, duplicateMemberNumbers: duplicateSummary( validOwnerRows, owner => owner.memberNo, owner => ({ line: owner.line, roomNo: owner.roomNo, name: owner.name }) ), duplicateUnitNumbers: duplicateSummary( validOwnerRows, owner => owner.unitNo, owner => ({ line: owner.line, roomNo: owner.roomNo, name: owner.name }) ) }, usage: { declaredSections: declaredSectionCount, parsedSections: sections.length, declaredRecords: declaredUsageCount, parsedRecords: usableRows.length, declaredCoveredRooms: declaredCoveredRoomCount, uniqueHeadingRooms: uniqueHeadingRooms.size, uniqueMetadataRooms: new Set(sections.flatMap(section => section.metadataRooms)).size, uniqueConfirmations: new Set(usableRows.map(row => row.confirmationNo)).size, confirmationDuplicates, nonDigitConfirmations: usableRows .filter(row => !/^\d+$/.test(row.confirmationNo)) .map(row => ({ line: row.line, section: row.section, confirmationNo: row.confirmationNo })), yearDistribution, impliedEffectiveRatioDistribution, roomCountDistribution: Object.fromEntries( [...grouped(usableRows, row => String(row.room))] .sort(([left], [right]) => Number(left) - Number(right)) .map(([value, rows]) => [value, rows.length]) ), usedRoomTypeDistribution, rawCanonicalUsedTypeRows: usableRows.filter(row => row.usedClassification.kind === "canonical").length, rawAliasUsedTypeRows: usableRows.filter(row => row.usedClassification.kind === "alias").length, rawUnknownUsedTypeRows: usableRows.filter(row => row.usedClassification.kind === "unknown").length, remarkResolvedUsedTypeRows: usableRows.filter(row => row.resolvedUsedClassification.kind === "remark").length, resolvedCanonicalUsedTypeRows: usableRows.filter(row => Boolean(row.resolvedUsedClassification.code)).length, unresolvedUsedTypeRows: usableRows.filter(row => !row.resolvedUsedClassification.code).length, usedTypeConflictRows: usableRows.filter(row => row.usedTypeConflict).length, structurallyDirectRows: usableRows.filter(row => row.structurallyDirect).length, fullyDirectRows: usableRows.filter(row => row.fullyDirect).length, currentRuleCompatibleRows: usableRows.filter(row => row.currentRuleCompatible).length, currentRuleEvaluatedRows: usableRows.filter(row => row.expectedMultiplier !== null).length, balanceChainValidRows: usableRows.filter(row => row.balanceChainOk).length, currentRuleBalanceEvaluatedRows: usableRows.filter(row => typeof row.currentRuleBalanceMatchesSource === "boolean").length, currentRuleBalanceMatchingRows: usableRows.filter(row => row.currentRuleBalanceMatchesSource === true).length, currentRuleBalanceMismatchingRows: usableRows.filter(row => row.currentRuleBalanceMatchesSource === false).length, maxRoomCount: Math.max(...usableRows.map(row => row.room ?? 0)), maxBalance: Math.max(...usableRows.map(row => row.balance ?? 0)), maxFieldLengths: { confirmationNo: Math.max(...usableRows.map(row => row.confirmationNo.length)), usedRoomTypeRaw: Math.max(...usableRows.map(row => row.usedRoomTypeRaw.length)), remark: Math.max(...usableRows.map(row => row.remark.length)) } }, associations: { multiRoomSections: sections .filter(section => section.metadataRooms.length > 1) .map(section => ({ line: section.line, heading: section.heading, metadataRooms: section.metadataRooms, usageRows: section.usageRows.length })), headingMetadataMismatches: sections .filter(section => !sameValues(section.headingRooms, section.metadataRooms)) .map(section => ({ line: section.line, heading: section.heading, headingRooms: section.headingRooms, metadataRooms: section.metadataRooms })), metadataRoomsAbsentFromOwners: [...new Set( sections .flatMap(section => section.metadataRooms) .filter(roomNo => !ownersByRoom.has(roomNo)) )], rowsWithRemarkRoomNumbers: usableRows.filter(row => row.remarkRoomNumbers.length > 0).length, rowsWithAllRemarkRoomsFound: usableRows.filter(row => row.remarkRoomNumbers.length > 0 && row.remarkRoomNumbers.length === row.remarkRoomsFound ).length, rowsWithMultipleRemarkRoomTypes: usableRows.filter(row => row.remarkRoomTypes.length > 1).length }, metadata: { isoTransferDateRows: metadataValues.filter(item => validIsoDate(item.transferOnRaw)).length, nonIsoOrCompoundTransferDateRows: metadataValues .filter(item => !validIsoDate(item.transferOnRaw)) .map(item => ({ line: item.line, section: item.section, transferOnRaw: item.transferOnRaw })), canonicalPurchasedTypeRows: metadataValues.filter(item => explicitRoomTypeCodes(item.purchasedTypeRaw).length > 0 ).length, noncanonicalPurchasedTypeRows: metadataValues .filter(item => explicitRoomTypeCodes(item.purchasedTypeRaw).length === 0) .map(item => ({ line: item.line, section: item.section, purchasedTypeRaw: item.purchasedTypeRaw })) }, issueCounts, issueSamples, blockerIssues, transformIssues, severityCounts: Object.fromEntries( [...grouped(issues, item => item.severity)] .sort(([left], [right]) => left.localeCompare(right)) .map(([severity, values]) => [severity, values.length]) ) }; process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);