Files
wyndham-Condon/backend/scripts/audit-import-data.mjs
2026-08-02 13:33:13 +08:00

886 lines
31 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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(/<br\s*\/?>/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`);