feat: add Condo owner desk frontend and backend

This commit is contained in:
Wyndham ARR
2026-08-02 13:33:13 +08:00
commit ed6229c203
54 changed files with 18640 additions and 0 deletions

View File

@@ -0,0 +1,885 @@
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`);