886 lines
31 KiB
JavaScript
886 lines
31 KiB
JavaScript
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`);
|